diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 084c800b7..2ac8f2adc 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -24,6 +24,7 @@ and related GitHub configuration. | CI step | Local command | |---------|---------------| | Run tests | `./gradlew test` | +| Run tests (guarded, same guards as CI) | `bash .github/scripts/test-watchdog.sh` | | Verify maintainability baseline | `./gradlew verifyMaintainabilityBaseline` | | Full maintainability verification | `./gradlew verifyFullMaintainabilityBaseline` | | Change policy check | `./gradlew verifyChangePolicy` | diff --git a/.github/scripts/test-watchdog.sh b/.github/scripts/test-watchdog.sh new file mode 100755 index 000000000..e432badc4 --- /dev/null +++ b/.github/scripts/test-watchdog.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Guarded test runner for the CI `tests` job. +# +# A hanging suite and a merely slow suite need different treatment, so this carries two independent +# guards: +# +# inactivity no observable progress for WATCHDOG_INACTIVITY_SECONDS -> a real stall, abort now +# ceiling wall-clock WATCHDOG_CEILING_SECONDS -> a growth margin, abort regardless of progress +# +# Progress is read from Gradle's own test-result tree, which advances as each test executes. Both +# guards dump worker threads first, so the uploaded artifact shows where the run stopped. +# +# Calibration consequence: the ceiling must sit comfortably above the suite's real runtime. If the +# suite grows into the ceiling, the guard silently becomes an assertion about suite duration rather +# than a hang detector, and healthy runs get aborted at random. +set -uo pipefail + +CEILING_SECONDS="${WATCHDOG_CEILING_SECONDS:-1200}" +INACTIVITY_SECONDS="${WATCHDOG_INACTIVITY_SECONDS:-270}" +POLL_SECONDS="${WATCHDOG_POLL_SECONDS:-30}" +DUMP_DIR="${WATCHDOG_DUMP_DIR:-/tmp/threaddumps}" +RESULTS_ROOT="${WATCHDOG_RESULTS_ROOT:-.}" +COMMAND="${WATCHDOG_COMMAND:-./gradlew test}" + +mkdir -p "$DUMP_DIR" + +progress_fingerprint() { + find "$RESULTS_ROOT" -path '*/build/test-results/*' -type f -printf '%p %s %T@\n' 2>/dev/null | + sort | md5sum | cut -d' ' -f1 +} + +abort() { + echo "::error::$1" + for P in $(jps -l 2>/dev/null | grep -iE 'GradleWorkerMain|GradleDaemon' | awk '{print $1}'); do + jstack "$P" >"$DUMP_DIR/worker-$P-$(date +%H%M%S).txt" 2>&1 || true + done + kill "$CHILD_PID" 2>/dev/null || true + wait "$CHILD_PID" 2>/dev/null || true + exit 1 +} + +echo "watchdog: ceiling=${CEILING_SECONDS}s inactivity=${INACTIVITY_SECONDS}s poll=${POLL_SECONDS}s results=${RESULTS_ROOT}" + +bash -c "exec ${COMMAND}" & +CHILD_PID=$! + +started=$(date +%s) +last_progress=$started +fingerprint=$(progress_fingerprint) + +while kill -0 "$CHILD_PID" 2>/dev/null; do + sleep "$POLL_SECONDS" + now=$(date +%s) + current=$(progress_fingerprint) + if [ "$current" != "$fingerprint" ]; then + fingerprint="$current" + last_progress="$now" + fi + idle=$((now - last_progress)) + elapsed=$((now - started)) + if [ "$idle" -ge "$INACTIVITY_SECONDS" ]; then + abort "Run tests made no progress for ${idle}s (limit ${INACTIVITY_SECONDS}s) — dumping threads and aborting" + fi + if [ "$elapsed" -ge "$CEILING_SECONDS" ]; then + abort "Run tests reached the ${CEILING_SECONDS}s wall-clock ceiling — dumping threads and aborting" + fi +done + +wait "$CHILD_PID" +exit $? diff --git a/.github/workflows/architecture-authority.yml b/.github/workflows/architecture-authority.yml new file mode 100644 index 000000000..dd01f3f96 --- /dev/null +++ b/.github/workflows/architecture-authority.yml @@ -0,0 +1,84 @@ +name: Architecture Authority + +# The API architecture authority (verify060Architecture) aggregates Contract-1 (committed dump vs +# source), Contract-2 (base-branch compatibility + stability policy) and the migration-registry +# hygiene rule. It ran in NO workflow at all, and in no verifyPr lane either, so release-line drift +# in the API/migration policy stayed invisible until someone ran it by hand — twice (0.7.1c and +# 0.7.1d), the second time as a permanent red that no PR could clear without deleting release +# history. +# +# It lives in its own workflow rather than in ci.yml because ci.yml is the JDK 25 production matrix +# (.github/AGENTS.md rule 6), while this gate is a build-logic authority documented against JDK 21. +# It lives outside maintainability-baseline.yml because that workflow triggers only for PRs against +# master, which would leave every epic-targeted slice — where this drift actually happens — +# unguarded. +# +# It invokes the single gate and its own base resolution; it does not re-implement the constituent +# checks. + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + api-architecture: + name: api-architecture (verify060Architecture) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Contract-2 and the registry chain walk compare against base-branch dumps, so the base + # SHA must be resolvable from history. + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + # The gate consumes -PchangePolicyBase, which is configuration-time and untracked as a + # task input, so a cached result could belong to a different base SHA. + cache-read-only: true + + - name: Resolve comparison base + id: base + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="${{ github.event.before }}" + fi + # A new branch push reports an all-zero (or empty) before-SHA; fall back to the pushed + # commit's parent so the gate still compares against something real. + case "$BASE" in + ""|0000000000000000000000000000000000000000) + BASE="$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)" + ;; + esac + echo "sha=$BASE" >> "$GITHUB_OUTPUT" + echo "Comparing against base $BASE" + + - name: Verify API architecture authority + run: | + ./gradlew verify060Architecture --no-daemon \ + -PchangePolicyBase="${{ steps.base.outputs.sha }}" + + - name: Upload architecture report + if: always() + uses: actions/upload-artifact@v4 + with: + name: api-architecture-report + path: build/reports/tramai/architecture/ + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4e9bce02..814948f32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: gates: spotlessCheck verifyStaticAnalysis verifyStaticSafetyGuards - name: hygiene gates: verifyDependencyHygiene + - name: junit-test-signatures + gates: verifyJUnitTestSignatures steps: - name: Checkout @@ -101,6 +103,16 @@ jobs: - name: static-analysis test-filter: "--tests 'dev.tramai.build.quality.StaticAnalysis*Test'" expected: 25 + # 0.7.1e: the API compatibility / migration-policy suites lived only in + # maintainability-baseline.yml, which triggers for PRs against master — so on every + # epic-targeted slice (where this policy actually drifts) they never ran. They are pure + # dump/policy logic, so they belong in this JDK 25 matrix like the other partitions. + - name: ci-wiring + test-filter: "--tests 'dev.tramai.build.quality.TestsWatchdogWiringTest'" + expected: 6 + - name: api-compatibility + test-filter: "--tests 'dev.tramai.build.quality.ApiCompatibilityMutationTest' --tests 'dev.tramai.build.quality.ApiBaselineVerifierTest' --tests 'dev.tramai.build.quality.ApiStabilityPolicyTest'" + expected: 67 steps: - name: Checkout @@ -149,29 +161,14 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Run tests - timeout-minutes: 15 - run: | - ./gradlew test & - GRADLE_PID=$! - DUMP_DIR=/tmp/threaddumps - mkdir -p "$DUMP_DIR" - # Containment watchdog: if tests exceed ~12 min, dump worker threads and - # fail fast instead of letting a concurrency flake hold the pipeline - # hostage for the full timeout. - for i in $(seq 1 18); do - if ! kill -0 "$GRADLE_PID" 2>/dev/null; then break; fi - sleep 40 - done - if kill -0 "$GRADLE_PID" 2>/dev/null; then - echo "::error::Run tests exceeded 12 minutes — dumping threads and aborting" - for P in $(jps -l 2>/dev/null | grep -iE 'GradleWorkerMain|GradleDaemon' | awk '{print $1}'); do - jstack "$P" > "$DUMP_DIR/worker-$P-$(date +%H%M%S).txt" 2>&1 || true - done - kill "$GRADLE_PID" 2>/dev/null || true - wait "$GRADLE_PID" 2>/dev/null || true - exit 1 - fi - wait "$GRADLE_PID" + timeout-minutes: 25 + env: + # Wall-clock ceiling: growth margin, not a hang signal. Healthy runs measure ~13-14 min, + # so 20 min absorbs suite growth while the inactivity guard below catches real stalls. + WATCHDOG_CEILING_SECONDS: "1200" + # No test-result progress for 4.5 min means the run is stalled, not slow. + WATCHDOG_INACTIVITY_SECONDS: "270" + run: bash ./.github/scripts/test-watchdog.sh - name: Upload test hang thread dumps if: always() @@ -220,13 +217,28 @@ jobs: run: | ./gradlew verifyMutationRatchet \ --no-daemon \ + -PtramaiMutationEvolution=${{ contains(github.event.pull_request.labels.*.name, 'mutation-population-evolution') && 'recorded-evolution' || 'forbid' }} \ -PtramaiMutationBaseSha="${{ github.event.pull_request.base.sha }}" - name: Verify mutation ratchet against previous master if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000' + # Evolution authority follows the transition, not the branch. A merge into master carries a + # mutation-population evolution only if it changed BOTH the committed population and the + # evolution records; anything else stays FORBID (fast, no PIT) so an ordinary push can never + # enter a ~50 minute campaign, and a removal without its records fails closed in FORBID. + # A legitimate evolution still has to survive the exact record-set check, which rejects + # records that do not match the identities disappearing in this transition. run: | + CHANGED=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}") + EVOLUTION="forbid" + if echo "$CHANGED" | grep -qx 'config/quality/mutation-baseline.json' && \ + echo "$CHANGED" | grep -qx 'config/quality/mutation-evolution.yml'; then + EVOLUTION="recorded-evolution" + fi + echo "mutation evolution mode: $EVOLUTION" ./gradlew verifyMutationRatchet \ --no-daemon \ + -PtramaiMutationEvolution="$EVOLUTION" \ -PtramaiMutationBaseSha="${{ github.event.before }}" consumer-smoke: diff --git a/.github/workflows/maintainability-baseline.yml b/.github/workflows/maintainability-baseline.yml index 82519cc6a..bccfa222f 100644 --- a/.github/workflows/maintainability-baseline.yml +++ b/.github/workflows/maintainability-baseline.yml @@ -104,7 +104,25 @@ jobs: # MutationRatchetAuthorityTest (7) = 257 -> 302. # 10.3d: W4 mutation ratchet wiring discriminator + W5 CI workflow wiring discriminators in CoverageWiringTest = 248 -> 253. # 12.3b (R12-003): Gradle 9 strict task dependency validation tests (+1 in policy-maintainability, +1 in scanners-coverage) = 302 -> 303, 253 -> 254. - # Counts: 8 + 71 + 75 + 25 (quality) + 174 + 303 + 254 = 910. + # 0.7.1e: ApiStabilityPolicyTest (24) enrolled in scanners-coverage = 254 -> 278. The suite was + # in no CI lane at all, so the release-line API-policy discriminators never ran in CI. + # Docs/version-surface repair: the example-selection guard derives the promoted release from + # CHANGELOG.md, so ExampleGuideReleaseAssertionTest (+3) enters release-sovereign = 174 -> 179. + # The same measurement certifies accumulated policy-maintainability drift = 303 -> 305 + # (both reproduced with the exact filters below; 0 failures). + # 0.7.1g1F follow-up (split out of the ceremony PR by review, scope hygiene): the pin is corrected to + # a MEASURED value. Measured through this lane's own 13 filters (extracted from the matrix below, not + # approximated): 333 on the epic line before the ceremony suite, 364 with it (0 failures both times; + # 333 + 31 = 364 cross-checks the two runs). The pin had been stale at 305 for the whole epic line + # because this workflow only triggers for pull requests targeting master, so epic-targeted PRs never + # exercised it. + # NOTE: the other lanes' pins in this matrix were NOT re-measured and may carry the same epic-line + # drift. Re-derive them before this workflow next runs against master. + # #426: sibling-catch cancellation recognition adds 8 scanner tests, and the 83-line + # context-equivalence case is split into three single-scenario tests (1 removed, 3 added). + # MERGED: master's 264 scanner tests + the epic's ApiStabilityPolicyTest (24) and nine other + # lane additions = 297, measured through this lane's own 20 filters after merging master in. + # Counts: 8 + 71 + 75 + 25 (quality) + 179 + 364 + 297 = 1019. build-logic-tests: name: build-logic (${{ matrix.name }}) runs-on: ubuntu-latest @@ -139,7 +157,7 @@ jobs: --tests 'dev.tramai.build.publishing.*' --tests 'dev.tramai.build.docs.*' --tests 'dev.tramai.build.conventions.*' - expected: 174 + expected: 179 - name: policy-maintainability test-filter: >- --tests 'dev.tramai.build.quality.ChangePolicy*Test' @@ -155,12 +173,13 @@ jobs: --tests 'dev.tramai.build.quality.DetektBaselineGrowthDriftTest' --tests 'dev.tramai.build.quality.DependencyBaselineVerifierTest' --tests 'dev.tramai.build.quality.DependencyEdgeNormalizerTest' - expected: 303 + expected: 364 - name: scanners-coverage test-filter: >- --tests 'dev.tramai.build.quality.KotlinCancellationCatchScannerTest' --tests 'dev.tramai.build.quality.ApiCompatibilityMutationTest' --tests 'dev.tramai.build.quality.ApiBaselineVerifierTest' + --tests 'dev.tramai.build.quality.ApiStabilityPolicyTest' --tests 'dev.tramai.build.quality.JUnitTestSignatureVerifierTest' --tests 'dev.tramai.build.quality.CancellationDeltaComparatorTest' --tests 'dev.tramai.build.quality.CoveragePolicyDeltaVerifierTest' @@ -177,7 +196,7 @@ jobs: --tests 'dev.tramai.build.quality.TestPerformanceVerifierTest' --tests 'dev.tramai.build.quality.TestPerformanceAggregatorTest' --tests 'dev.tramai.build.quality.ResolvedDependencyAggregateTest' - expected: 254 + expected: 297 steps: - name: Checkout @@ -228,4 +247,4 @@ jobs: name: build-logic-${{ matrix.name }}-test-reports path: | build-logic/build/test-results/test/ - build-logic/build/reports/tests/test/ + build-logic/build/reports/tests/test/ \ No newline at end of file diff --git a/.github/workflows/mutation-authority-integration.yml b/.github/workflows/mutation-authority-integration.yml new file mode 100644 index 000000000..1d783158e --- /dev/null +++ b/.github/workflows/mutation-authority-integration.yml @@ -0,0 +1,76 @@ +name: Mutation Authority Integration + +# The population-admission authority transport proof: the real `verifyMutationRatchet` task, under +# RECORDED_EVOLUTION, measuring the candidate population through a nested Gradle + PIT run, consuming +# a minted authorization and rejecting its retention (M38). +# +# `:build-logic:test` excludes `@Tag("integration")`, and `canonicalProbeIntegrationTest` only runs in +# the scheduled `maintainability-full` workflow when `generate-baseline=true`. Without this lane the +# proof is never executed by CI, so a transport regression could ship while every other check is green. +# It is deliberately narrow: the whole canonical-probe suite belongs in the scheduled workflow, not on +# every PR. +on: + pull_request: + paths: + - 'build-logic/src/main/kotlin/dev/tramai/build/quality/**' + - 'build-logic/src/test/kotlin/dev/tramai/build/quality/Mutation*.kt' + - 'build-logic/build.gradle.kts' + - 'config/quality/**' + - '.github/workflows/mutation-authority-integration.yml' + push: + branches: + - master + paths: + - 'build-logic/src/main/kotlin/dev/tramai/build/quality/**' + - 'build-logic/src/test/kotlin/dev/tramai/build/quality/Mutation*.kt' + - 'build-logic/build.gradle.kts' + - 'config/quality/**' + - '.github/workflows/mutation-authority-integration.yml' + +permissions: + contents: read + +concurrency: + group: mutation-authority-integration-${{ github.ref }} + cancel-in-progress: true + +jobs: + authority-transport: + name: Mutation authority transport + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run the population-admission authority transport proof + run: | + ./gradlew :build-logic:canonicalProbeIntegrationTest \ + --tests '*MutationPopulationAdmissionIntegrationTest*' \ + --no-daemon + + # A Gradle test task that selects nothing is green. Assert the proof actually executed the + # authority-transport cases. This is a non-vacuity FLOOR, not a frozen suite cardinality: the + # suite may grow as further transport discriminators are added, and what matters is that the + # proof ran, not that the class has exactly this many tests. + - name: Assert the proof was not vacuous + run: | + report=$(ls build-logic/build/test-results/canonicalProbeIntegrationTest/TEST-*MutationPopulationAdmissionIntegrationTest.xml) + executed=$(grep -o 'tests="[0-9]*"' "$report" | head -1 | tr -dc '0-9') + failures=$(grep -o 'failures="[0-9]*"' "$report" | head -1 | tr -dc '0-9') + errors=$(grep -o 'errors="[0-9]*"' "$report" | head -1 | tr -dc '0-9') + echo "executed=$executed failures=$failures errors=$errors" + [ "$executed" -ge 2 ] || { echo "::error::expected at least 2 authority-transport tests, got $executed"; exit 1; } + [ "$failures" = "0" ] && [ "$errors" = "0" ] || { echo "::error::authority-transport failures=$failures errors=$errors"; exit 1; } diff --git a/AGENTS.md b/AGENTS.md index e6e65b63d..aa1a2a2e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,19 @@ Examples of the expected rigor: - provider tests should assert deterministic routing, timeout handling, retry behavior, and error mapping - observability tests should assert span attributes and parse-failure events, not just that tracing code executed +For governance and authority machinery (mutation ratchets, population admissions, classification +enrollments, baseline growth gates), the test hierarchy is different, and a single level is never +enough: + +- **pure verifier tests prove semantics** — the rule is correct for the inputs it is handed; +- **real-task tests prove authority transport** — the Gradle task actually loads the repository + state (ledger, baseline, configuration) that the verifier assumes, and changing that real file + changes the task's outcome. + +Both are required. An authority system can be internally correct and still be ineffective because +the wrong state reaches it; a verifier-level discriminator cannot detect a missing +`Loader.load(rootDir)` at the call site. + ## Assertions and Invariants Write code that defends its invariants early and clearly. @@ -172,6 +185,17 @@ The architecture map is authoritative for navigation; do not duplicate it here. - report skipped checks explicitly - do not push while a required local check is failing +### Merge authorization + +An agent may push branches and open or update pull requests. An agent must **never** merge a pull +request that targets `master` or an `epic/**` branch: those merges require explicit authorization +from the repository owner, given after the review findings on the exact head have been read. + +Epic branches carry no branch protection or required-review rule, and an agent acting through the +owner's credentials would satisfy any such rule that did exist, so this instruction — not the +platform — is the merge gate. Green CI is necessary and never sufficient: exact-head CI, the +Architecture Authority and the Sovereign Runtime RC checks can all pass while no approval exists. + ## CI Failure Protocol When a pipeline fails: @@ -187,9 +211,14 @@ When a pipeline fails: - `workflow-defect` — CI workflow configuration is wrong - `flaky-external` — network, rate limit, or transient dependency failure 4. State the diagnosed category and evidence before editing. -5. Do not modify a gate until evidence shows the gate is incorrect. -6. Do not modify a deviation until the current and canonical populations have been measured. -7. After two unsuccessful fixes, stop and provide a root-cause report rather than applying another speculative patch. +5. Before attributing a failing required check to the candidate change, reproduce the same failing test + or suite at the exact pristine base when feasible. A failure may be classified as + pre-existing/environmental only when the base reproduces the same relevant failure signature; file + ownership or apparent unrelatedness alone is not evidence. Record both candidate and base + commands/results in the completion report. +6. Do not modify a gate until evidence shows the gate is incorrect. +7. Do not modify a deviation until the current and canonical populations have been measured. +8. After two unsuccessful fixes, stop and provide a root-cause report rather than applying another speculative patch. ## Completion Report Format diff --git a/build-logic/src/main/kotlin/dev/tramai/build/docs/DocsContractVerifierTask.kt b/build-logic/src/main/kotlin/dev/tramai/build/docs/DocsContractVerifierTask.kt index 8c5a6bce1..0bf7cb724 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/docs/DocsContractVerifierTask.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/docs/DocsContractVerifierTask.kt @@ -51,7 +51,6 @@ enum class DocGuardKind { */ @DisableCachingByDefault(because = "Verification task has no output artifact") abstract class DocsContractVerifierTask : DefaultTask() { - @get:Input abstract val contractId: Property @@ -116,30 +115,62 @@ abstract class DocsContractVerifierTask : DefaultTask() { fun verify() { val id = contractId.get() when (verifierKind.orNull) { - DocGuardKind.PRODUCT_POSITIONING -> RootDocGuardVerifiers.productPositioning(rootDir.get()) - DocGuardKind.README_POSITIONING -> RootDocGuardVerifiers.readmePositioning(rootDir.get()) - DocGuardKind.GOVERNED_WORKFLOW_ARTICLE -> RootDocGuardVerifiers.governedWorkflowArticle(rootDir.get()) - DocGuardKind.EXAMPLE_SELECTION_GUIDE -> RootDocGuardVerifiers.exampleSelectionGuide(rootDir.get()) - DocGuardKind.JVM_AI_FRAMEWORK_COMPARISON -> RootDocGuardVerifiers.jvmAiFrameworkComparison(rootDir.get()) - DocGuardKind.WORKFLOW_API_STABILITY_BOUNDARY -> RootDocGuardVerifiers.workflowApiStabilityBoundary(rootDir.get()) + DocGuardKind.PRODUCT_POSITIONING -> { + RootDocGuardVerifiers.productPositioning(rootDir.get()) + } + + DocGuardKind.README_POSITIONING -> { + RootDocGuardVerifiers.readmePositioning(rootDir.get()) + } + + DocGuardKind.GOVERNED_WORKFLOW_ARTICLE -> { + RootDocGuardVerifiers.governedWorkflowArticle(rootDir.get()) + } + + DocGuardKind.EXAMPLE_SELECTION_GUIDE -> { + RootDocGuardVerifiers.exampleSelectionGuide(rootDir.get()) + } + + DocGuardKind.JVM_AI_FRAMEWORK_COMPARISON -> { + RootDocGuardVerifiers.jvmAiFrameworkComparison(rootDir.get()) + } + + DocGuardKind.WORKFLOW_API_STABILITY_BOUNDARY -> { + RootDocGuardVerifiers.workflowApiStabilityBoundary(rootDir.get()) + } + DocGuardKind.VERSION_ALIGNMENT -> { - val releaseDate = expectedReleaseDate.orNull - ?: throw GradleException("tramaiReleaseDate must be set in gradle.properties") - RootDocGuardVerifiers.versionAlignment(rootDir.get(), expectedVersion.get(), releaseDate) + val releaseDate = + expectedReleaseDate.orNull + ?: throw GradleException("tramaiReleaseDate must be set in gradle.properties") + // Snapshot-aware version alignment (VersionAlignmentVerifier): on a development + // line the release-scoped surfaces describe the last promoted release, so the gate + // must not demand a dated section for the unreleased snapshot version. + verifyVersionAlignmentSurfaces(rootDir.get(), expectedVersion.get(), releaseDate) + } + + DocGuardKind.TOOL_GOVERNANCE_EXAMPLE -> { + RootDocGuardVerifiers.toolGovernanceExample(rootDir.get()) } - DocGuardKind.TOOL_GOVERNANCE_EXAMPLE -> RootDocGuardVerifiers.toolGovernanceExample(rootDir.get()) + DocGuardKind.MODULE_DOC_CONTRACT -> { val diagnostics = RootDocGuardVerifiers.moduleDocContract(rootDir.get()) if (diagnostics.isNotEmpty()) { throw GradleException( - diagnostics.joinToString("\n") { "[${it.code}] ${it.message}" } + diagnostics.joinToString("\n") { "[${it.code}] ${it.message}" }, ) } } - DocGuardKind.GENERIC_CLAIMS -> verifyGenericClaims(id) + + DocGuardKind.GENERIC_CLAIMS -> { + verifyGenericClaims(id) + } + // Fail loud instead of degrading to a vacuous pass: a registration // that forgets verifierKind.set() must never silently succeed. - null -> throw GradleException("$id: verifierKind is not configured; register with an explicit DocGuardKind") + null -> { + throw GradleException("$id: verifierKind is not configured; register with an explicit DocGuardKind") + } } logger.lifecycle("$id: documentation contract verification complete.") } diff --git a/build-logic/src/main/kotlin/dev/tramai/build/docs/PromotedReleaseVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/docs/PromotedReleaseVerifier.kt new file mode 100644 index 000000000..b6b9ffd13 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/docs/PromotedReleaseVerifier.kt @@ -0,0 +1,32 @@ +package dev.tramai.build.docs + +import java.io.File + +/* + * Promoted-release authority: the last release actually published for consumers. Distinct from + * `tramaiVersion`, which is the development/build version (a `-SNAPSHOT` line). Anything that + * describes published artifacts must name a promoted release rather than a development version or a + * historical literal that silently goes stale at every release cut. + */ + +/** Newest dated release heading in CHANGELOG.md: `## X.Y.Z - YYYY-MM-DD`, releases listed newest first. */ +private val DATED_RELEASE_HEADING = Regex("""(?m)^## (\d+\.\d+\.\d+) - (\d{4}-\d{2}-\d{2})""") + +/** + * The last promoted release: the first dated CHANGELOG heading, which is the newest by the + * repository's newest-first changelog convention. This is the single authority for "what is the + * newest promoted release, and when was it cut?" — [promotedReleaseDate] reads the same heading + * rather than re-parsing CHANGELOG.md. Fails closed when no dated heading exists. + */ +internal fun promotedReleaseVersion(rootDir: File): String = datedReleaseHeading(rootDir).groupValues[1] + +/** The date carried by the [promotedReleaseVersion] heading. */ +internal fun promotedReleaseDate(rootDir: File): String = datedReleaseHeading(rootDir).groupValues[2] + +private fun datedReleaseHeading(rootDir: File): MatchResult = + DATED_RELEASE_HEADING + .find(File(rootDir, "CHANGELOG.md").readText()) + ?: error( + "CHANGELOG.md must contain a dated release heading " + + "(## X.Y.Z - YYYY-MM-DD)", + ) diff --git a/build-logic/src/main/kotlin/dev/tramai/build/docs/RootDocGuardVerifiers.kt b/build-logic/src/main/kotlin/dev/tramai/build/docs/RootDocGuardVerifiers.kt index eadfd742a..fb9356d5c 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/docs/RootDocGuardVerifiers.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/docs/RootDocGuardVerifiers.kt @@ -2,7 +2,7 @@ package dev.tramai.build.docs import java.io.File -/** +/* * Pure documentation-contract verifiers for the root project (Epic 9.2d-a2). * * Each verifier is an exact, mechanical port of the historical root @@ -17,7 +17,11 @@ import java.io.File */ /** Shared section-extraction helper used by the example-guide and comparison verifiers. */ -private fun sectionBetween(text: String, start: String, end: String): String { +private fun sectionBetween( + text: String, + start: String, + end: String, +): String { val s = text.indexOf(start) require(s >= 0) { "Missing section start: '$start'" } val e = text.indexOf(end, s + start.length) @@ -27,9 +31,7 @@ private fun sectionBetween(text: String, start: String, end: String): String { /** verifyProductPositioning (root build.gradle.kts @ 66198f33, lines 3215–3349). */ object RootDocGuardVerifiers { - fun productPositioning(rootDir: File) { - val positioningDoc = File(rootDir, "docs/product/positioning.md") require(positioningDoc.isFile) { "Missing product positioning document at ${positioningDoc.absolutePath}." @@ -38,22 +40,23 @@ object RootDocGuardVerifiers { val text = positioningDoc.readText() // Required sections - val requiredSections = listOf( - "## Canonical Message", - "### Tagline", - "### One-Sentence Positioning", - "### Thirty-Second Description", - "## The Problem TramAI Solves", - "## Product Category", - "## Who TramAI Is For", - "## Representative Use Cases", - "## Product Pillars", - "## What TramAI Is Not", - "## Current Maturity", - "## Claim Boundaries", - "## Messaging Guide", - "## Source-of-Truth Documents", - ) + val requiredSections = + listOf( + "## Canonical Message", + "### Tagline", + "### One-Sentence Positioning", + "### Thirty-Second Description", + "## The Problem TramAI Solves", + "## Product Category", + "## Who TramAI Is For", + "## Representative Use Cases", + "## Product Pillars", + "## What TramAI Is Not", + "## Current Maturity", + "## Claim Boundaries", + "## Messaging Guide", + "## Source-of-Truth Documents", + ) for (section in requiredSections) { require(text.contains(section)) { "Missing required section: '$section'" @@ -79,17 +82,18 @@ object RootDocGuardVerifiers { } // Forbidden claims (case-insensitive, with punctuation variants) - val forbiddenClaims = listOf( - "fully compliant", - "guarantees compliance", - "production certified", - "production-certified", - "production-ready for every deployment", - "guarantees sovereignty", - "fully air-gapped by default", - "amount-threshold authorization is implemented", - "remote MCP tools are currently governed", - ) + val forbiddenClaims = + listOf( + "fully compliant", + "guarantees compliance", + "production certified", + "production-certified", + "production-ready for every deployment", + "guarantees sovereignty", + "fully air-gapped by default", + "amount-threshold authorization is implemented", + "remote MCP tools are currently governed", + ) for (claim in forbiddenClaims) { require(!text.contains(claim, ignoreCase = true)) { "Forbidden claim found: '$claim'" @@ -127,13 +131,14 @@ object RootDocGuardVerifiers { } // Must not contain stale language that implies no MCP runtime exists at all - val staleMcpPhrases = listOf( - "does not implement an MCP server", - "no MCP server exists", - "does not currently implement an MCP connector, MCP client, MCP server", - "before any MCP runtime implementation", - "when TramAI eventually supports MCP", - ) + val staleMcpPhrases = + listOf( + "does not implement an MCP server", + "no MCP server exists", + "does not currently implement an MCP connector, MCP client, MCP server", + "before any MCP runtime implementation", + "when TramAI eventually supports MCP", + ) for (phrase in staleMcpPhrases) { require(!mcpText.contains(phrase, ignoreCase = true)) { "Stale MCP language found: '$phrase'" @@ -152,8 +157,7 @@ object RootDocGuardVerifiers { } /** verifyReadmePositioning (root build.gradle.kts @ 66198f33, lines 3360–3470). */ -fun readmePositioning(rootDir: File) { - + fun readmePositioning(rootDir: File) { val readme = File(rootDir, "README.md") require(readme.isFile) { "Missing README.md at ${readme.absolutePath}" @@ -162,19 +166,20 @@ fun readmePositioning(rootDir: File) { val text = readme.readText() // Required phrases - val requiredPhrases = listOf( - "Governed AI Workflows for the JVM", - "Kotlin-first JVM runtime for governed AI workflows", - "./gradlew :examples:governed-workflow:run", - "examples/governed-workflow", - "examples/approval-resume", - "examples/sovereign-document-intelligence", - "docs/guides/quickstart.md", - "docs/guides/governed-workflow-quickstart.md", - "docs/STATUS.md", - "docs/product/positioning.md", - "active development", - ) + val requiredPhrases = + listOf( + "Governed AI Workflows for the JVM", + "Kotlin-first JVM runtime for governed AI workflows", + "./gradlew :examples:governed-workflow:run", + "examples/governed-workflow", + "examples/approval-resume", + "examples/sovereign-document-intelligence", + "docs/guides/quickstart.md", + "docs/guides/governed-workflow-quickstart.md", + "docs/STATUS.md", + "docs/product/positioning.md", + "active development", + ) for (phrase in requiredPhrases) { require(text.contains(phrase, ignoreCase = true)) { "README must contain: '$phrase'" @@ -188,24 +193,26 @@ fun readmePositioning(rootDir: File) { "README must contain the governed-workflow run command" } - val firstGradleIndex = Regex("""(?m)^\s*\./gradlew\b""") - .find(text) - ?.range - ?.first - ?: -1 + val firstGradleIndex = + Regex("""(?m)^\s*\./gradlew\b""") + .find(text) + ?.range + ?.first + ?: -1 require(firstGradleIndex == governedRunIndex) { "The governed-workflow command must be the first Gradle command in README.md. " + "Found another Gradle command at position $firstGradleIndex before governed-workflow at $governedRunIndex." } // Verify all README navigation links resolve to actual files - val navTargets = listOf( - "docs/architecture/overview.md", - "docs/modules/sovereign-runtime-module-matrix.md", - "examples/governed-workflow", - "examples/approval-resume", - "examples/sovereign-document-intelligence", - ) + val navTargets = + listOf( + "docs/architecture/overview.md", + "docs/modules/sovereign-runtime-module-matrix.md", + "examples/governed-workflow", + "examples/approval-resume", + "examples/sovereign-document-intelligence", + ) for (path in navTargets) { val target = File(rootDir, path) require(target.exists()) { @@ -214,17 +221,18 @@ fun readmePositioning(rootDir: File) { } // Forbidden claims (case-insensitive) - val forbiddenClaims = listOf( - "fully compliant", - "guarantees compliance", - "production certified", - "production-certified", - "production-ready for every deployment", - "guarantees sovereignty", - "fully air-gapped by default", - "amount-threshold authorization is implemented", - "remote MCP tools are currently governed", - ) + val forbiddenClaims = + listOf( + "fully compliant", + "guarantees compliance", + "production certified", + "production-certified", + "production-ready for every deployment", + "guarantees sovereignty", + "fully air-gapped by default", + "amount-threshold authorization is implemented", + "remote MCP tools are currently governed", + ) for (claim in forbiddenClaims) { require(!text.contains(claim, ignoreCase = true)) { "Forbidden claim in README: '$claim'" @@ -232,12 +240,13 @@ fun readmePositioning(rootDir: File) { } // No premature competitor comparisons (reserved for PR #196) - val forbiddenComparisons = listOf( - "Spring AI lacks", - "LangChain4j lacks", - "better than Spring AI", - "better than LangChain4j", - ) + val forbiddenComparisons = + listOf( + "Spring AI lacks", + "LangChain4j lacks", + "better than Spring AI", + "better than LangChain4j", + ) for (phrase in forbiddenComparisons) { require(!text.contains(phrase, ignoreCase = true)) { "Forbidden comparison in README: '$phrase'" @@ -245,16 +254,16 @@ fun readmePositioning(rootDir: File) { } // No stale roadmap sentence referencing completed phases - val staleSentence = "The next phase focuses on workflow ergonomics, API stability, " + - "structured output contracts, and runtime evidence" + val staleSentence = + "The next phase focuses on workflow ergonomics, API stability, " + + "structured output contracts, and runtime evidence" require(!text.contains(staleSentence, ignoreCase = true)) { "README must not contain stale roadmap sentence about completed phases" } } /** verifyGovernedWorkflowArticle (root build.gradle.kts @ 66198f33, lines 3484–3631). */ -fun governedWorkflowArticle(rootDir: File) { - + fun governedWorkflowArticle(rootDir: File) { val article = File(rootDir, "docs/articles/governed-ai-workflows-for-the-jvm.md") val talk = File(rootDir, "docs/talks/governed-ai-workflows-for-the-jvm.md") require(article.isFile) { @@ -268,20 +277,21 @@ fun governedWorkflowArticle(rootDir: File) { val talkText = talk.readText() // Required article headings - val requiredHeadings = listOf( - "## The Model Call Is the Easy Part", - "## Governance Cannot Live Only in Prompts", - "## What Makes a Workflow Governed", - "## A Concrete Example: Claim Triage", - "## Policy Before Side Effects", - "## Human Approval Is a Lifecycle", - "## Controlled Routing for Sensitive Workloads", - "## Evidence and Operational Recovery", - "## Why the JVM", - "## Composable Adoption", - "## What This Does Not Claim", - "## Try It", - ) + val requiredHeadings = + listOf( + "## The Model Call Is the Easy Part", + "## Governance Cannot Live Only in Prompts", + "## What Makes a Workflow Governed", + "## A Concrete Example: Claim Triage", + "## Policy Before Side Effects", + "## Human Approval Is a Lifecycle", + "## Controlled Routing for Sensitive Workloads", + "## Evidence and Operational Recovery", + "## Why the JVM", + "## Composable Adoption", + "## What This Does Not Claim", + "## Try It", + ) for (heading in requiredHeadings) { require(articleText.contains(heading)) { "Article missing required heading: '$heading'" @@ -289,19 +299,20 @@ fun governedWorkflowArticle(rootDir: File) { } // Required phrases - val requiredPhrases = listOf( - "Governed AI workflows for the JVM", - "when governance components are configured", - "active development", - "does not itself", - "make an organization compliant", - "./gradlew :examples:governed-workflow:run", - "policy-check", - "approval-required", - "replay-safe continuation", - "classification-aware routing", - "tamper-evident", - ) + val requiredPhrases = + listOf( + "Governed AI workflows for the JVM", + "when governance components are configured", + "active development", + "does not itself", + "make an organization compliant", + "./gradlew :examples:governed-workflow:run", + "policy-check", + "approval-required", + "replay-safe continuation", + "classification-aware routing", + "tamper-evident", + ) for (phrase in requiredPhrases) { require(articleText.contains(phrase, ignoreCase = true)) { "Article must contain: '$phrase'" @@ -317,14 +328,15 @@ fun governedWorkflowArticle(rootDir: File) { } // Required links with target existence validation - val requiredLinks = mapOf( - "../../README.md" to "README.md", - "../product/positioning.md" to "docs/product/positioning.md", - "../STATUS.md" to "docs/STATUS.md", - "../../examples/governed-workflow" to "examples/governed-workflow", - "../../examples/approval-resume" to "examples/approval-resume", - "../../examples/sovereign-document-intelligence" to "examples/sovereign-document-intelligence", - ) + val requiredLinks = + mapOf( + "../../README.md" to "README.md", + "../product/positioning.md" to "docs/product/positioning.md", + "../STATUS.md" to "docs/STATUS.md", + "../../examples/governed-workflow" to "examples/governed-workflow", + "../../examples/approval-resume" to "examples/approval-resume", + "../../examples/sovereign-document-intelligence" to "examples/sovereign-document-intelligence", + ) for ((link, targetPath) in requiredLinks) { require(articleText.contains(link)) { "Article must contain link: '$link'" @@ -336,13 +348,14 @@ fun governedWorkflowArticle(rootDir: File) { } // Required talk-outline sections - val requiredTalkSections = listOf( - "## Audience", - "## Thirty-Minute Version", - "## Forty-Five-Minute Version", - "## Demo Plan", - "## Speaker Claim Boundaries", - ) + val requiredTalkSections = + listOf( + "## Audience", + "## Thirty-Minute Version", + "## Forty-Five-Minute Version", + "## Demo Plan", + "## Speaker Claim Boundaries", + ) for (section in requiredTalkSections) { require(talkText.contains(section)) { "Talk outline missing required section: '$section'" @@ -350,24 +363,25 @@ fun governedWorkflowArticle(rootDir: File) { } // Forbidden claims (case-insensitive) - val forbiddenClaims = listOf( - "fully compliant", - "guarantees compliance", - "production certified", - "production-certified", - "production-ready for every deployment", - "guarantees sovereignty", - "fully air-gapped by default", - "amount-threshold authorization is implemented", - "remote MCP tools are currently governed", - "tamper-proof", - "every decision is always recorded", - "every workflow resumes exactly once", - "record every decision", - "every governance decision", - "evidence export worker", - "automatically exported as", - ) + val forbiddenClaims = + listOf( + "fully compliant", + "guarantees compliance", + "production certified", + "production-certified", + "production-ready for every deployment", + "guarantees sovereignty", + "fully air-gapped by default", + "amount-threshold authorization is implemented", + "remote MCP tools are currently governed", + "tamper-proof", + "every decision is always recorded", + "every workflow resumes exactly once", + "record every decision", + "every governance decision", + "evidence export worker", + "automatically exported as", + ) for (claim in forbiddenClaims) { require(!articleText.contains(claim, ignoreCase = true)) { "Forbidden claim in article: '$claim'" @@ -375,12 +389,13 @@ fun governedWorkflowArticle(rootDir: File) { } // No premature competitor comparisons (reserved for PR #196) - val forbiddenComparisons = listOf( - "Spring AI lacks", - "LangChain4j lacks", - "better than Spring AI", - "better than LangChain4j", - ) + val forbiddenComparisons = + listOf( + "Spring AI lacks", + "LangChain4j lacks", + "better than Spring AI", + "better than LangChain4j", + ) for (phrase in forbiddenComparisons) { require(!articleText.contains(phrase, ignoreCase = true)) { "Forbidden comparison in article: '$phrase'" @@ -389,8 +404,7 @@ fun governedWorkflowArticle(rootDir: File) { } /** verifyExampleSelectionGuide (root build.gradle.kts @ 66198f33, lines 3637–3923). */ -fun exampleSelectionGuide(rootDir: File) { - + fun exampleSelectionGuide(rootDir: File) { val guide = File(rootDir, "examples/README.md") require(guide.isFile) { "Missing example selection guide at ${guide.absolutePath}" @@ -398,14 +412,15 @@ fun exampleSelectionGuide(rootDir: File) { val text = guide.readText() // Required headings - val requiredHeadings = listOf( - "## Start Here", - "## Choose by Goal", - "## Example Matrix", - "## Recommended Learning Paths", - "## Example Profiles", - "## What the Examples Do Not Prove", - ) + val requiredHeadings = + listOf( + "## Start Here", + "## Choose by Goal", + "## Example Matrix", + "## Recommended Learning Paths", + "## Example Profiles", + "## What the Examples Do Not Prove", + ) for (heading in requiredHeadings) { require(text.contains(heading)) { "Guide missing required heading: '$heading'" @@ -413,16 +428,17 @@ fun exampleSelectionGuide(rootDir: File) { } // Required profiles - val requiredProfiles = listOf( - "### Governed Workflow", - "### Support Agent", - "### Kotlin Spring Boot Example", - "### Approval Resume", - "### Spring Sovereign Starter", - "### Sovereign Document Intelligence", - "### Sovereign Offline Verification", - "### Sovereign Lab", - ) + val requiredProfiles = + listOf( + "### Governed Workflow", + "### Support Agent", + "### Kotlin Spring Boot Example", + "### Approval Resume", + "### Spring Sovereign Starter", + "### Sovereign Document Intelligence", + "### Sovereign Offline Verification", + "### Sovereign Lab", + ) for (profile in requiredProfiles) { require(text.contains(profile)) { "Guide missing required profile: '$profile'" @@ -430,16 +446,17 @@ fun exampleSelectionGuide(rootDir: File) { } // Required commands - val requiredCommands = listOf( - "./gradlew :examples:governed-workflow:run", - "./gradlew :examples:support-agent:run", - "./gradlew :examples:approval-resume:test", - "./gradlew :examples:spring-sovereign-starter:bootRun", - "./gradlew :examples:sovereign-document-intelligence:run", - "./gradlew -p examples/kotlin-springboot-example bootRun", - "./scripts/verify-zero-egress.sh", - "./gradlew verifySovereignLabProfile", - ) + val requiredCommands = + listOf( + "./gradlew :examples:governed-workflow:run", + "./gradlew :examples:support-agent:run", + "./gradlew :examples:approval-resume:test", + "./gradlew :examples:spring-sovereign-starter:bootRun", + "./gradlew :examples:sovereign-document-intelligence:run", + "./gradlew -p examples/kotlin-springboot-example bootRun", + "./scripts/verify-zero-egress.sh", + "./gradlew verifySovereignLabProfile", + ) for (cmd in requiredCommands) { require(text.contains(cmd)) { "Guide must contain command: '$cmd'" @@ -447,18 +464,19 @@ fun exampleSelectionGuide(rootDir: File) { } // Required phrases - val requiredPhrases = listOf( - "no credentials", - "embedded PostgreSQL", - "no Docker", - "separate Gradle build", - "in-memory", - "reference workflow", - "verification harness", - "physical local-model evaluation", - "not a production deployment template", - "active development", - ) + val requiredPhrases = + listOf( + "no credentials", + "embedded PostgreSQL", + "no Docker", + "separate Gradle build", + "in-memory", + "reference workflow", + "verification harness", + "physical local-model evaluation", + "not a production deployment template", + "active development", + ) for (phrase in requiredPhrases) { require(text.contains(phrase, ignoreCase = true)) { "Guide must contain: '$phrase'" @@ -467,13 +485,14 @@ fun exampleSelectionGuide(rootDir: File) { // Link-to-target validation — verifies both that the link text exists // in the guide AND that the target file exists on disk - val requiredLinks = mapOf( - "spring-sovereign-starter/README.md" to - "examples/spring-sovereign-starter/README.md", - "kotlin-springboot-example/README.md" to - "examples/kotlin-springboot-example/README.md", - "sovereign-lab/README.md" to "examples/sovereign-lab/README.md", - ) + val requiredLinks = + mapOf( + "spring-sovereign-starter/README.md" to + "examples/spring-sovereign-starter/README.md", + "kotlin-springboot-example/README.md" to + "examples/kotlin-springboot-example/README.md", + "sovereign-lab/README.md" to "examples/sovereign-lab/README.md", + ) for ((link, targetPath) in requiredLinks) { require(text.contains(link)) { "Guide must contain relative link: $link" @@ -499,14 +518,15 @@ fun exampleSelectionGuide(rootDir: File) { // Root example modules in settings.gradle.kts val settingsText = File(rootDir, "settings.gradle.kts").readText() - val rootModules = listOf( - "examples:support-agent", - "examples:sovereign-document-intelligence", - "examples:sovereign-offline-verification", - "examples:spring-sovereign-starter", - "examples:governed-workflow", - "examples:approval-resume", - ) + val rootModules = + listOf( + "examples:support-agent", + "examples:sovereign-document-intelligence", + "examples:sovereign-offline-verification", + "examples:spring-sovereign-starter", + "examples:governed-workflow", + "examples:approval-resume", + ) for (module in rootModules) { require(settingsText.contains("\"$module\"")) { "settings.gradle.kts must still include root example module: $module" @@ -550,8 +570,13 @@ fun exampleSelectionGuide(rootDir: File) { require(ktSection.contains("separate Gradle build")) { "Kotlin Spring Boot Example section must contain 'separate Gradle build'" } - require(ktSection.contains("0.4.0")) { - "Kotlin Spring Boot Example section must contain '0.4.0'" + // This example consumes published artifacts, so the section must name the last promoted + // release. Deriving it keeps the guard honest across release cuts; the literal it replaced + // had gone stale and made the full release closure red on master. + val promotedRelease = promotedReleaseVersion(rootDir) + require(ktSection.contains(promotedRelease)) { + "Kotlin Spring Boot Example section must name the last promoted release " + + "'$promotedRelease' it consumes as published dependencies" } require(ktSection.contains("gemma4:e4b")) { "Kotlin Spring Boot Example section must specify 'gemma4:e4b' model" @@ -618,19 +643,20 @@ fun exampleSelectionGuide(rootDir: File) { } // Forbidden claims (case-insensitive) - val forbiddenClaims = listOf( - "all examples require no credentials", - "all examples are production-ready", - "proves compliance", - "certifies compliance", - "guarantees sovereignty", - "LOCAL means air-gapped", - "every TramAI deployment has zero egress", - "all side effects execute exactly once", - "every workflow resumes exactly once", - "remote MCP tools are governed", - "support-agent demonstrates sovereign governance", - ) + val forbiddenClaims = + listOf( + "all examples require no credentials", + "all examples are production-ready", + "proves compliance", + "certifies compliance", + "guarantees sovereignty", + "LOCAL means air-gapped", + "every TramAI deployment has zero egress", + "all side effects execute exactly once", + "every workflow resumes exactly once", + "remote MCP tools are governed", + "support-agent demonstrates sovereign governance", + ) for (claim in forbiddenClaims) { require(!text.contains(claim, ignoreCase = true)) { "Forbidden claim in guide: '$claim'" @@ -638,12 +664,13 @@ fun exampleSelectionGuide(rootDir: File) { } // No premature competitor comparisons (reserved for PR #196) - val forbiddenComparisons = listOf( - "Spring AI lacks", - "LangChain4j lacks", - "better than Spring AI", - "better than LangChain4j", - ) + val forbiddenComparisons = + listOf( + "Spring AI lacks", + "LangChain4j lacks", + "better than Spring AI", + "better than LangChain4j", + ) for (phrase in forbiddenComparisons) { require(!text.contains(phrase, ignoreCase = true)) { "Forbidden comparison in guide: '$phrase'" @@ -652,8 +679,7 @@ fun exampleSelectionGuide(rootDir: File) { } /** verifyJvmAiFrameworkComparison (root build.gradle.kts @ 66198f33, lines 3929–4161). */ -fun jvmAiFrameworkComparison(rootDir: File) { - + fun jvmAiFrameworkComparison(rootDir: File) { val doc = File(rootDir, "docs/comparison/jvm-ai-frameworks.md") require(doc.isFile) { "Missing comparison document at ${doc.absolutePath}" @@ -671,32 +697,36 @@ fun jvmAiFrameworkComparison(rootDir: File) { val coexistenceSection = sectionBetween(text, "## Coexistence and Migration", "## Limitations and Non-Claims") // Spring AI content: optimization + capability (table + qualification) + choice - val springAiContent = springOptimization + "\n" + - sectionBetween(text, "## Capability Comparison", "## Choose LangChain4j When") + val springAiContent = + springOptimization + "\n" + + sectionBetween(text, "## Capability Comparison", "## Choose LangChain4j When") // LangChain4j content: optimization + capability (table + qualification) + choice - val langChainContent = langChainOptimization + "\n" + - sectionBetween(text, "## Capability Comparison", "## Choose TramAI When") + val langChainContent = + langChainOptimization + "\n" + + sectionBetween(text, "## Capability Comparison", "## Choose TramAI When") // TramAI content: comparison table + choice + weaknesses - val tramaiContent = sectionBetween(text, "## Capability Comparison", "## Choose Spring AI When") + "\n" + - tramaiChoiceSection + "\n" + weaknessesSection + val tramaiContent = + sectionBetween(text, "## Capability Comparison", "## Choose Spring AI When") + "\n" + + tramaiChoiceSection + "\n" + weaknessesSection // Required headings - val requiredHeadings = listOf( - "## Scope and Method", - "## Version and Source Snapshot", - "## What the Three Projects Optimize For", - "## Shared Capabilities", - "## Capability Comparison", - "## Choose Spring AI When", - "## Choose LangChain4j When", - "## Choose TramAI When", - "## Where TramAI Is Weaker Today", - "## Coexistence and Migration", - "## Limitations and Non-Claims", - "## Source Notes", - ) + val requiredHeadings = + listOf( + "## Scope and Method", + "## Version and Source Snapshot", + "## What the Three Projects Optimize For", + "## Shared Capabilities", + "## Capability Comparison", + "## Choose Spring AI When", + "## Choose LangChain4j When", + "## Choose TramAI When", + "## Where TramAI Is Weaker Today", + "## Coexistence and Migration", + "## Limitations and Non-Claims", + "## Source Notes", + ) for (heading in requiredHeadings) { require(text.contains(heading)) { "Comparison missing required heading: '$heading'" @@ -704,15 +734,16 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Required snapshot phrases - val snapshotPhrases = listOf( - "July 12, 2026", - "Spring AI 2.0.0", - "LangChain4j 1.17.2", - "0.4.0", - "dated snapshot", - "official documentation", - "not an evergreen benchmark", - ) + val snapshotPhrases = + listOf( + "July 12, 2026", + "Spring AI 2.0.0", + "LangChain4j 1.17.2", + "0.4.0", + "dated snapshot", + "official documentation", + "not an evergreen benchmark", + ) for (phrase in snapshotPhrases) { require(text.contains(phrase, ignoreCase = true)) { "Comparison must contain: '$phrase'" @@ -720,16 +751,17 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Required Spring AI acknowledgements (scoped to Spring AI sections) - val springAiTerms = listOf( - "ChatClient", - "Advisors", - "ToolCallingManager", - "structured output", - "observability", - "MCP client", - "MCP server", - "RAG", - ) + val springAiTerms = + listOf( + "ChatClient", + "Advisors", + "ToolCallingManager", + "structured output", + "observability", + "MCP client", + "MCP server", + "RAG", + ) for (term in springAiTerms) { require(springAiContent.contains(term, ignoreCase = true)) { "Spring AI section must acknowledge '$term'" @@ -737,18 +769,19 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Required LangChain4j acknowledgements (scoped to LangChain4j sections) - val langchainTerms = listOf( - "AI Services", - "structured outputs", - "guardrails", - "HumanInTheLoop", - "PendingResponse", - "persistent `AgenticScope`", - "dynamic model selection", - "compensation", - "MCP client", - "experimental", - ) + val langchainTerms = + listOf( + "AI Services", + "structured outputs", + "guardrails", + "HumanInTheLoop", + "PendingResponse", + "persistent `AgenticScope`", + "dynamic model selection", + "compensation", + "MCP client", + "experimental", + ) for (term in langchainTerms) { require(langChainContent.contains(term, ignoreCase = true)) { "LangChain4j section must acknowledge '$term'" @@ -756,16 +789,17 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Required TramAI boundaries (scoped to TramAI sections: comparison table, choice, weaknesses) - val tramaiTerms = listOf( - "policy", - "DLP", - "approval", - "replay-safe", - "trust-zone", - "tamper-evident", - "RC+", - "active development", - ) + val tramaiTerms = + listOf( + "policy", + "DLP", + "approval", + "replay-safe", + "trust-zone", + "tamper-evident", + "RC+", + "active development", + ) for (term in tramaiTerms) { require(tramaiContent.contains(term, ignoreCase = true)) { "TramAI section must contain '$term'" @@ -773,31 +807,34 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Required maturity acknowledgements (scoped to relevant sections) - val langChainMaturityTerms = listOf( - "guardrails are experimental", - "agentic module is experimental", - ) + val langChainMaturityTerms = + listOf( + "guardrails are experimental", + "agentic module is experimental", + ) for (term in langChainMaturityTerms) { require(langChainContent.contains(term, ignoreCase = true)) { "Comparison must acknowledge maturity: '$term'" } } - val springMaturityTerms = listOf( - "MCP security", - "work in progress", - ) + val springMaturityTerms = + listOf( + "MCP security", + "work in progress", + ) for (term in springMaturityTerms) { require(springAiContent.contains(term, ignoreCase = true)) { "Comparison must acknowledge maturity: '$term'" } } - val tramaiMaturityTerms = listOf( - "governed remote MCP client", - "not implemented", - "no stable sovereign 1.0 API", - ) + val tramaiMaturityTerms = + listOf( + "governed remote MCP client", + "not implemented", + "no stable sovereign 1.0 API", + ) for (term in tramaiMaturityTerms) { require(tramaiContent.contains(term, ignoreCase = true)) { "Comparison must acknowledge maturity: '$term'" @@ -805,12 +842,13 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Required coexistence boundaries (scoped to Coexistence section) - val coexistenceTerms = listOf( - "not a drop-in replacement", - "no official interoperability adapter", - "architectural composition", - "not a shipped adapter", - ) + val coexistenceTerms = + listOf( + "not a drop-in replacement", + "no official interoperability adapter", + "architectural composition", + "not a shipped adapter", + ) for (term in coexistenceTerms) { require(coexistenceSection.contains(term, ignoreCase = true)) { "Comparison must contain coexistence boundary: '$term'" @@ -832,15 +870,16 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Forbidden claims (case-insensitive) - val forbiddenClaims = listOf( - "Spring AI lacks governance", - "LangChain4j lacks governance", - "Spring AI has no policy", - "LangChain4j has no policy", - "Spring AI cannot block requests", - "LangChain4j cannot block requests", - "Spring AI has no tool controls", - ) + val forbiddenClaims = + listOf( + "Spring AI lacks governance", + "LangChain4j lacks governance", + "Spring AI has no policy", + "LangChain4j has no policy", + "Spring AI cannot block requests", + "LangChain4j cannot block requests", + "Spring AI has no tool controls", + ) for (claim in forbiddenClaims) { require(!text.contains(claim, ignoreCase = true)) { "Forbidden claim in comparison: '$claim'" @@ -848,13 +887,14 @@ fun jvmAiFrameworkComparison(rootDir: File) { } // Row-level comparison matrix checks (against capability section only) - val matrixRows = listOf( - "| **MCP client** | Implemented | Implemented | Not implemented", - "| **MCP server** | Implemented | Community server", - "| **Release maturity** | Stable 2.0.0", - "Dedicated DLP/redaction", - "Policy enforcement points with explicit ALLOW/DENY/REQUIRE_APPROVAL", - ) + val matrixRows = + listOf( + "| **MCP client** | Implemented | Implemented | Not implemented", + "| **MCP server** | Implemented | Community server", + "| **Release maturity** | Stable 2.0.0", + "Dedicated DLP/redaction", + "Policy enforcement points with explicit ALLOW/DENY/REQUIRE_APPROVAL", + ) for (row in matrixRows) { require(capabilitySection.contains(row, ignoreCase = true)) { "Capability comparison table must contain row fragment: '$row'" @@ -863,8 +903,7 @@ fun jvmAiFrameworkComparison(rootDir: File) { } /** verifyWorkflowApiStabilityBoundary (root build.gradle.kts @ 66198f33, lines 4167–4282). */ -fun workflowApiStabilityBoundary(rootDir: File) { - + fun workflowApiStabilityBoundary(rootDir: File) { val boundaryDoc = File(rootDir, "docs/workflow-api-stability-boundary.md") require(boundaryDoc.isFile) { "Missing workflow API stability boundary document at ${boundaryDoc.absolutePath}." @@ -873,7 +912,11 @@ fun workflowApiStabilityBoundary(rootDir: File) { val text = boundaryDoc.readText() // ── Section extraction helper ── - fun sectionBetween(text: String, start: String, end: String): String { + fun sectionBetween( + text: String, + start: String, + end: String, + ): String { require(text.contains(start)) { "Workflow API stability boundary is missing section: $start" } @@ -885,11 +928,15 @@ fun workflowApiStabilityBoundary(rootDir: File) { val stableSection = sectionBetween(text, "## Stable Workflow Surface", "## Preview Workflow Surface") val previewSection = sectionBetween(text, "## Preview Workflow Surface", "## Internal Workflow Surface") val internalSection = sectionBetween(text, "## Internal Workflow Surface", "## Deferred Workflow Surface") - val deferredSection = text.substringAfter("## Deferred Workflow Surface") - .substringBefore("## Cross-References") + val deferredSection = + text + .substringAfter("## Deferred Workflow Surface") + .substringBefore("## Cross-References") val allowedClaimsSection = sectionBetween(text, "## Allowed Claims", "## Forbidden Claims") - val forbiddenClaimsSection = text.substringAfter("## Forbidden Claims") - .substringBefore("## Acceptance Criteria") + val forbiddenClaimsSection = + text + .substringAfter("## Forbidden Claims") + .substringBefore("## Acceptance Criteria") // ── Stable section: core workflow annotations ── listOf( @@ -969,17 +1016,21 @@ fun workflowApiStabilityBoundary(rootDir: File) { } /** verifyVersionAlignment (root build.gradle.kts @ 66198f33, lines 4287–4453). */ -fun versionAlignment(rootDir: File, expectedVersion: String, expectedReleaseDate: String) { - + fun versionAlignment( + rootDir: File, + expectedVersion: String, + expectedReleaseDate: String, + ) { // 1. gradle.properties contains exactly tramaiVersion= val propsFile = File(rootDir, "gradle.properties") require(propsFile.isFile) { "Missing gradle.properties" } val propsText = propsFile.readText() - val committedVersion = propsText - .lineSequence() - .single { it.startsWith("tramaiVersion=") } - .substringAfter("=") - .trim() + val committedVersion = + propsText + .lineSequence() + .single { it.startsWith("tramaiVersion=") } + .substringAfter("=") + .trim() require(committedVersion == expectedVersion) { "gradle.properties must set tramaiVersion exactly to $expectedVersion, got '$committedVersion'" } @@ -1038,38 +1089,47 @@ fun versionAlignment(rootDir: File, expectedVersion: String, expectedReleaseDate } // 9. Consumer docs use expectedVersion for active coordinates (historical records excluded) - val consumerDocs = listOf( - "README.md", - "docs/guides/getting-started.md", - "docs/guides/quickstart.md", - "docs/guides/spring-boot.md", - "docs/guides/standalone-usage.md", - "docs/guides/tutorial-invoice-analyzer.md", - "docs/module-guide.md", - "docs/STATUS.md", - "docs/POST-SOVEREIGNTY-ROADMAP.md", - "docs/reference/releasing.md", - "examples/README.md", - "examples/support-agent/build.gradle.kts", - "examples/kotlin-springboot-example/build.gradle.kts", - "examples/kotlin-native-smoke-example/build.gradle.kts", - "examples/sovereign-runtime-consumer-smoke/build.gradle.kts", - "examples/spring-sovereign-starter/build.gradle.kts", - ) + val consumerDocs = + listOf( + "README.md", + "docs/guides/getting-started.md", + "docs/guides/quickstart.md", + "docs/guides/spring-boot.md", + "docs/guides/standalone-usage.md", + "docs/guides/tutorial-invoice-analyzer.md", + "docs/module-guide.md", + "docs/STATUS.md", + "docs/POST-SOVEREIGNTY-ROADMAP.md", + "docs/reference/releasing.md", + "examples/README.md", + "examples/support-agent/build.gradle.kts", + "examples/kotlin-springboot-example/build.gradle.kts", + "examples/kotlin-native-smoke-example/build.gradle.kts", + "examples/sovereign-runtime-consumer-smoke/build.gradle.kts", + "examples/spring-sovereign-starter/build.gradle.kts", + ) // Also check all module docs val moduleDocsDir = File(rootDir, "docs/modules") - val moduleDocs = if (moduleDocsDir.isDirectory) { - moduleDocsDir.listFiles().orEmpty().filter { it.name.endsWith(".md") }.map { it.path } - } else emptyList() + val moduleDocs = + if (moduleDocsDir.isDirectory) { + moduleDocsDir + .listFiles() + .orEmpty() + .filter { it.name.endsWith(".md") } + .map { it.path } + } else { + emptyList() + } val allConsumerDocs = consumerDocs + moduleDocs // Historical allowlist - old release records - val historicalAllowlist = setOf( - "docs/releases/CHANGELOG-0.3.1.md", - "docs/releases/CHANGELOG-0.4.0.md", - "docs/guides/secure-defaults-migration.md", - "docs/reference/release-0.1.0.md", - ) + val historicalAllowlist = + setOf( + "docs/releases/CHANGELOG-0.3.1.md", + "docs/releases/CHANGELOG-0.4.0.md", + "docs/guides/secure-defaults-migration.md", + "docs/reference/release-0.1.0.md", + ) for (path in allConsumerDocs) { val f = File(rootDir, path) @@ -1091,21 +1151,28 @@ fun versionAlignment(rootDir: File, expectedVersion: String, expectedReleaseDate // Reject any stale Gradle coordinates (dev.tramai:*:x.y.z where x.y.z != expectedVersion) val gradleCoordinatePattern = Regex("""dev\.tramai:[a-z0-9-]+:([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)""") - val staleGradleCoords = gradleCoordinatePattern.findAll(content) - .filter { it.groupValues[1] != expectedVersion } - .map { it.value } - .toList() + val staleGradleCoords = + gradleCoordinatePattern + .findAll(content) + .filter { it.groupValues[1] != expectedVersion } + .map { it.value } + .toList() require(staleGradleCoords.isEmpty()) { "Consumer doc $path contains stale TramAI Gradle coordinates: ${staleGradleCoords.joinToString()}" } // Reject any stale Maven versions in dev.tramai dependency blocks val mavenDevTramaiDependency = - Regex("""dev\.tramai.*?\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)\s*""", setOf(RegexOption.DOT_MATCHES_ALL)) - val staleMvnVersions = mavenDevTramaiDependency.findAll(content) - .filter { it.groupValues[1] != expectedVersion } - .map { it.value } - .toList() + Regex( + """dev\.tramai.*?\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)\s*""", + setOf(RegexOption.DOT_MATCHES_ALL), + ) + val staleMvnVersions = + mavenDevTramaiDependency + .findAll(content) + .filter { it.groupValues[1] != expectedVersion } + .map { it.value } + .toList() require(staleMvnVersions.isEmpty()) { "Consumer doc $path contains stale TramAI Maven versions: ${staleMvnVersions.joinToString()}" } @@ -1118,11 +1185,11 @@ fun versionAlignment(rootDir: File, expectedVersion: String, expectedReleaseDate } /** verifyToolGovernanceExample (root build.gradle.kts @ 66198f33, lines 4459–4526). */ -fun moduleDocContract(rootDir: File): List = - dev.tramai.build.quality.ModuleDocContractVerifier.verify(rootDir) - -fun toolGovernanceExample(rootDir: File) { + fun moduleDocContract(rootDir: File): List = + dev.tramai.build.quality.ModuleDocContractVerifier + .verify(rootDir) + fun toolGovernanceExample(rootDir: File) { val exampleDir = File(rootDir, "examples/tool-governance") val settingsText = File(rootDir, "settings.gradle.kts").readText() val examplesReadme = File(rootDir, "examples/README.md").readText() diff --git a/build-logic/src/main/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPlugin.kt b/build-logic/src/main/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPlugin.kt index d7e93124b..ff2e49505 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPlugin.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPlugin.kt @@ -159,7 +159,7 @@ class TramaiDocsGuardsPlugin : Plugin { project.tasks.register("verifyVersionAlignment") { group = "verification" - description = "Verifies the repository version surfaces are aligned: 0.6.0 as release version." + description = "Verifies the repository version surfaces are aligned." contractId.set("verifyVersionAlignment") verifierKind.set(DocGuardKind.VERSION_ALIGNMENT) this.rootDir.set(project.rootDir) diff --git a/build-logic/src/main/kotlin/dev/tramai/build/docs/VersionAlignmentVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/docs/VersionAlignmentVerifier.kt new file mode 100644 index 000000000..0cb2861fe --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/docs/VersionAlignmentVerifier.kt @@ -0,0 +1,284 @@ +package dev.tramai.build.docs + +import dev.tramai.build.quality.TramaiVersions +import java.io.File + +/* + * Version-surface contract (verifyVersionAlignment), extracted from RootDocGuardVerifiers so both + * files stay inside their function budgets and the release-line scoping stays readable. + */ + +/** Active-doc guards for legacy snapshot coordinates. */ +private val SNAPSHOT_GRADLE_COORDINATE = Regex("""dev\.tramai:[a-z0-9-]+:0\.5\.0-SNAPSHOT""") +private val SNAPSHOT_MAVEN_VERSION = Regex("""\s*0\.5\.0-SNAPSHOT\s*""") +private val SNAPSHOT_VARIABLE = Regex("""tramaiVersion\s*=\s*"0\.5\.0-SNAPSHOT"""") + +/** `x.y.z` capture used by both active-coordinate patterns. */ +private const val DOTTED_VERSION_CAPTURE = """([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)""" + +/** Numeric `x.y.z` ordering of two release versions; unparseable input sorts below everything. */ +private const val UNPARSEABLE = -1 + +/** `major.minor.patch`. */ +private const val RELEASE_VERSION_PARTS = 3 + +/** The three `x.y.z` components of a release version, or null when it is not one. */ +private fun parseReleaseVersion(version: String): List? = + version.split(".").map { it.toIntOrNull() ?: return null }.takeIf { it.size == RELEASE_VERSION_PARTS } + +/** Numeric `x.y.z` ordering of two release versions; unparseable input sorts below everything. */ +private fun compareReleaseVersions( + left: String, + right: String, +): Int { + val leftParts = parseReleaseVersion(left) + val rightParts = parseReleaseVersion(right) + if (leftParts == null || rightParts == null) return UNPARSEABLE + return leftParts.zip(rightParts).firstOrNull { (l, r) -> l != r }?.let { (l, r) -> l.compareTo(r) } ?: 0 +} + +/** Active consumer docs plus every module card; historical release records are excluded later. */ +private fun activeConsumerDocs(rootDir: File): List { + val consumerDocs = + listOf( + "README.md", + "docs/guides/getting-started.md", + "docs/guides/quickstart.md", + "docs/guides/spring-boot.md", + "docs/guides/standalone-usage.md", + "docs/guides/tutorial-invoice-analyzer.md", + "docs/module-guide.md", + "docs/STATUS.md", + "docs/POST-SOVEREIGNTY-ROADMAP.md", + "docs/reference/releasing.md", + "examples/README.md", + "examples/support-agent/build.gradle.kts", + "examples/kotlin-springboot-example/build.gradle.kts", + "examples/kotlin-native-smoke-example/build.gradle.kts", + "examples/sovereign-runtime-consumer-smoke/build.gradle.kts", + "examples/spring-sovereign-starter/build.gradle.kts", + ) + val moduleDocsDir = File(rootDir, "docs/modules") + val moduleDocs = + if (moduleDocsDir.isDirectory) { + moduleDocsDir + .listFiles() + .orEmpty() + .filter { it.name.endsWith(".md") } + .map { it.path } + } else { + emptyList() + } + return consumerDocs + moduleDocs +} + +fun verifyVersionAlignmentSurfaces( + rootDir: File, + expectedVersion: String, + expectedReleaseDate: String, +) { + requireVersionProperties(rootDir, expectedVersion) + val promotedVersion = requireChangelogReleaseSection(rootDir, expectedVersion, expectedReleaseDate) + requireReleaseSurfaces(rootDir, promotedVersion, promotedReleaseDate(rootDir)) + requireConsumerDocCoordinates(rootDir, promotedVersion) +} + +/** 1-2: the project version is the single authority, and the build fallback agrees with it. */ +private fun requireVersionProperties( + rootDir: File, + expectedVersion: String, +) { + // 1. gradle.properties contains exactly tramaiVersion= + val propsFile = File(rootDir, "gradle.properties") + require(propsFile.isFile) { "Missing gradle.properties" } + val propsText = propsFile.readText() + val committedVersion = + propsText + .lineSequence() + .single { it.startsWith("tramaiVersion=") } + .substringAfter("=") + .trim() + require(committedVersion == expectedVersion) { + "gradle.properties must set tramaiVersion exactly to $expectedVersion, got '$committedVersion'" + } + + // 2. Build fallback is expectedVersion + val buildFile = File(rootDir, "build.gradle.kts") + val buildText = buildFile.readText() + require(buildText.contains("orElse(\"$expectedVersion\")")) { + "build.gradle.kts fallback must be $expectedVersion" + } +} + +/** + * 3: `## Unreleased` is present above a dated release section, and returns the promoted release the + * release-scoped surfaces of this tree describe. + * + * Both the derived version *and* the derived date are checked on a development line: taking the + * heading at face value would accept `## 0.6.0 - 1999-01-01` and record a release cut on a date that + * never happened. + */ +private fun requireChangelogReleaseSection( + rootDir: File, + expectedVersion: String, + expectedReleaseDate: String, +): String { + // 3. CHANGELOG.md has ## Unreleased present above a dated release section. + // + // Release-scoped surfaces — the dated CHANGELOG section, the release-readiness document, + // the roadmap release train and consumer dependency coordinates — describe a *promoted + // release*. On a development line the project version carries a -SNAPSHOT suffix, so those + // surfaces still describe the last promoted release: keep verifying the same promises + // against the release they describe, instead of demanding release-promotion artifacts for + // an unreleased snapshot. + val changelog = File(rootDir, "CHANGELOG.md") + val changelogText = changelog.readText() + require(changelogText.contains("## Unreleased")) { + "CHANGELOG.md must retain ## Unreleased heading" + } + val afterUnreleased = changelogText.substringAfter("## Unreleased") + if (!TramaiVersions.isSnapshot(expectedVersion)) { + // After promotion, ## Unreleased is immediately followed by ## + require(afterUnreleased.contains("## $expectedVersion - $expectedReleaseDate")) { + "CHANGELOG.md must contain a dated $expectedVersion section after ## Unreleased" + } + return expectedVersion + } + + val promotedVersion = promotedReleaseVersion(rootDir) + val promotedDate = promotedReleaseDate(rootDir) + require(afterUnreleased.contains("## $promotedVersion - $promotedDate")) { + "CHANGELOG.md must contain a dated $promotedVersion section after ## Unreleased " + + "(release-scoped surface of the $expectedVersion development line)" + } + require(promotedDate == expectedReleaseDate) { + "CHANGELOG.md must date the promoted release $promotedVersion as " + + "$expectedReleaseDate, got '$promotedDate'" + } + // The one thing a development line must not do is target a release that is not ahead + // of the last promoted one: that would mislabel the line rather than merely postpone + // its promotion. + require(compareReleaseVersions(TramaiVersions.releaseVersionOf(expectedVersion), promotedVersion) > 0) { + "Development line $expectedVersion must target a release after the last promoted " + + "release $promotedVersion" + } + + return promotedVersion +} + +/** 5-8, 10: release surfaces, roadmap train and release documents for the promoted release. */ +private fun requireReleaseSurfaces( + rootDir: File, + releaseScopedVersion: String, + releaseDate: String, +) { + // 5. No active -SNAPSHOT references remain + // 6. STATUS.md names the promoted release — its actual version and date — as the current one. + // Asserting a historical literal (0.4.0) plus the loose phrase "Latest published release" + // passed on several incorrect trees, because the two tokens need not describe the same + // release, or any current one. + val statusDoc = File(rootDir, "docs/STATUS.md") + val statusText = statusDoc.readText() + require( + statusText.lineSequence().any { line -> + line.startsWith("| $releaseScopedVersion | $releaseDate |") && + line.contains("Current release") + }, + ) { + "STATUS.md must identify $releaseScopedVersion (dated $releaseDate) as the " + + "current release in its released-versions table" + } + + // 7. The roadmap identifies the completed expectedVersion train + val roadmap = File(rootDir, "docs/POST-SOVEREIGNTY-ROADMAP.md") + val roadmapText = roadmap.readText() + require(roadmapText.contains("Release train: TramAI $releaseScopedVersion")) { + "Roadmap must identify release train $releaseScopedVersion" + } + require(roadmapText.contains("$releaseScopedVersion release")) { + "Roadmap must reference $releaseScopedVersion release" + } + // 7b. Roadmap tables use valid Markdown (no line starting with ||) + require(!roadmapText.lineSequence().any { it.trimStart().startsWith("||") }) { + "Roadmap contains malformed Markdown table rows beginning with '||' — pipe prefixes must be a single |" + } + + // 8. Release notes and readiness documents exist + require(File(rootDir, "docs/releases/$releaseScopedVersion-release-readiness.md").isFile) { + "Missing $releaseScopedVersion release-readiness document" + } + require(File(rootDir, "docs/releases/sovereign-runtime-release-readiness.md").isFile) { + "Missing sovereign-runtime release-readiness document" + } + + // 10. No malformed Markdown tables or prohibited claims + require(!roadmapText.lineSequence().any { it.trimStart().startsWith("||") }) { + "Roadmap contains malformed Markdown table rows beginning with '||'" + } +} + +/** 9: no consumer doc advertises coordinates other than [releaseScopedVersion]. */ +private fun requireConsumerDocCoordinates( + rootDir: File, + releaseScopedVersion: String, +) { + // 9. Consumer docs use expectedVersion for active coordinates (historical records excluded) + val allConsumerDocs = activeConsumerDocs(rootDir) + + // Historical allowlist - old release records + val historicalAllowlist = + setOf( + "docs/releases/CHANGELOG-0.3.1.md", + "docs/releases/CHANGELOG-0.4.0.md", + "docs/guides/secure-defaults-migration.md", + "docs/reference/release-0.1.0.md", + ) + + val historical = historicalAllowlist.map { File(rootDir, it).canonicalPath }.toSet() + + for (path in allConsumerDocs) { + val f = File(rootDir, path) + if (!f.isFile || f.canonicalPath in historical) continue + val content = f.readText() + + // No stale SNAPSHOT references in active docs + require(!SNAPSHOT_GRADLE_COORDINATE.containsMatchIn(content)) { + "Consumer doc $path still contains dev.tramai:*:0.5.0-SNAPSHOT dependency reference" + } + require(!SNAPSHOT_MAVEN_VERSION.containsMatchIn(content)) { + "Consumer doc $path still contains Maven 0.5.0-SNAPSHOT" + } + require(!SNAPSHOT_VARIABLE.containsMatchIn(content)) { + "Consumer doc $path still contains tramaiVersion = \"0.5.0-SNAPSHOT\"" + } + + // Reject any stale Gradle coordinates (dev.tramai:*:x.y.z where x.y.z != expectedVersion) + val gradleCoordinatePattern = + Regex("""dev\.tramai:[a-z0-9-]+:$DOTTED_VERSION_CAPTURE""") + val staleGradleCoords = + gradleCoordinatePattern + .findAll(content) + .filter { it.groupValues[1] != releaseScopedVersion } + .map { it.value } + .toList() + require(staleGradleCoords.isEmpty()) { + "Consumer doc $path contains stale TramAI Gradle coordinates: ${staleGradleCoords.joinToString()}" + } + + // Reject any stale Maven versions in dev.tramai dependency blocks + val mavenDevTramaiDependency = + Regex( + """dev\.tramai.*?\s*$DOTTED_VERSION_CAPTURE\s*""", + setOf(RegexOption.DOT_MATCHES_ALL), + ) + val staleMvnVersions = + mavenDevTramaiDependency + .findAll(content) + .filter { it.groupValues[1] != releaseScopedVersion } + .map { it.value } + .toList() + require(staleMvnVersions.isEmpty()) { + "Consumer doc $path contains stale TramAI Maven versions: ${staleMvnVersions.joinToString()}" + } + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/ApiCompatibilityVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/ApiCompatibilityVerifier.kt index 308420cbd..659e5c795 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/ApiCompatibilityVerifier.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/ApiCompatibilityVerifier.kt @@ -40,15 +40,21 @@ data class ApiMigrationEntry( val rationale: String, val migration: String, ) { - /** Exact-transition authorization: both hashes AND the target version must match. */ + /** + * Exact-transition authorization: both hashes *** AND the release target must match. + * + * [releaseVersion] is the release the project version belongs to + * ([TramaiVersions.releaseVersionOf]), not the raw development version: an entry written + * during a `0.7.0-SNAPSHOT` development line targets `0.7.0` and stays truthful after the cut. + */ fun authorizes( module: String, baseContent: String, committedContent: String, - projectVersion: String, + releaseVersion: String, ): Boolean = this.module == module && - this.targetVersion == projectVersion && + this.targetVersion == releaseVersion && this.fromSha256 == ApiCompatibilityVerifier.sha256(baseContent) && this.toSha256 == ApiCompatibilityVerifier.sha256(committedContent) } @@ -135,26 +141,37 @@ class ApiCompatibilityVerifier( when (stability) { "stable" -> { - diagnostics += - VerificationDiagnostic.failure( - DiagnosticCode.API_COMPATIBILITY_FAILED, - "Stable API module '$module' changed (breaking or additive); stable API is frozen for $projectVersion. " + - "Migration entries cannot authorize stable changes.", - modulePath = module, - baselineValue = sha256(base), - currentValue = sha256(committed), - ) + // Stable means backward compatible, not byte-identical: a legitimately + // additive stable release line must pass, while a removal or an altered + // declaration must not. MIGRATION ENTRIES CANNOT AUTHORIZE STABLE BREAKAGE. + val breakages = ApiDumpCompatibility.incompatibleDeclarations(base, committed) + if (breakages.isNotEmpty()) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.API_COMPATIBILITY_FAILED, + "Stable API module '$module' changed incompatibly: " + + breakages.take(MAX_REPORTED_BREAKAGES).joinToString("; ") + + ". A stable API must keep existing consumers working, so additive changes are " + + "allowed and removals or signature changes are not; migration entries cannot " + + "authorize stable breakage.", + modulePath = module, + baselineValue = sha256(base), + currentValue = sha256(committed), + ) + } } "preview", "experimental" -> { - val authorized = migrations.any { it.authorizes(module, base, committed, projectVersion) } + val releaseVersion = TramaiVersions.releaseVersionOf(projectVersion) + val authorized = migrations.any { it.authorizes(module, base, committed, releaseVersion) } if (!authorized) { diagnostics += VerificationDiagnostic.failure( DiagnosticCode.API_COMPATIBILITY_FAILED, "API module '$module' changed without an exact hash-bound migration entry; " + "add an entry binding fromSha256=${sha256(base)} toSha256=${sha256(committed)} " + - "for targetVersion=$projectVersion in config/quality/api-migrations.yml", + "for targetVersion=$releaseVersion in config/quality/api-migrations.yml " + + "(project version $projectVersion)", modulePath = module, baselineValue = sha256(base), currentValue = sha256(committed), @@ -193,28 +210,41 @@ class ApiCompatibilityVerifier( // Entry lifecycle (D1): a registry entry is valid evidence in exactly // two states — ACTIVE (base→current transition it was written for is - // still the live one) or LANDED (its `to` hash has landed: either it is - // both base and current — the merged steady state — or it is the new - // base while a FURTHER change is in flight, so it is retained history - // that authorizes nothing). Any other state — orphan, stale, wrong - // hash, wrong version — FAILs. Authorization of a live base→current - // change is handled by Contract-2 (exact entry required); a landed - // entry must never block the next PR nor authorize a later change. + // still the live one) or RETAINED HISTORY (its target API state is + // transitively connected to the module's current base dump). Any other + // state — orphan, stale, wrong hash, wrong version, disconnected chain, + // cycle, ambiguous branching — FAILs. Authorization of a live + // base→current change is handled by Contract-2 (exact ACTIVE entry + // required); retained history authorizes nothing and must never block + // the next PR. + // + // The rule is reachability to the REAL base hash, not connectivity + // between registry entries: two fabricated entries could otherwise + // corroborate each other with no relation to any API state that ever + // landed. migrations.forEach { entry -> val realTransition = actualTransition(evidence, entry.module) val base = evidence.base[entry.module] val committed = evidence.committed[entry.module] + // ACTIVE requires the declared target release to match the release the current + // project version belongs to, so a `0.7.0` entry stays valid on a `0.7.0-SNAPSHOT` + // development line and after the cut. + val releaseVersion = TramaiVersions.releaseVersionOf(projectVersion) val active = realTransition != null && realTransition.first == entry.fromSha256 && realTransition.second == entry.toSha256 && - entry.targetVersion == projectVersion - val landed = - base != null && committed != null && ( - (realTransition == null && sha256(committed) == entry.toSha256) || - (realTransition != null && sha256(base) == entry.toSha256) - ) - if (!active && !landed) { + entry.targetVersion == releaseVersion + val retainedHistory = + base != null && + committed != null && + reachesCurrentBase( + module = entry.module, + startHash = entry.toSha256, + currentBaseHash = sha256(base), + migrations = migrations, + ) + if (!active && !retainedHistory) { diagnostics += VerificationDiagnostic.failure( DiagnosticCode.API_COMPATIBILITY_FAILED, @@ -230,6 +260,37 @@ class ApiCompatibilityVerifier( } } + /** + * Whether [startHash] reaches the module's current base API hash by following migration + * entries of that module as a SEQUENCE: exactly one successor per hash, no cycles. + * + * [currentBaseHash] is the only trust anchor — connectivity between entries is not evidence. + * A hash with two successors is ambiguous (a migration history describes a sequence of + * authoritative API states, not a branching graph), a hash that repeats is a cycle, and a + * hash with no successor is simply disconnected. All three are not history. + */ + private fun reachesCurrentBase( + module: String, + startHash: String, + currentBaseHash: String, + migrations: List, + ): Boolean { + val successors = + migrations + .filter { it.module == module } + .groupBy { it.fromSha256 } + .mapValues { (_, entries) -> entries.map(ApiMigrationEntry::toSha256).distinct() } + val visited = mutableSetOf() + var cursor = startHash + // Exactly two exits: reached the base, or stopped being a sequence (no successor, + // ambiguous successor, or a repeated hash). + while (cursor != currentBaseHash) { + val next = successors[cursor]?.takeIf { it.size == 1 && visited.add(cursor) } ?: return false + cursor = next.single() + } + return true + } + private fun actualTransition( evidence: ApiDumpEvidence, module: String, @@ -321,6 +382,9 @@ class ApiCompatibilityVerifier( private fun applicable(stability: String): Boolean = stability in setOf("stable", "preview", "experimental") companion object { + /** Bounded so a large incompatible change still yields a readable diagnostic. */ + private const val MAX_REPORTED_BREAKAGES = 5 + private val OWNED_CLASS_REGEX = Regex("""\bclass\s+(dev/tramai/[\w/$]+)""") fun sha256(content: String): String = diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/ApiDumpCompatibility.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/ApiDumpCompatibility.kt new file mode 100644 index 000000000..cfbad70bb --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/ApiDumpCompatibility.kt @@ -0,0 +1,101 @@ +package dev.tramai.build.quality + +/** + * Backward-compatibility comparison over BCV dump text, used for `stable` API modules. + * + * Stable is a compatibility promise, not an immutability promise: an existing consumer must keep + * working against a newer release. Additions therefore pass, while removals, signature changes and + * visibility reductions fail. Contract-2 previously equated any dump inequality with instability, + * which made a legitimate additive stable release impossible — and, once a new release line grows + * its stable API, unsatisfiable by construction. + * + * ponytail: declaration-identity comparison rather than full ABI analysis. A class's declaration + * head (visibility/kind/name) must be preserved and its supertype set may only grow; members are + * matched by exact dump line, so a *compatible* signature change (e.g. a widened return type) is + * reported as a removal. That is the fail-closed direction for a stability promise. Upgrade path: + * a BCV-backed signature comparison, if stable modules ever need to accept such transitions. + */ +object ApiDumpCompatibility { + /** + * Why [current] is not backward compatible with [base], one entry per broken declaration. + * An empty result means every declaration present at [base] still exists at [current]. + */ + fun incompatibleDeclarations( + base: String, + current: String, + ): List { + val baseClasses = parse(base) + val currentClasses = parse(current) + val breakages = mutableListOf() + + baseClasses.forEach { (key, baseClass) -> + val currentClass = currentClasses[key] + if (currentClass == null) { + breakages += "class $key was removed" + return@forEach + } + if (currentClass.head != baseClass.head) { + breakages += "class $key changed declaration head: '${baseClass.head}' -> '${currentClass.head}'" + } + val droppedSupertypes = (baseClass.supertypes - currentClass.supertypes).sorted() + if (droppedSupertypes.isNotEmpty()) { + breakages += "class $key dropped supertype(s): ${droppedSupertypes.joinToString(", ")}" + } + (baseClass.members - currentClass.members).sorted().forEach { member -> + breakages += "member '$member' of $key was removed or changed" + } + } + return breakages + } + + private data class ClassApi( + val head: String, + val supertypes: Set, + val members: Set, + ) + + /** + * Index every class of a dump by its descriptor, with the body of its block as member lines. + * Blocks are tracked with a stack: a header pushes, a lone `}` pops, so nested classes and + * their bodies are attributed to the class that owns them and compare like-for-like. + */ + private fun parse(dump: String): Map { + val classes = linkedMapOf() + val open = ArrayDeque() + val members = mutableMapOf>() + + dump.lineSequence().forEach { raw -> + val line = raw.trimEnd() + if (line.isBlank()) return@forEach + if (line.trim() == "}") { + if (open.isNotEmpty()) open.removeLast() + return@forEach + } + val parsed = HEADER.find(line)?.destructured + if (parsed != null && parsed.component3().contains('/')) { + val descriptor = parsed.component3() + classes[descriptor] = + ClassApi( + head = "${parsed.component1()}${parsed.component2()}".trim(), + supertypes = + parsed + .component5() + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet(), + members = emptySet(), + ) + members[descriptor] = linkedSetOf() + open.addLast(descriptor) + return@forEach + } + open.lastOrNull()?.let { owner -> members[owner]?.add(line.trim()) } + } + + return classes.mapValues { (key, api) -> api.copy(members = (members[key] ?: emptySet()).toSet()) } + } + + /** `public final class dev/tramai/core/Foo : super, iface {` → head, kind, descriptor, supertypes. */ + private val HEADER = Regex("""^\s*(.*?)\b(class|interface|object)\s+(\S+?)(\s*:\s*(.*?))?\s*\{\s*$""") +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt new file mode 100644 index 000000000..a1f5d71cf --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/CertificateConsumption.kt @@ -0,0 +1,302 @@ +package dev.tramai.build.quality + +/** + * Certificate consumption: M40-M43 (0.7.1g1P2). + * + * These rules produce a **positive, attributed fact** — [CertificateConsumption.Valid] — and the only + * way to obtain one is through [verifyCertificateConsumption], which derives every predicate from real + * authority inputs: the base certificate ledger, the cited admission, the exact base authorization + * set and the verifier's own fresh measurement. + * + * That matters because lifecycle rules must not be able to act on a claim: + * + * - M44 and M47 take `Set`, never raw digests, so a caller cannot + * state "trust me, this digest was consumed". The attribution is carried by the value. + * - M43 provenance is derived here by looking the certificate up in the base ledger. There is no + * `fromBase` flag for a caller to assert, so the Boolean cannot drift from the truth. + * - The Valid constructor is `private` (with `@ConsistentCopyVisibility` on the class), so no code + * outside the verification itself can construct one - not even other files in this module, which + * is where the Gradle call sites live. The proof and the fact share a scope by construction: there + * is no path to a Valid that skips M43-M42. This is deliberately not a capability system: the goal + * is that no ordinary call site can accidentally manufacture consumption authority. + * + * Consumption is bounded, not a licence. A certificate names one exact source digest, one exact + * target digest and one exact admission set, and it may only be consumed by a transition whose fresh + * measurement actually projects to that target: + * + * ``` + * M43 the certificate is present in the base with the payload it was minted with (the consuming + * candidate cannot invent the semantic upgrade it consumes - the M31 analogue) + * M40 toDigest == the verifier's fresh authority projection (never candidate-supplied: T7) + * M41 fromDigest == the cited admission's own populationDigest (the historical authority) + * M42 admissionSetDigest == the exact base authorization set (bounded: no different, later set) + * ``` + * + * Every predicate is fail-closed: a missing or mismatched value yields [CertificateConsumption.Invalid], + * never a pass by omission. Each predicate is its own function so the refusal order is readable, and + * the whole consumption is one chain: the first predicate that refuses decides, and only a + * certificate satisfying all of them yields `Valid`. + */ +sealed interface CertificateConsumption { + /** + * The proven fact: this certificate validly authorises this migration in this transition. + * + * The constructor is **private**, so no code outside this class - including other files in the + * same module, which is where the Gradle call sites live - can construct one. The only production + * path is [Valid.verify], which is the verification itself: it derives M43 provenance from the + * base ledger and runs M40-M42 before the fact exists. `@ConsistentCopyVisibility` keeps the + * generated `copy()` private too, so it is not an escape hatch either. + */ + @ConsistentCopyVisibility + data class Valid private constructor( + val fromDigest: String, + val toDigest: String, + val admissionSetDigest: String, + ) : CertificateConsumption { + companion object { + /** + * M43 + M40 + M41 + M42, derived from base authority. The only way a [Valid] comes into + * existence. + * + * @param certificate the certificate being cited + * @param baseCertificates the certificate ledger as it exists in the authority base. M43 + * is derived from it, not asserted: a certificate that is absent, or whose enforced + * payload differs from the base copy, cannot be consumed. + * @param citedAdmissionPopulationDigest the `populationDigest` of the admission being + * consumed (M41). + * @param baseAdmissionIdentities the exact identities authorised in the base (M42). + * @param freshAuthorityProjectionHash the authority-v2 projection the verifier computed + * from its own fresh measurement (M40). Callers must pass measured data, never + * candidate data. + */ + internal fun verify( + certificate: MutationAuthorityDigestCertificate, + baseCertificates: MutationAuthorityDigestCertificates, + citedAdmissionPopulationDigest: String, + baseAdmissionIdentities: List, + freshAuthorityProjectionHash: String, + ): CertificateConsumption { + val inBase = baseCertificates.byFromDigest()[certificate.fromDigest] + val refusal = + provenance(certificate, inBase) + ?: targetDigest(certificate, freshAuthorityProjectionHash) + ?: algorithmPair(certificate) + ?: sourceDigest(certificate, citedAdmissionPopulationDigest) + ?: admissionSet(certificate, baseAdmissionIdentities) + return refusal?.let { CertificateConsumption.Invalid(it) } + ?: Valid( + fromDigest = certificate.fromDigest, + toDigest = certificate.toDigest, + admissionSetDigest = certificate.admissionSetDigest, + ) + } + } + } + + /** The refusal, carrying the named rule that rejected it. */ + data class Invalid( + val diagnostic: VerificationDiagnostic, + ) : CertificateConsumption +} + +/** + * The transition's population-authority context for one admission verdict (M34). + * + * Carries the two things a verdict needs about authority - the canonical fresh projection and the + * consumptions that projection certifies - as one value, so the two judgment paths (the lifecycle + * scan and the appearing-identity path) are handed the same object and cannot disagree about + * consumption. Bundling them also keeps [MutationPopulationAdmissionCeremony.appearanceVerdict] + * inside the repository's parameter budget without suppressing anything. + * + * Produced exactly once per transition and passed unchanged to every consumer - M34, the admission + * lifecycle and the certificate lifecycle (M44/M47). Consumers must never re-derive it: a + * value-equal recomputation is not the same property as shared evidence, and only one production + * site makes it structurally impossible for the rules to disagree about which consumptions were + * proven. `CertificateCustodyTransportTest` pins that with a source-level assertion. + */ +data class AdmissionAuthority( + val freshAuthorityProjectionHash: String?, + val certifiedConsumptions: Set = emptySet(), +) + +/** + * The certified consumptions a base can prove for this fresh projection (Step 3b). + * + * The single production site for the facts M34, M44 and M47 act on: they all consume the same + * `Set`, so no consumer re-derives its own and the call sites cannot drift apart. Both inputs + * come from the coherent transition context - the base snapshot supplies the certificate ledger, the + * historical admission digests and the exact authorized identity set, while the authority-v2 + * projection comes only from the fresh measurement. + * + * One fact is produced per distinct historical digest the base's admissions were minted under, and + * each citation is verified against that admission's own digest (M41) and the exact base + * authorization set (M42). A null projection yields no facts: without a fresh measurement there is + * nothing to certify, which is the fail-closed state. + */ +fun certifiedConsumptions( + certificates: MutationAuthorityDigestCertificates, + baseAdmissions: MutationPopulationAdmissions, + freshAuthorityProjectionHash: String?, +): Set { + val projection = freshAuthorityProjectionHash ?: return emptySet() + val identities = baseAdmissions.admissions.map { it.identity } + val historicalDigests = baseAdmissions.admissions.map { it.populationDigest }.distinct() + return certificates.certificates + .flatMap { certificate -> + historicalDigests.mapNotNull { digest -> + verifyCertificateConsumption( + certificate = certificate, + baseCertificates = certificates, + citedAdmissionPopulationDigest = digest, + baseAdmissionIdentities = identities, + freshAuthorityProjectionHash = projection, + ) as? CertificateConsumption.Valid + } + }.toSet() +} + +/** + * The authority context for a verdict over one base snapshot and this transition's fresh projection. + */ +fun MutationRatchetAuthority.admissionAuthority(freshAuthorityProjectionHash: String?): AdmissionAuthority = + AdmissionAuthority( + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + certifiedConsumptions = + certifiedConsumptions(certificates, admissions, freshAuthorityProjectionHash), + ) + +/** + * The consumption entry point for call sites: delegates to [CertificateConsumption.Valid.verify], so + * there is exactly one implementation of the proof and no second route to a fact. + */ +fun verifyCertificateConsumption( + certificate: MutationAuthorityDigestCertificate, + baseCertificates: MutationAuthorityDigestCertificates, + citedAdmissionPopulationDigest: String, + baseAdmissionIdentities: List, + freshAuthorityProjectionHash: String, +): CertificateConsumption = + CertificateConsumption.Valid.verify( + certificate = certificate, + baseCertificates = baseCertificates, + citedAdmissionPopulationDigest = citedAdmissionPopulationDigest, + baseAdmissionIdentities = baseAdmissionIdentities, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + ) + +/** + * M43, derived from the base ledger rather than asserted by the caller. Two distinct failures: the + * certificate is not in the base at all (the transition is minting the authority it consumes), or it + * is present but its enforced payload differs (the consumption cites something other than the + * certificate that was minted). + */ +private fun provenance( + certificate: MutationAuthorityDigestCertificate, + inBase: MutationAuthorityDigestCertificate?, +): VerificationDiagnostic? = + when { + inBase == null -> { + failure( + certificate, + "M43: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} is not present in the authority base, so this " + + "transition would consume migration authority it introduces itself. Migration " + + "authority must already exist in the base.", + ) + } + + inBase.enforcedPayload() != certificate.enforcedPayload() -> { + failure( + certificate, + "M43: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} does not match the certificate present in the " + + "authority base. A consumption must cite the base certificate as it was minted.", + ) + } + + else -> { + null + } + } + +private fun targetDigest( + certificate: MutationAuthorityDigestCertificate, + freshAuthorityProjectionHash: String, +): VerificationDiagnostic? = + if (certificate.toDigest == freshAuthorityProjectionHash) { + null + } else { + failure( + certificate, + "M40: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} certifies target digest " + + "${short(certificate.toDigest)}, but this transition's fresh measurement projects to " + + "${short(freshAuthorityProjectionHash)}. A certificate authorises one exact " + + "migration, not any migration.", + ) + } + +private fun algorithmPair(certificate: MutationAuthorityDigestCertificate): VerificationDiagnostic? = + if (certificate.fromAlgorithm == MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1 && + certificate.toAlgorithm == MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2 + ) { + null + } else { + failure( + certificate, + "M41: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} declares the migration " + + "'${certificate.fromAlgorithm}' -> '${certificate.toAlgorithm}', which is not the " + + "'${MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1}' -> " + + "'${MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2}' authority upgrade " + + "this consumption requires.", + ) + } + +private fun sourceDigest( + certificate: MutationAuthorityDigestCertificate, + citedAdmissionPopulationDigest: String, +): VerificationDiagnostic? = + if (certificate.fromDigest == citedAdmissionPopulationDigest) { + null + } else { + failure( + certificate, + "M41: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} does not match the cited admission's historical " + + "population digest ${short(citedAdmissionPopulationDigest)}. A certificate " + + "translates the authority the admission was actually minted under, not a different " + + "one.", + ) + } + +private fun admissionSet( + certificate: MutationAuthorityDigestCertificate, + baseAdmissionIdentities: List, +): VerificationDiagnostic? { + val bound = MutationAuthorityDigestCertificates.admissionSetDigest(baseAdmissionIdentities) + if (certificate.admissionSetDigest == bound) return null + return failure( + certificate, + "M42: the digest-migration certificate for source digest " + + "${short(certificate.fromDigest)} binds admission set " + + "${short(certificate.admissionSetDigest)}, which is not the exact set of " + + "${baseAdmissionIdentities.size} base authorizations (${short(bound)}). The certificate " + + "is bounded: it cannot cover a different or later admission set.", + ) +} + +private fun failure( + certificate: MutationAuthorityDigestCertificate, + message: String, +): VerificationDiagnostic = + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, + message, + findingId = certificate.fromDigest, + ) + +private fun short(digest: String): String = digest.take(SHORT_HASH_LENGTH) + +/** Prefix length used to identify a digest in diagnostics; the full value stays in findingId. */ +private const val SHORT_HASH_LENGTH = 8 diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifier.kt index 4ec720c3e..1134b3c82 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifier.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifier.kt @@ -36,10 +36,10 @@ import java.nio.file.Path * between `@Test` and the declaration. */ object JUnitTestSignatureVerifier { - // Explicit `` type argument on the expression head, e.g. `runBlocking {` private val EXPLICIT_UNIT_HEAD = Regex("""^[\w.`]+\s*\s*(?=[({.\s]).*$""") + // `runTest { ... }` returns TestResult, which JUnit Jupiter special-cases as // a valid test-method return type (discovered, not skipped) — Unit-safe in // the discovery sense, so whitelisted without a type argument. @@ -47,13 +47,23 @@ object JUnitTestSignatureVerifier { private val BARE_UNIT = Regex("""^Unit\s*$""") private val NAME = Regex("""\s*(?:public|private|internal)?\s*(?:suspend\s+)?fun\s+(`[^`]+`|[A-Za-z0-9_]+)""") - data class Violation(val file: Path, val line: Int, val functionName: String, val expressionHead: String) + data class Violation( + val file: Path, + val line: Int, + val functionName: String, + val expressionHead: String, + ) /** Result of evaluating an accumulated `@Test` declaration. */ private sealed interface Decision { data object Continue : Decision + data object Safe : Decision - data class Reject(val functionName: String, val expressionHead: String) : Decision + + data class Reject( + val functionName: String, + val expressionHead: String, + ) : Decision } /** @@ -64,7 +74,8 @@ object JUnitTestSignatureVerifier { val violations = mutableListOf() if (!Files.isDirectory(root)) return violations Files.walk(root).use { paths -> - paths.filter { Files.isRegularFile(it) && it.fileName.toString().endsWith(".kt") } + paths + .filter { Files.isRegularFile(it) && it.fileName.toString().endsWith(".kt") } .filter { p -> // Normalize separators so matching works on any platform. val path = p.toString().replace(File.separatorChar, '/') @@ -73,8 +84,7 @@ object JUnitTestSignatureVerifier { // exclusion is needed — and one would wrongly skip the // `dev/tramai/build/*` source package (the guard's own home). (path.contains("/src/test/") || path.contains("/src/testFixtures/")) - } - .forEach { file -> violations += scanFile(file) } + }.forEach { file -> violations += scanFile(file) } } return violations } @@ -94,7 +104,10 @@ object JUnitTestSignatureVerifier { var i = openParen while (i < decl.length) { when (decl[i]) { - '(' -> depth++ + '(' -> { + depth++ + } + ')' -> { depth-- if (depth == 0) { @@ -103,8 +116,14 @@ object JUnitTestSignatureVerifier { if (j >= decl.length) return Decision.Continue val name = NAME.find(decl, funIdx)?.groupValues?.get(1) ?: decl.substring(funIdx) return when { - decl[j] == '{' -> Decision.Safe - decl[j] == '=' -> headDecision(decl.substring(j + 1), name) + decl[j] == '{' -> { + Decision.Safe + } + + decl[j] == '=' -> { + bodyDecision(decl.substring(j + 1), name) + } + decl[j] == ':' -> { // Explicit return type: `: Unit` is safe, anything // else with `=` needs the head to be Unit-safe. @@ -113,10 +132,13 @@ object JUnitTestSignatureVerifier { eqOrBrace < 0 -> Decision.Continue decl[eqOrBrace] == '{' -> Decision.Safe decl.substring(j + 1, eqOrBrace).trim() == "Unit" -> Decision.Safe - else -> headDecision(decl.substring(eqOrBrace + 1), name) + else -> bodyDecision(decl.substring(eqOrBrace + 1), name) } } - else -> Decision.Continue + + else -> { + Decision.Continue + } } } } @@ -126,7 +148,28 @@ object JUnitTestSignatureVerifier { return Decision.Continue } - private fun headDecision(headRaw: String, name: String): Decision { + /** + * Decides from the text that follows the body separator (`=`). + * + * A blank head means the body starts on the next line — `fun x() =` with the expression + * on a continuation line, which includes the Unit-safe form the verifier documents + * (`= runBlocking { ... }`). Such a declaration is not resolved yet, so + * it must stay pending until [scanFile] appends the continuation line. Deciding on a + * blank head instead rejects every legal multi-line expression body, whether or not it + * is Unit-safe. + * + * Both `=` paths (plain and explicit-return-type) route through here so the + * continuation rule cannot drift apart between them. + */ + private fun bodyDecision( + headRaw: String, + name: String, + ): Decision = if (headRaw.isBlank()) Decision.Continue else headDecision(headRaw, name) + + private fun headDecision( + headRaw: String, + name: String, + ): Decision { val head = headRaw.trim() return if (EXPLICIT_UNIT_HEAD.matches(head) || KNOWN_UNIT_HEAD.matches(head) || BARE_UNIT.matches(head)) { Decision.Safe @@ -136,11 +179,12 @@ object JUnitTestSignatureVerifier { } private fun scanFile(file: Path): List { - val lines = try { - Files.readAllLines(file) - } catch (_: Exception) { - return emptyList() - } + val lines = + try { + Files.readAllLines(file) + } catch (_: Exception) { + return emptyList() + } val violations = mutableListOf() var pending: StringBuilder? = null var inRawString = false @@ -186,16 +230,27 @@ object JUnitTestSignatureVerifier { * Evaluates an accumulated declaration; returns the continuation state * (null once the declaration resolved to Safe or Reject). */ - private fun resolve(pending: StringBuilder, file: Path, line: Int, violations: MutableList): StringBuilder? { - return when (val decision = decide(pending.toString())) { + private fun resolve( + pending: StringBuilder, + file: Path, + line: Int, + violations: MutableList, + ): StringBuilder? = + when (val decision = decide(pending.toString())) { is Decision.Reject -> { violations += Violation(file, line + 1, decision.functionName, decision.expressionHead) null } - Decision.Safe -> null - Decision.Continue -> pending + + Decision.Safe -> { + null + } + + Decision.Continue -> { + pending + } } - } + private fun String.countOccurrencesOf(sub: String): Int { var count = 0 var idx = indexOf(sub) diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScanner.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScanner.kt index 25888b8af..f8686b5c0 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScanner.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScanner.kt @@ -7,19 +7,20 @@ package dev.tramai.build.quality * Deterministic: same input always produces same output regardless of execution context. */ object KotlinCancellationCatchScanner { + private val broadCatchPatterns = + listOf( + Regex( + """catch\s*\(\s*(?:[A-Za-z_][A-Za-z0-9_]*|_)\s*:\s*(?:[A-Za-z_][A-Za-z0-9_]*\.)*(?:Exception|Throwable|RuntimeException)\s*\)""", + ), + Regex("""runCatching\s*\{"""), + ) - private val broadCatchPatterns = listOf( - Regex( - """catch\s*\(\s*(?:[A-Za-z_][A-Za-z0-9_]*|_)\s*:\s*(?:[A-Za-z_][A-Za-z0-9_]*\.)*(?:Exception|Throwable|RuntimeException)\s*\)""" - ), - Regex("""runCatching\s*\{""") - ) - - private val suspendPatterns = listOf( - Regex("""\bsuspend\s+fun\b"""), - Regex("""\bsuspend\s*\{"""), - Regex("""\bsuspend\s+\(""") - ) + private val suspendPatterns = + listOf( + Regex("""\bsuspend\s+fun\b"""), + Regex("""\bsuspend\s*\{"""), + Regex("""\bsuspend\s+\("""), + ) /** * Result of line joining: [joined] lines with their [originalIndices] mapping @@ -27,20 +28,27 @@ object KotlinCancellationCatchScanner { */ private data class JoinedLines( val lines: List, - val originalIndices: List // joined[i] started at original[originalIndices[i]] + // joined[i] started at original[originalIndices[i]] + val originalIndices: List, ) - fun scan(source: String, module: String, file: String): List { + fun scan( + source: String, + module: String, + file: String, + ): List { val findings = mutableListOf() val lines = source.lines() val suspendRanges = findSuspendRanges(lines) + // Only built when a broad catch actually needs sibling resolution. + val catchIndex by lazy { buildSourceIndex(source) } // Join multiline catches while tracking original line positions val joined = joinCatchLines(lines) for ((joinedIdx, line) in joined.lines.withIndex()) { val originalLineIdx = joined.originalIndices[joinedIdx] - val originalLineNum = originalLineIdx + 1 // 1-indexed for suspend range check + val originalLineNum = originalLineIdx + 1 // 1-indexed for suspend range check // Skip comment lines val trimmed = line.trim() @@ -48,7 +56,7 @@ object KotlinCancellationCatchScanner { if (trimmed.startsWith("*") && !trimmed.contains("*/")) continue if (trimmed.startsWith("/*") && !trimmed.contains("*/")) continue - for (pattern in broadCatchPatterns) { + for ((patternIdx, pattern) in broadCatchPatterns.withIndex()) { val match = pattern.find(line) ?: continue // Skip matches inside string literals if (isInsideStringLiteral(line, match.range.first)) continue @@ -56,24 +64,32 @@ object KotlinCancellationCatchScanner { val inSuspend = suspendRanges.any { originalLineNum in it } val functionName = findEnclosingFunction(lines, originalLineIdx) - val catchType = when { - line.contains("Throwable") -> "Throwable" - line.contains("RuntimeException") -> "RuntimeException" - line.contains("Exception") -> "Exception" - line.contains("runCatching") -> "runCatching" - else -> "unknown" - } + val catchType = + when { + line.contains("Throwable") -> "Throwable" + line.contains("RuntimeException") -> "RuntimeException" + line.contains("Exception") -> "Exception" + line.contains("runCatching") -> "runCatching" + else -> "unknown" + } - val rethrowsCancellation = checkRethrowsCancellation(lines, originalLineIdx) + // patternIdx 0 is the `catch (…: Exception|Throwable|RuntimeException)` + // clause — the only shape that can share a try with a sibling + // catch clause. runCatching has no sibling to be rescued by. + val preservesCancellationViaSiblingCatch = + patternIdx == 0 && + CatchClauseSyntax.checkEarlierSiblingPreservesCancellation(source, catchIndex, originalLineIdx) + val rethrowsCancellation = + checkRethrowsCancellation(lines, originalLineIdx) || preservesCancellationViaSiblingCatch val transformsException = checkTransformsException(lines, originalLineIdx) - val risk = when { - rethrowsCancellation -> "accepted" - inSuspend && transformsException -> "high" - inSuspend -> "critical" - !inSuspend -> "medium" - else -> "medium" - } + val risk = + when { + rethrowsCancellation -> "accepted" + inSuspend && transformsException -> "high" + inSuspend -> "critical" + else -> "medium" + } findings.add( CancellationCatchFinding( @@ -86,8 +102,8 @@ object KotlinCancellationCatchScanner { transformsException = transformsException, risk = risk, sourceLine = originalLineNum, - sourceFingerprint = fingerprintConstruct(lines, originalLineIdx, match) - ) + sourceFingerprint = fingerprintConstruct(lines, originalLineIdx, match), + ), ) } } @@ -114,12 +130,13 @@ object KotlinCancellationCatchScanner { while (i < lines.size) { val trimmed = lines[i].trim() // Match lines that contain a catch keyword followed by `(` without a closing `)`. - // Common patterns: `catch (`, `} catch (`, `} catch (` + // Common patterns: `catch (`, `} catch (`, `} catch (` val catchStart = trimmed.indexOf("catch") - val isMultiLineCatch = catchStart >= 0 && - trimmed.substring(catchStart).let { after -> - after.startsWith("catch") && after.contains("(") && !after.contains(")") - } + val isMultiLineCatch = + catchStart >= 0 && + trimmed.substring(catchStart).let { after -> + after.startsWith("catch") && after.contains("(") && !after.contains(")") + } if (isMultiLineCatch) { val sb = StringBuilder(lines[i]) val startIdx = i @@ -162,7 +179,10 @@ object KotlinCancellationCatchScanner { } /** Finds matching closing brace accounting for nested braces. */ - private fun findBlockEnd(lines: List, startIdx: Int): Int { + private fun findBlockEnd( + lines: List, + startIdx: Int, + ): Int { var braceCount = 0 var found = false for (i in startIdx until lines.size) { @@ -174,7 +194,10 @@ object KotlinCancellationCatchScanner { return startIdx + 1 } - private fun findEnclosingFunction(lines: List, idx: Int): String { + private fun findEnclosingFunction( + lines: List, + idx: Int, + ): String { for (i in idx downTo 0) { val funMatch = Regex("""fun\s+(\w+)""").find(lines[i].trim()) if (funMatch != null) return funMatch.groupValues[1] @@ -208,7 +231,7 @@ object KotlinCancellationCatchScanner { private fun fingerprintConstruct( lines: List, startIdx: Int, - match: MatchResult + match: MatchResult, ): String { val endIdx = findConstructEndIdx(lines, startIdx, match) ?: return "" return (startIdx..endIdx) @@ -231,7 +254,11 @@ object KotlinCancellationCatchScanner { * included), strings, char literals and raw strings are ignored, with * state carried across lines. */ - private fun findConstructEndIdx(lines: List, startIdx: Int, match: MatchResult): Int? { + private fun findConstructEndIdx( + lines: List, + startIdx: Int, + match: MatchResult, + ): Int? { var depth = 0 var opened = false var state = LexState.CODE @@ -247,57 +274,82 @@ object KotlinCancellationCatchScanner { while (j < line.length) { val c = line[j] when (state) { - LexState.CODE -> when { - c == '/' && j + 1 < line.length && line[j + 1] == '/' -> { - state = LexState.LINE_COMMENT - j++ - } - c == '/' && j + 1 < line.length && line[j + 1] == '*' -> { - state = LexState.BLOCK_COMMENT - blockCommentDepth = 1 - j++ - } - c == '"' && j + 2 < line.length && line[j + 1] == '"' && line[j + 2] == '"' -> { - state = LexState.RAW_STRING - j += 2 - } - c == '"' -> state = LexState.STRING - c == '\'' -> state = LexState.CHAR - c == '{' -> { - depth++ - opened = true - } - c == '}' -> if (opened) { - depth-- - if (depth == 0) return i + LexState.CODE -> { + when { + c == '/' && j + 1 < line.length && line[j + 1] == '/' -> { + state = LexState.LINE_COMMENT + j++ + } + + c == '/' && j + 1 < line.length && line[j + 1] == '*' -> { + state = LexState.BLOCK_COMMENT + blockCommentDepth = 1 + j++ + } + + c == '"' && j + 2 < line.length && line[j + 1] == '"' && line[j + 2] == '"' -> { + state = LexState.RAW_STRING + j += 2 + } + + c == '"' -> { + state = LexState.STRING + } + + c == '\'' -> { + state = LexState.CHAR + } + + c == '{' -> { + depth++ + opened = true + } + + c == '}' -> { + if (opened) { + depth-- + if (depth == 0) return i + } + } + + else -> {} } - else -> {} } + LexState.LINE_COMMENT -> { /* skip to end of line */ } - LexState.BLOCK_COMMENT -> when { - c == '/' && j + 1 < line.length && line[j + 1] == '*' -> { - // Kotlin block comments nest - blockCommentDepth++ - j++ - } - c == '*' && j + 1 < line.length && line[j + 1] == '/' -> { - blockCommentDepth-- - if (blockCommentDepth == 0) state = LexState.CODE - j++ + + LexState.BLOCK_COMMENT -> { + when { + c == '/' && j + 1 < line.length && line[j + 1] == '*' -> { + // Kotlin block comments nest + blockCommentDepth++ + j++ + } + + c == '*' && j + 1 < line.length && line[j + 1] == '/' -> { + blockCommentDepth-- + if (blockCommentDepth == 0) state = LexState.CODE + j++ + } + + else -> {} } - else -> {} } - LexState.STRING, LexState.CHAR -> + + LexState.STRING, LexState.CHAR -> { if (c == '\\') { j++ // skip escaped char } else if ((state == LexState.STRING && c == '"') || (state == LexState.CHAR && c == '\'')) { state = LexState.CODE } - LexState.RAW_STRING -> + } + + LexState.RAW_STRING -> { if (c == '"' && j + 2 < line.length && line[j + 1] == '"' && line[j + 2] == '"') { state = LexState.CODE j += 2 } + } } j++ } @@ -314,36 +366,24 @@ object KotlinCancellationCatchScanner { return match?.groupValues?.get(1) } - /** - * Strips inline comments (// ...) from a line of source code. - * Does NOT handle block comments correctly — those are assumed to be - * on their own lines (and already skipped by the caller). - */ - private fun stripComment(line: String): String { - val idx = line.indexOf("//") - return if (idx >= 0) line.substring(0, idx) else line - } - - /** Check if the catch block rethrows CancellationException (within catch body, including nested blocks). - * Requires that the caught exception variable itself is rethrown AND the throw is inside the - * same branch as the CancellationException check, not merely nearby. - * Also recognizes `catchVar.rethrowIfCancellation()` as a safe pattern. - * For the rethrowIfCancellation() helper, it must be the FIRST non-blank, non-comment - * line in the catch body — before it, only blank lines and comments are allowed. */ - private fun checkRethrowsCancellation(lines: List, catchIdx: Int): Boolean { + private fun checkRethrowsCancellation( + lines: List, + catchIdx: Int, + ): Boolean { val catchVar = extractCatchVariable(lines[catchIdx]) ?: return false val catchBodyEnd = findCatchBodyEnd(lines, catchIdx) val end = minOf(catchBodyEnd, lines.size) // Pattern: .rethrowIfCancellation() — extension-based cancellation rethrow - val rethrowIfCancellationPattern = Regex( - """\b${Regex.escape(catchVar)}\.rethrowIfCancellation\s*\(\s*\)""" - ) + val rethrowIfCancellationPattern = + Regex( + """\b${Regex.escape(catchVar)}\.rethrowIfCancellation\s*\(\s*\)""", + ) // Check rethrowIfCancellation() — must be first non-blank, non-comment line var foundHelper = false for (i in catchIdx + 1 until end) { - val stripped = stripComment(lines[i]) + val stripped = CatchClauseSyntax.stripComment(lines[i]) val trimmed = stripped.trim() if (trimmed.isBlank()) continue // Skip multi-line block comment starts and line-comment-only lines @@ -352,9 +392,10 @@ object KotlinCancellationCatchScanner { if (trimmed.startsWith("/*") && !trimmed.contains("*/")) continue // This is the first real statement — check if the ENTIRE line is // just: .rethrowIfCancellation() (with optional trailing semicolon) - val fullLinePattern = Regex( - """^\s*${Regex.escape(catchVar)}\.rethrowIfCancellation\s*\(\s*\)\s*;?\s*$""" - ) + val fullLinePattern = + Regex( + """^\s*${Regex.escape(catchVar)}\.rethrowIfCancellation\s*\(\s*\)\s*;?\s*$""", + ) if (fullLinePattern.matches(trimmed)) { foundHelper = true } @@ -365,12 +406,13 @@ object KotlinCancellationCatchScanner { // Pattern: throw (exact variable rethrow) val throwVarPattern = Regex("""\bthrow\s+${Regex.escape(catchVar)}\b""") // Pattern: if (variable is [...][.]CancellationException[...]) - val cancellationIfPattern = Regex( - """\bif\s*\(\s*${Regex.escape(catchVar)}\s+is\s+[A-Za-z_.]*CancellationException""" - ) + val cancellationIfPattern = + Regex( + """\bif\s*\(\s*${Regex.escape(catchVar)}\s+is\s+[A-Za-z_.]*CancellationException""", + ) for (i in catchIdx + 1 until end) { - val stripped = stripComment(lines[i]) + val stripped = CatchClauseSyntax.stripComment(lines[i]) if (stripped.isBlank()) continue // Only consider lines that are checking for CancellationException on our caught variable @@ -389,19 +431,271 @@ object KotlinCancellationCatchScanner { // Search inside the if-block for `throw var` for (j in i + 1 until minOf(ifBlockEnd, end)) { - if (throwVarPattern.containsMatchIn(stripComment(lines[j]))) return true + if (throwVarPattern.containsMatchIn(CatchClauseSyntax.stripComment(lines[j]))) return true } } return false } + /** Single forward pass: bracket pairing, code mask and line starts. */ + private fun buildSourceIndex(source: String): SourceIndex { + val openOffsets = HashMap() + val closeOffsets = HashMap() + val codeMask = BooleanArray(source.length) { true } + scanSourceStructure(source, openOffsets, closeOffsets, codeMask) + return SourceIndex(openOffsets, closeOffsets, codeMask, SourceStructure.buildLineStarts(source)) + } + /** - * Find the end of the block that follows an `if (...)` statement. - * Returns the index of the closing `}` (exclusive), or -1 if not found. - * Handles: `if (cond) statement` (single-line, no brace) and `if (cond) { ... }`. + * The lexical walk: bracket pairing and code mask. Owns every piece of lexical state + * (stack, state, blockCommentDepth, spanStart, i) plus span closing; the output containers + * are passed in and written exactly as before. + */ + private fun scanSourceStructure( + source: String, + openOffsets: HashMap, + closeOffsets: HashMap, + codeMask: BooleanArray, + ) { + val stack = ArrayDeque() + var state = LexState.CODE + var blockCommentDepth = 0 + var spanStart = -1 + + fun closeSpan(endExclusive: Int) { + if (spanStart >= 0) { + codeMask.fill(false, spanStart, minOf(endExclusive, source.length)) + spanStart = -1 + } + } + var i = 0 + + // Same shape as closeSpan below: a local mutating helper, so the graded body of this + // function stays a dispatch loop. Application order is unchanged. + fun applyTransition(transition: LexTransition) { + transition.spanStartAt?.let { spanStart = it } + transition.blockCommentDepth?.let { blockCommentDepth = it } + transition.closeSpanAt?.let { closeSpan(it) } + state = transition.state + i += transition.consumed + } + + while (i < source.length) { + when (state) { + LexState.CODE -> { + val transition = advanceCode(source, i) + if (transition == null) { + SourceStructure.recordBracketAt(source, i, stack, openOffsets, closeOffsets) + } else { + applyTransition(transition) + } + } + + LexState.LINE_COMMENT -> { + applyTransition(advanceLineComment(source, i)) + } + + LexState.BLOCK_COMMENT -> { + applyTransition(advanceBlockComment(source, i, blockCommentDepth)) + } + + LexState.STRING, LexState.CHAR -> { + applyTransition(advanceQuoted(source, i, state)) + } + + LexState.RAW_STRING -> { + applyTransition(advanceRawString(source, i)) + } + } + i++ + } + closeSpan(source.length) + } + + /** Minimal state-transition description for a single lexical step. */ + private data class LexTransition( + val state: LexState, + val consumed: Int = 0, + val closeSpanAt: Int? = null, + // null = this transition does not modify spanStart; a value = assign exactly that + // absolute source offset. A coordinate, symmetric with closeSpanAt — not a command. + val spanStartAt: Int? = null, + // null = this transition does not alter depth; a value = assign exactly this depth. + // Deliberately not a delta and not a command. + val blockCommentDepth: Int? = null, + ) + + /** + * LINE_COMMENT handling for the character at absolute [offset]: a newline closes the span at + * that offset and returns to code; any other character stays inside the line comment. + */ + private fun advanceLineComment( + source: String, + offset: Int, + ): LexTransition = + if (source[offset] == '\n') { + LexTransition(state = LexState.CODE, closeSpanAt = offset) + } else { + LexTransition(LexState.LINE_COMMENT) + } + + /** + * RAW_STRING handling for the character at absolute [offset]: a closing triple quote consumes + * two further characters and closes the span three characters past the delimiter start. The + * asymmetry against [advanceLineComment] is the original behaviour, preserved literally. + */ + private fun advanceRawString( + source: String, + offset: Int, + ): LexTransition = + if (SourceStructure.startsRawStringAt(source, offset)) { + LexTransition(state = LexState.CODE, consumed = 2, closeSpanAt = offset + 3) + } else { + LexTransition(LexState.RAW_STRING) + } + + /** + * STRING/CHAR handling for the character at absolute [offset] while in [state]: a backslash + * consumes the escaped character, the matching quote closes the span one past [offset], and any + * other character stays inside the literal. The branch is shared by both states, so the incoming + * state is the only extra input — no new transition fields are needed. + */ + private fun advanceQuoted( + source: String, + offset: Int, + state: LexState, + ): LexTransition = + when { + source[offset] == '\\' -> { + LexTransition(state = state, consumed = 1) + } + + state == LexState.STRING && source[offset] == '"' -> { + LexTransition(state = LexState.CODE, closeSpanAt = offset + 1) + } + + state == LexState.CHAR && source[offset] == '\'' -> { + LexTransition(state = LexState.CODE, closeSpanAt = offset + 1) + } + + else -> { + LexTransition(state = state) + } + } + + /** True when a nested block-comment opener starts at [offset]. */ + private fun startsBlockCommentAt( + source: String, + offset: Int, + ): Boolean = source.startsWith("/*", offset) + + /** True when a block-comment closer starts at [offset]. */ + private fun endsBlockCommentAt( + source: String, + offset: Int, + ): Boolean = source.startsWith("*/", offset) + + /** + * BLOCK_COMMENT handling for the character at absolute [offset] at nesting [depth]: `/*` nests + * one level deeper, `*/` unwinds one level and returns to code when the outermost closes. Both + * delimiter cases consume one further character, matching the original inner i++. */ - private fun findBlockAfterIf(lines: List, ifIdx: Int, limit: Int): Int { + private fun advanceBlockComment( + source: String, + offset: Int, + depth: Int, + ): LexTransition = + when { + startsBlockCommentAt(source, offset) -> { + LexTransition( + state = LexState.BLOCK_COMMENT, + consumed = 1, + blockCommentDepth = depth + 1, + ) + } + + endsBlockCommentAt(source, offset) -> { + if (depth == 1) { + LexTransition( + state = LexState.CODE, + consumed = 1, + closeSpanAt = offset + 1, + blockCommentDepth = 0, + ) + } else { + LexTransition( + state = LexState.BLOCK_COMMENT, + consumed = 1, + blockCommentDepth = depth - 1, + ) + } + } + + else -> { + LexTransition(state = LexState.BLOCK_COMMENT) + } + } + + /** True when a line-comment opener starts at [offset]. */ + private fun isLineCommentStart( + source: String, + offset: Int, + ): Boolean = source.startsWith("//", offset) + + /** + * The five lexical mode entries reachable from CODE, in the original precedence order: + * line comment, block comment, raw string, string, char. Bracket handling is deliberately not + * part of this and is not representable as a transition. + */ + private fun advanceCode( + source: String, + offset: Int, + ): LexTransition? = + when { + isLineCommentStart(source, offset) -> { + LexTransition( + state = LexState.LINE_COMMENT, + consumed = 1, + spanStartAt = offset, + ) + } + + startsBlockCommentAt(source, offset) -> { + LexTransition( + state = LexState.BLOCK_COMMENT, + consumed = 1, + spanStartAt = offset, + blockCommentDepth = 1, + ) + } + + SourceStructure.startsRawStringAt(source, offset) -> { + LexTransition( + state = LexState.RAW_STRING, + consumed = 2, + spanStartAt = offset, + ) + } + + source[offset] == '"' -> { + LexTransition(state = LexState.STRING, spanStartAt = offset) + } + + source[offset] == '\'' -> { + LexTransition(state = LexState.CHAR, spanStartAt = offset) + } + + else -> { + null + } + } + + private fun findBlockAfterIf( + lines: List, + ifIdx: Int, + limit: Int, + ): Int { val ifLine = lines[ifIdx] val braceIdx = ifLine.indexOf('{') if (braceIdx >= 0) { @@ -420,7 +714,10 @@ object KotlinCancellationCatchScanner { /** Find the end of the catch body (closing brace of the catch block). * Counts only `{` from the catch line (ignoring `}` which may close the try block), * then balances from the next line onward. */ - private fun findCatchBodyEnd(lines: List, catchIdx: Int): Int { + private fun findCatchBodyEnd( + lines: List, + catchIdx: Int, + ): Int { // Count opening braces on the catch line (ignore closes — they belong to try/if blocks) var braceCount = lines[catchIdx].count { it == '{' } if (braceCount == 0) { @@ -442,25 +739,43 @@ object KotlinCancellationCatchScanner { } /** Returns true if the given position on the line is inside a string literal. */ - private fun isInsideStringLiteral(line: String, pos: Int): Boolean { + private fun isInsideStringLiteral( + line: String, + pos: Int, + ): Boolean { var inString = false var quoteChar: Char? = null var escaped = false for (i in 0 until pos) { if (i >= line.length) break val ch = line[i] - if (escaped) { escaped = false; continue } - if (ch == '\\') { escaped = true; continue } + if (escaped) { + escaped = false + continue + } + if (ch == '\\') { + escaped = true + continue + } if (inString) { - if (ch == quoteChar) { inString = false; quoteChar = null } + if (ch == quoteChar) { + inString = false + quoteChar = null + } } else { - if (ch == '\"' || ch == '\'') { inString = true; quoteChar = ch } + if (ch == '\"' || ch == '\'') { + inString = true + quoteChar = ch + } } } return inString } - private fun checkTransformsException(lines: List, catchIdx: Int): Boolean { + private fun checkTransformsException( + lines: List, + catchIdx: Int, + ): Boolean { val catchBodyEnd = findCatchBodyEnd(lines, catchIdx) val end = minOf(catchBodyEnd, lines.size) for (i in catchIdx + 1 until end) { @@ -471,11 +786,292 @@ object KotlinCancellationCatchScanner { return false } - private fun riskWeight(risk: String): Int = when (risk) { - "critical" -> 4 - "high" -> 3 - "medium" -> 2 - "accepted" -> 1 - else -> 0 + private fun riskWeight(risk: String): Int = + when (risk) { + "critical" -> 4 + "high" -> 3 + "medium" -> 2 + "accepted" -> 1 + else -> 0 + } +} + +/** + * Lexically-resolved structure of one source text: bracket pairs in both + * directions (closer→opener and opener→closer), plus a mask marking which + * offsets are executable code (never inside a comment or a literal). + * Sibling-catch recognition must not be fooled by braces, parens or + * keywords that sit inside comments and strings. + */ +private class SourceIndex( + val openOffsets: Map, + val closeOffsets: Map, + val codeMask: BooleanArray, + val lineStarts: IntArray, +) + +/** Source-position and structural primitives shared by the scanner's parse passes. */ +private object SourceStructure { + /** + * Bracket bookkeeping for the character at [offset], executed only when no lexical mode entry + * applies. An opener is pushed; a closer pairs with the innermost opener if one exists. No + * bracket-type validation and no malformed-input recovery, matching the original behaviour. + */ + fun recordBracketAt( + source: String, + offset: Int, + stack: ArrayDeque, + openOffsets: MutableMap, + closeOffsets: MutableMap, + ) { + if (source[offset] == '(' || source[offset] == '{') { + stack.addLast(offset) + return + } + if (source[offset] == ')' || source[offset] == '}') { + stack.removeLastOrNull()?.let { opener -> + openOffsets[offset] = opener + closeOffsets[opener] = offset + } + } + } + + /** + * Line start offsets of [source], including offset 0. Independent preparation pass: it reads + * nothing from and writes nothing into the lexical walk, so its equivalence can be proven + * separately from the state machine. + */ + fun buildLineStarts(source: String): IntArray { + val starts = ArrayList() + starts.add(0) + source.forEachIndexed { i, c -> if (c == '\n') starts.add(i + 1) } + return starts.toIntArray() + } + + /** + * True when a triple-quote raw-string delimiter starts at [offset]. Pure: reads [source] only. + * Extracted from the lexical walk so the close condition keeps its original operands (bounds + * check plus delimiter match) without tripping the condition-complexity threshold. + */ + fun startsRawStringAt( + source: String, + offset: Int, + ): Boolean = offset + 2 < source.length && source.startsWith("\"\"\"", offset) + + /** Previous executable-code offset before [offset], or -1. */ + fun previousCodeOffset( + source: String, + index: SourceIndex, + offset: Int, + ): Int { + var i = minOf(offset, source.length) - 1 + while (i >= 0 && (!index.codeMask[i] || source[i].isWhitespace())) i-- + return i } + + /** + * The first non-whitespace code character at or after [from]: its offset when it equals + * [target], null when some other code character intervenes or none is found. + */ + fun codeOffsetOf( + source: String, + index: SourceIndex, + from: Int, + target: Char, + ): Int? { + val firstCodeChar = + (from until source.length).firstOrNull { index.codeMask[it] && !source[it].isWhitespace() } + return firstCodeChar?.takeIf { source[it] == target } + } + + /** True when [keyword] ends exactly at [offset] as a standalone token. */ + fun keywordEndsAt( + source: String, + offset: Int, + keyword: String, + ): Boolean { + val start = offset - keyword.length + 1 + if (start < 0 || !source.regionMatches(start, keyword, 0, keyword.length)) return false + return start == 0 || !(source[start - 1].isLetterOrDigit() || source[start - 1] == '_') + } + + /** + * Offset of the `catch` keyword of the clause that produced a finding on + * [lineIdx]. ponytail: takes the last `catch` token on the line — source in + * this repo is one catch clause per line; a compact multi-catch line would + * resolve to the wrong clause and degrade to "not accepted". + */ + fun catchKeywordOffset( + source: String, + index: SourceIndex, + lineIdx: Int, + ): Int? { + if (lineIdx !in index.lineStarts.indices) return null + val lineStart = index.lineStarts[lineIdx] + val lineEnd = if (lineIdx + 1 < index.lineStarts.size) index.lineStarts[lineIdx + 1] else source.length + return Regex("""\bcatch\b""") + .findAll(source.substring(lineStart, lineEnd)) + .map { lineStart + it.range.first } + .filter { it < source.length && index.codeMask[it] } + .lastOrNull() + } +} + +/** Catch-clause parsing and sibling-catch structural analysis. */ +private object CatchClauseSyntax { + /** + * The catch keyword offset of the sibling clause directly preceding [keywordOffset], or null + * when the construct before it is not a `catch (...) { ... }`. + */ + fun previousSiblingCatchKeyword( + source: String, + index: SourceIndex, + keywordOffset: Int, + ): Int? = + SourceStructure + .previousCodeOffset(source, index, keywordOffset) + .takeIf { it >= 0 && source[it] == '}' } + ?.let { closer -> previousCatchKeywordBeforeBody(source, index, closer) } + + /** The `catch` keyword offset of the clause whose body closes at [closer], or null. */ + fun previousCatchKeywordBeforeBody( + source: String, + index: SourceIndex, + closer: Int, + ): Int? = + index.openOffsets[closer] + ?.let { blockOpen -> SourceStructure.previousCodeOffset(source, index, blockOpen) } + ?.takeIf { it >= 0 && source[it] == ')' } + ?.let { beforeBlock -> index.openOffsets[beforeBlock] } + ?.let { paramOpen -> SourceStructure.previousCodeOffset(source, index, paramOpen) } + ?.takeIf { SourceStructure.keywordEndsAt(source, it, "catch") } + ?.let { beforeParams -> beforeParams - "catch".length + 1 } + + /** + * True when the broad catch clause on [catchLineIdx] shares its `try` + * expression with an EARLIER sibling catch clause that catches + * CancellationException (bare or qualified) and ultimately throws that + * clause's own catch variable — cleanup statements before the throw are + * allowed. Cancellation is then preserved by the try statement as a whole, + * so the broad catch is cancellation-safe. + * + * Resolved structurally, not by a file-wide regex: the chain of catch + * clauses is walked backwards through lexically balanced braces/parens until + * the `try` keyword, so a CancellationException catch belonging to a nested + * try inside this clause's body, or to an unrelated try, or appearing AFTER + * this clause is never mistaken for a sibling. Kotlin itself rejects some of + * those orderings at compile time; the gate decides from syntax alone. + * + * The backwards walk is expressed as a sequence of sibling keyword offsets + * (see [previousSiblingCatchKeyword]) so no mutable cursor is needed. + */ + fun checkEarlierSiblingPreservesCancellation( + source: String, + index: SourceIndex, + catchLineIdx: Int, + ): Boolean { + val firstCatch = SourceStructure.catchKeywordOffset(source, index, catchLineIdx) ?: return false + return generateSequence(firstCatch) { previousSiblingCatchKeyword(source, index, it) } + .drop(1) + .any { catchClauseRethrowsItsOwnVariable(source, index, it) } + } + + /** Bounds of the `( ... )` catch parameter list following the keyword, or null. */ + fun catchParameterBounds( + source: String, + index: SourceIndex, + keywordOffset: Int, + ): IntRange? = + SourceStructure + .codeOffsetOf(source, index, keywordOffset + "catch".length, '(') + ?.let { paramOpen -> index.closeOffsets[paramOpen]?.let { paramClose -> paramOpen..paramClose } } + + /** Bounds of the `{ ... }` catch body following the parameter list, or null. */ + fun catchBodyBounds( + source: String, + index: SourceIndex, + paramClose: Int, + ): IntRange? = + SourceStructure + .codeOffsetOf(source, index, paramClose + 1, '{') + ?.let { bodyOpen -> index.closeOffsets[bodyOpen]?.let { bodyClose -> bodyOpen..bodyClose } } + + /** + * The catch variable name when the parameter list's catch type ends in CancellationException, + * null for any other type or an unparsable parameter. + */ + fun cancellationCatchVariable( + source: String, + paramOpen: Int, + paramClose: Int, + ): String? = + catchParameterPattern + .find(source.substring(paramOpen + 1, paramClose).trim()) + ?.takeIf { it.groupValues[2].substringAfterLast('.') == "CancellationException" } + ?.groupValues + ?.get(1) + + /** A structurally resolved cancellation catch clause: its variable name and body bounds. */ + data class ParsedCatchClause( + val variable: String, + val body: IntRange, + ) + + /** Parses the clause at [keywordOffset]; null when incomplete or not a CancellationException. */ + fun parseCancellationCatchClause( + source: String, + index: SourceIndex, + keywordOffset: Int, + ): ParsedCatchClause? = + catchParameterBounds(source, index, keywordOffset)?.let { params -> + cancellationCatchVariable(source, params.first, params.last)?.let { variable -> + catchBodyBounds(source, index, params.last)?.let { ParsedCatchClause(variable, it) } + } + } + + /** + * True when the catch clause opening at [keywordOffset] catches + * CancellationException and its body throws that clause's own catch + * variable as a standalone statement. A different variable, a new + * exception, a transformation (`throw c.cause`) or no throw at all are all + * rejected. + */ + fun catchClauseRethrowsItsOwnVariable( + source: String, + index: SourceIndex, + keywordOffset: Int, + ): Boolean { + val clause = parseCancellationCatchClause(source, index, keywordOffset) ?: return false + val throwOwnVariable = Regex("""\bthrow\s+${Regex.escape(clause.variable)}\s*[;}]?\s*$""") + return source + .substring(clause.body.first, clause.body.last + 1) + .lines() + .any { throwOwnVariable.containsMatchIn(stripComment(it)) } + } + + /** `variable: Type` of a catch parameter list. */ + val catchParameterPattern = Regex("""^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_.]*)$""") + + /* + * Find the end of the block that follows an `if (...)` statement. + * Returns the index of the closing `}` (exclusive), or -1 if not found. + * Handles: `if (cond) statement` (single-line, no brace) and `if (cond) { ... }`. + */ + + /** + * Strips inline comments (// ...) from a line of source code. + * Does NOT handle block comments correctly — those are assumed to be + * on their own lines (and already skipped by the caller). + */ + fun stripComment(line: String): String { + val idx = line.indexOf("//") + return if (idx >= 0) line.substring(0, idx) else line + } + + /* Check if the catch block rethrows CancellationException (within catch body, including nested blocks). + * Requires that the caught exception variable itself is rethrown AND the throw is inside the + * same branch as the CancellationException check, not merely nearby. + * Also recognizes `catchVar.rethrowIfCancellation()` as a safe pattern. + * For the rethrowIfCancellation() helper, it must be the FIRST non-blank, non-comment + * line in the catch body — before it, only blank lines and comments are allowed. */ } diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt index cc5fae412..80765a273 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MaintainabilityBaselinePlugin.kt @@ -798,14 +798,17 @@ abstract class MaintainabilityBaselinePlugin : Plugin { // (test-quality.yml) vs the PR base / master authority. Deliberately // runs NO PITest campaign: it compares exact identities and canonical // KILLED|NON_KILLED outcomes in memory, so it joins verifyPr without - // adding a measurement run. Accepts -PtramaiMutationBaseSha for PR - // base SHA comparison (mirrors cancellation safety and coverage). + // adding a measurement run. Recorded evolution performs a fresh exact + // measurement before it can authorize any removal. Accepts + // -PtramaiMutationBaseSha for PR base SHA comparison (mirrors cancellation safety and coverage). project.tasks.register("verifyMutationRatchet") { group = "maintainability" description = - "Base-authoritative mutation ratchet: judges candidate mutation population, classifications, " + + "Base-authoritative mutation ratchet: judges candidate population and classifications, " + "and target configuration against the PR base / master authority. Accepts " + - "-PtramaiMutationBaseSha for PR base SHA comparison. Runs no PITest campaign." + "-PtramaiMutationBaseSha for PR base SHA comparison and -P${MutationPopulationEvolution.PROPERTY} " + + "for population evolution authority. Runs no PITest campaign in the default FORBID " + + "mode; recorded evolution runs a fresh canonical campaign first." doLast { val baseSha = MutationRatchetAuthorityLoader.resolveBaseSha( @@ -828,19 +831,64 @@ abstract class MaintainabilityBaselinePlugin : Plugin { throw GradleException("Failed to read candidate mutation population: ${e.message}", e) } val candidateClassifications = MutationClassificationLoader.load(project.rootDir) + // The property is invocation authority; YAML records are the per-identity audit trail. + val evolution = + MutationPopulationEvolution.fromProperty( + project.findProperty(MutationPopulationEvolution.PROPERTY)?.toString(), + ) + val evolutionRecords = MutationEvolutionLoader.load(project.rootDir) + val exactMeasurement = + if (evolution == MutationPopulationEvolution.RECORDED_EVOLUTION) { + val fresh = + runMutationMeasurement( + MutationMeasurementRequest( + project, + generator, + testQualityConfiguration, + reportDir, + measurementName = "mutation-evolution", + persistCommittedBaseline = false, + ), + ) + MutationPopulationEvolutionProof.exactComparison(fresh, candidatePopulation) + } else { + MutationPopulationExactComparison(null, emptyList()) + } val candidate = MutationRatchetCandidate( population = candidatePopulation, classifications = candidateClassifications, targetFamilies = testQualityConfiguration.mutation.targetFamilies, + enrollments = MutationClassificationEnrollmentLoader.load(project.rootDir), + // The transition's own proposal must be loaded from the repository, not + // defaulted away: without it M35 cannot bind a minted authorization to its + // base, and M36-M38 cannot see retention or rewriting of a pending row. + admissions = MutationPopulationAdmissionLoader.load(project.rootDir), + // The transition's own certificate ledger, loaded the same way: M45 binds a + // newly minted certificate to this base, M46 rejects a rewritten retained one, + // and M47 must be able to see a base certificate disappear. + certificates = MutationAuthorityDigestCertificateLoader.load(project.rootDir), + // The promotion declaration is loaded from the transition's OWN tree by design: + // it is candidate-side, because it must name the base SHA it promotes onto and + // the base branch cannot name the commit that will contain it. Absent means "no + // promotion", which is the fail-closed state - M35/M45 then bind every + // appearing row to the transition base exactly as before. + promotion = MutationPopulationPromotionLoader.load(project.rootDir), ) val diagnostics = MutationRatchetVerifier().verify( authority, candidate, executable = MutationPopulationAggregator.canonicalSemantics(), + evolution = evolution, + evolutionEvidence = + MutationEvolutionEvidence(evolutionRecords, exactMeasurement.proof), ) - verifyTestQualityDiagnostics(project, "Mutation ratchet (base $baseSha)", diagnostics) + verifyTestQualityDiagnostics( + project, + "Mutation ratchet (base $baseSha)", + exactMeasurement.diagnostics + diagnostics, + ) } } @@ -872,6 +920,14 @@ abstract class MaintainabilityBaselinePlugin : Plugin { throw GradleException("Failed to read release mutation authority: ${e.message}", e) } val classifications = MutationClassificationLoader.load(project.rootDir) + // The release check compares a fresh measurement against the committed authority in the + // same tree, so the enrollment ledger is identical on both sides: an enrollment can never + // authorize a classification in the transition that introduces it (M23 still applies). + val enrollments = MutationClassificationEnrollmentLoader.load(project.rootDir) + // Release verification reasons over the committed authority surface on both sides, + // exactly like the enrollment ledger above, so the candidate side supplies the same + // loaded ledger instead of a default. + val admissions = MutationPopulationAdmissionLoader.load(project.rootDir) verifyTestQualityDiagnostics( project, "Release mutation", @@ -882,12 +938,16 @@ abstract class MaintainabilityBaselinePlugin : Plugin { population = committed, classifications = classifications, targetFamilies = testQualityConfiguration.mutation.targetFamilies, + enrollments = enrollments, + admissions = admissions, ), candidate = MutationRatchetCandidate( population = current, classifications = classifications, targetFamilies = testQualityConfiguration.mutation.targetFamilies, + enrollments = enrollments, + admissions = admissions, ), executable = MutationPopulationAggregator.canonicalSemantics(), ), diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt new file mode 100644 index 000000000..860d6c737 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificate.kt @@ -0,0 +1,117 @@ +package dev.tramai.build.quality + +import java.security.MessageDigest + +/** + * Base-side digest-migration certificate (0.7.1g1P2, M40-M47). + * + * This is the artifact that carries a *semantic* upgrade of the whole-population authority digest + * without rewriting historical authority. The P1 authorizations are bound to `raw-v1` raw-status + * digests and must stay byte-identical; the certificate stands beside them and states, in base + * authority, that one exact `fromDigest` is equivalent to one exact `toDigest` for one exact, + * bounded set of authorizations. M36's payload comparison never sees it, because it lives in its + * own ledger - no exception branch, no permitted-field list, is added to the admissions rules. + * + * ``` + * mint against the exact base (M45) + * -> retain byte-identically (M46) + * -> consume only from base (M43 + M42 + M41 + M40) + * -> remove in the valid consuming transition (M44 + M47) + * ``` + * + * ## Field semantics + * + * - [fromAlgorithm] / [fromDigest] - the digest semantics the covered authorizations were minted + * under, and that exact digest. M41 requires this to match the cited admission's + * `populationDigest`; a certificate can never be applied to a different authorization than the + * one whose digest it names. + * - [toAlgorithm] / [toDigest] - the digest semantics the covered authorizations are to be consumed + * under, and that exact digest. M40 requires [toDigest] to equal the verifier's own fresh + * authority projection, never anything the candidate supplies. + * - [admissionSetDigest] - SHA-256 over the exact set of covered identities, sorted and joined with + * `"\n"` with a trailing newline (the same recipe this repository uses for manifest digests). M42 + * requires it to equal the exact set of base authorizations, which is what makes the certificate + * *bounded*: it cannot be widened to cover a different or later set. + * - [fromBaseSha] - the authority base the certificate is minted against. **Enforced at + * introduction only (M45); thereafter immutable provenance.** Same semantics as + * [MutationPopulationAdmission.fromBaseSha]: a certificate legitimately survives intermediate + * merges, so requiring it to track the immediate base would make delayed consumption impossible; + * its enforcement value is anti-replay at mint time. + * - [reason] - the recorded provenance of the supersession, part of the enforced payload. + * - [authorizedBy], [authorizedAt] - **audit only, no enforcement value.** Recording them is fine; + * letting identity or actor carry trust would make them a bypass. + * + * ## Rule ownership + * + * M45 (mint binding), M46 (retained immutability) and M47 (removal custody) are lifecycle rules and + * live in [MutationAuthorityDigestCertificateCeremony]. M40-M44 are *consumption* rules and arrive + * with the certificate-aware consumption path; until then a base certificate may not disappear at + * all, which is fail-closed and is the stricter half of M47. + */ +data class MutationAuthorityDigestCertificate( + val fromAlgorithm: String, + val fromDigest: String, + val toAlgorithm: String, + val toDigest: String, + val admissionSetDigest: String, + val fromBaseSha: String, + val reason: String, + val authorizedBy: String? = null, + val authorizedAt: String? = null, +) { + /** + * The enforceable payload: every bound field except the audit-only ones. Base/candidate + * byte-identity is judged over this (M46), so rewriting any bound field fails while touching + * audit metadata alone can never manufacture authority. + */ + fun enforcedPayload(): List = + listOf( + fromAlgorithm, + fromDigest, + toAlgorithm, + toDigest, + admissionSetDigest, + fromBaseSha, + reason, + ) +} + +data class MutationAuthorityDigestCertificates( + val schemaVersion: String, + val certificates: List, +) { + /** + * Keyed by [MutationAuthorityDigestCertificate.fromDigest]: the source of a migration is + * unique. Two certificates claiming the same source digest would contradict each other, and the + * loader rejects that rather than leaving the winner to map iteration order. + */ + fun byFromDigest(): Map = certificates.associateBy { it.fromDigest } + + companion object { + /** The digest semantics a certificate may migrate from and to. */ + const val ALGORITHM_RAW_V1 = "raw-v1" + const val ALGORITHM_AUTHORITY_V2 = "authority-v2" + + /** Byte mask for the hex rendering of a digest. */ + private const val HEX_BYTE_MASK = 0xFF + + /** No certificates: strictly the most restrictive state, so no migration is possible. */ + val NONE = MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = emptyList()) + + /** + * The bounded admission-set digest (M42): SHA-256 over the covered identities, sorted and + * joined with `"\n"` with a trailing newline. + * + * The recipe is part of the contract, not an implementation detail: whoever mints a + * certificate must be able to reproduce this value exactly from the authorization set, and a + * test pins it against the real ledger. + */ + fun admissionSetDigest(identities: Collection): String { + val payload = identities.sorted().joinToString("\n", postfix = if (identities.isEmpty()) "" else "\n") + return MessageDigest + .getInstance("SHA-256") + .digest(payload.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and HEX_BYTE_MASK) } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt new file mode 100644 index 000000000..5eee3d4a8 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremony.kt @@ -0,0 +1,191 @@ +package dev.tramai.build.quality + +/** + * The digest-migration certificate's authority lifecycle (0.7.1g1P2, M45-M47). + * + * The certificate is itself base authority, so its whole lifecycle is fail-closed, not only its + * consumption. These three rules are the lifecycle half; M40-M44 are consumption rules and arrive + * with the certificate-aware consumption path. + * + * ``` + * mint against the exact base (M45) <- this file + * -> retain byte-identically (M46) <- this file + * -> consume only from base (M43 + M42 + M41 + M40) + * -> remove in the valid consuming transition (M44 + M47) <- M47's consuming half is here + * ``` + * + * Written standalone over the two ledgers plus the authority [baseSha], because a lifecycle rule is + * a transition property: it cannot be expressed by parsing one file, and it needs no coupling to the + * ratchet's own types. + */ +object MutationAuthorityDigestCertificateCeremony { + /** + * M44, M45, M46 and M47 over the base/candidate certificate ledgers. + * + * @param base the certificate ledger as it exists in the PR's authority base + * @param candidate the certificate ledger as the PR proposes it + * @param baseSha the exact authority base SHA this transition is proposed against + * @param validConsumptions the proven consumptions this transition established through + * [verifyCertificateConsumption]. Only attributed [CertificateConsumption.Valid] facts can be + * passed, so a caller cannot state "this digest was consumed" - the fact carries its own + * proof. M44 and M47 never re-derive it and never treat the absence of a certificate as + * evidence that it was consumed. An empty set is the fail-closed state, so a caller that + * establishes nothing gets the strictest behaviour. + */ + fun checks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + baseSha: String, + validConsumptions: Set = emptySet(), + promotion: MutationPopulationPromotion? = null, + ): List = + mintChecks(base, candidate, baseSha, promotion) + + retentionChecks(base, candidate) + + singleUseChecks(base, candidate, validConsumptions) + + removalChecks(base, candidate, validConsumptions) + + /** + * M45: a newly introduced certificate binds the exact authority base it is proposed against. + * Anti-replay at mint time: a certificate payload cannot be replayed onto a different authority + * base. Deliberately not re-checked later - a certificate legitimately survives intermediate + * merges, so requiring it to track the immediate base would make delayed consumption impossible. + * + * One narrow exception (0.7.1i): a certificate carried across a release promotion boundary by an + * exact [MutationPopulationPromotion] was not minted here, so it is not bound to this base. The + * exception applies only to the source digest the declaration carries. + */ + private fun mintChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + baseSha: String, + promotion: MutationPopulationPromotion?, + ): List { + val diagnostics = mutableListOf() + val baseByFromDigest = base.byFromDigest() + for ((fromDigest, certificate) in candidate.byFromDigest()) { + if (fromDigest in baseByFromDigest) continue + val carriedForward = promotion != null && certificate.fromDigest == promotion.populationDigest + if (certificate.fromBaseSha != baseSha && !carriedForward) { + diagnostics += + certificateFailure( + fromDigest, + "M45: the digest-migration certificate for source digest ${short(fromDigest)} " + + "records fromBaseSha '${certificate.fromBaseSha}', not the authority base " + + "'$baseSha' this transition is proposed against. fromBaseSha is the base of " + + "the minting transition.", + ) + } + } + return diagnostics + } + + /** + * M46: a certificate present in the base is immutable from the moment it is introduced. A + * retained copy differing in any field of its enforced payload is a rewrite, whether or not the + * migration it authorizes is being consumed in this transition. Correcting a certificate means + * minting a new one in a later transition, never editing a retained one. + * + * Judged over [MutationAuthorityDigestCertificate.enforcedPayload], so touching audit metadata + * alone never fails - and never manufactures authority either. + */ + private fun retentionChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List { + val diagnostics = mutableListOf() + val candidateByFromDigest = candidate.byFromDigest() + for ((fromDigest, baseCertificate) in base.byFromDigest()) { + val candidateCertificate = candidateByFromDigest[fromDigest] ?: continue + if (baseCertificate.enforcedPayload() != candidateCertificate.enforcedPayload()) { + diagnostics += + certificateFailure( + fromDigest, + "M46: the retained digest-migration certificate for source digest " + + "${short(fromDigest)} does not match the base certificate. A certificate, " + + "once introduced, is immutable.", + ) + } + } + return diagnostics + } + + /** + * M44: single use. A certificate that was consumed in this transition must not also be retained. + * Leaving it in place would let the same migration authority be consumed again by a later + * transition, so consumption and retention are mutually exclusive. + * + * Judged only over consumptions that were **independently established** (M40-M43). A source + * digest that is not in the base is not this rule's business - M43 refuses that citation - so a + * candidate cannot use this rule to fail a transition by citing certificates that do not exist. + */ + private fun singleUseChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + validConsumptions: Set, + ): List { + val diagnostics = mutableListOf() + val baseByFromDigest = base.byFromDigest() + val candidateByFromDigest = candidate.byFromDigest() + for (consumed in validConsumptions.sortedBy { it.fromDigest }) { + val fromDigest = consumed.fromDigest + if (fromDigest !in baseByFromDigest) continue + if (fromDigest in candidateByFromDigest) { + diagnostics += + certificateFailure( + fromDigest, + "M44: the digest-migration certificate for source digest " + + "${short(fromDigest)} was consumed by this transition but is retained in " + + "the candidate. A certificate authorises one migration once.", + ) + } + } + return diagnostics + } + + /** + * M47: removal custody. A certificate may only disappear by being consumed; it may not be + * cancelled silently. + * + * The consuming half is [validConsumptions] - the fact established by M40-M43, taken here as an + * input rather than rediscovered. That is structural, not conventional: this function has no + * access to the semantic predicates, so a disappearance can never become its own evidence. If + * nothing valid was established (empty set, including the fail-closed default), every base + * certificate absent from the candidate fails, exactly as before the consumption path existed. + */ + private fun removalChecks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + validConsumptions: Set, + ): List { + val diagnostics = mutableListOf() + val candidateByFromDigest = candidate.byFromDigest() + for ((fromDigest, _) in base.byFromDigest()) { + val consumed = validConsumptions.any { it.fromDigest == fromDigest } + if (fromDigest !in candidateByFromDigest && !consumed) { + diagnostics += + certificateFailure( + fromDigest, + "M47: the digest-migration certificate for source digest ${short(fromDigest)} " + + "was removed without being consumed. A certificate may not be cancelled " + + "silently.", + ) + } + } + return diagnostics + } + + private fun certificateFailure( + fromDigest: String, + message: String, + ): VerificationDiagnostic = + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, + message, + findingId = fromDigest, + ) + + private fun short(digest: String): String = digest.take(SHORT_HASH_LENGTH) + + /** Prefix length used to identify a digest in diagnostics; the full value stays in findingId. */ + private const val SHORT_HASH_LENGTH = 8 +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt new file mode 100644 index 000000000..6b716b366 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoader.kt @@ -0,0 +1,201 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File + +/** + * Parses config/quality/mutation-authority-digest-certificates.yml. + * + * An absent file means "no certificates", which is fail-closed: no semantic migration of a + * population-authority digest is then possible, and every authorization whose digest predates + * authority-v2 stays unconsumable. A present but malformed file is a hard failure - a broken + * certificate ledger must never silently degrade into "no certificates that happen to be unused". + * + * Every enforcement-critical field is required and validated here, because a partially specified + * certificate is indistinguishable from one that authorizes a different migration. + * + * **This loader validates shape only.** Mint-time base binding (M45), retained immutability (M46) + * and removal custody (M47) are transition properties and cannot be enforced by parsing a single + * file; they live in [MutationAuthorityDigestCertificateCeremony]. Leaving this ledger's lifecycle + * to a generic loader would give it none of those properties. + */ +object MutationAuthorityDigestCertificateLoader { + const val FILE_NAME = "config/quality/mutation-authority-digest-certificates.yml" + + private const val SCHEMA_VERSION = "1" + private const val MAX_ALIASES_FOR_COLLECTIONS = 20 + private val DIGEST = Regex("[0-9a-f]{64}") + private val COMMIT_SHA = Regex("[0-9a-f]{40}") + private val ALGORITHMS = + setOf( + MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + ) + + fun load(repositoryRoot: File): MutationAuthorityDigestCertificates { + val file = File(repositoryRoot, FILE_NAME) + if (!file.isFile) { + return MutationAuthorityDigestCertificates.NONE + } + val raw = readLedger(file) + val schemaVersion = requireSchemaVersion(raw) + val certificates = parseCertificates(raw) + requireUniqueFromDigests(certificates) + requireDistinctAlgorithms(certificates) + return MutationAuthorityDigestCertificates( + schemaVersion = schemaVersion, + certificates = certificates, + ) + } + + private fun readLedger(file: File): Map { + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES_FOR_COLLECTIONS + } + val text = file.readText(Charsets.UTF_8) + return try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(text) ?: emptyMap() + } catch (e: YAMLException) { + throw GradleException("Invalid mutation-authority-digest-certificates.yml: ${e.message}", e) + } + } + + private fun requireSchemaVersion(raw: Map): String { + val schemaVersion = + raw["schemaVersion"]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: schemaVersion is required", + ) + if (schemaVersion != SCHEMA_VERSION) { + throw GradleException( + "Unsupported mutation-authority-digest-certificates.yml schemaVersion '$schemaVersion'", + ) + } + return schemaVersion + } + + private fun parseCertificates(ledger: Map): List { + val certificatesRaw = + ledger["certificates"] as? List<*> + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates must be a list", + ) + return certificatesRaw.mapIndexed { index, item -> + val entry = + item as? Map<*, *> + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index] must be a mapping", + ) + MutationAuthorityDigestCertificate( + fromAlgorithm = requireAlgorithm(entry, "fromAlgorithm", index), + fromDigest = requireCanonical(entry, "fromDigest", index, DIGEST, "a 64-hex digest"), + toAlgorithm = requireAlgorithm(entry, "toAlgorithm", index), + toDigest = requireCanonical(entry, "toDigest", index, DIGEST, "a 64-hex digest"), + admissionSetDigest = + requireCanonical( + entry, + "admissionSetDigest", + index, + DIGEST, + "a 64-hex admission-set digest", + ), + fromBaseSha = + requireCanonical(entry, "fromBaseSha", index, COMMIT_SHA, "a 40-hex commit SHA"), + reason = requireString(entry, "reason", index), + authorizedBy = optionalString(entry, "authorizedBy"), + authorizedAt = optionalString(entry, "authorizedAt"), + ) + } + } + + private fun requireAlgorithm( + entry: Map<*, *>, + field: String, + index: Int, + ): String { + val value = + entry[field]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required", + ) + if (value !in ALGORITHMS) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field '$value' is " + + "not a known digest semantics (${ALGORITHMS.sorted().joinToString(", ")})", + ) + } + return value + } + + private fun requireCanonical( + entry: Map<*, *>, + field: String, + index: Int, + pattern: Regex, + description: String, + ): String { + val value = + entry[field]?.toString() + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required", + ) + if (!pattern.matches(value)) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field '$value' is not " + + "$description", + ) + } + return value + } + + private fun requireString( + entry: Map<*, *>, + field: String, + index: Int, + ): String { + val value = entry[field]?.toString()?.takeIf { it.isNotBlank() } + return value + ?: throw GradleException( + "mutation-authority-digest-certificates.yml: certificates[$index].$field is required and " + + "must be non-blank", + ) + } + + private fun optionalString( + entry: Map<*, *>, + field: String, + ): String? = entry[field]?.toString()?.takeIf { it.isNotBlank() } + + private fun requireUniqueFromDigests(certificates: List) { + val duplicates = + certificates + .groupingBy { it.fromDigest } + .eachCount() + .filterValues { it > 1 } + .keys + if (duplicates.isNotEmpty()) { + throw GradleException( + "mutation-authority-digest-certificates.yml: duplicate fromDigest ${duplicates.sorted()} - " + + "the source of a migration is unique, so two certificates claiming it would contradict " + + "each other", + ) + } + } + + private fun requireDistinctAlgorithms(certificates: List) { + for (certificate in certificates) { + if (certificate.fromAlgorithm == certificate.toAlgorithm) { + throw GradleException( + "mutation-authority-digest-certificates.yml: certificate ${certificate.fromDigest} " + + "migrates ${certificate.fromAlgorithm} to itself, which certifies nothing", + ) + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollmentLoader.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollmentLoader.kt new file mode 100644 index 000000000..7f741609a --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollmentLoader.kt @@ -0,0 +1,94 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File + +/** + * Parses config/quality/mutation-classification-enrollments.yml. + * + * An absent file means "no authorizations", which is fail-closed: enrollment is + * then impossible and every candidate-side classification addition still fails + * M08/M09. A present but malformed file is a hard failure — a broken ledger must + * never silently degrade into "no authorizations that happen to be unused". + * + * Classification vocabulary and per-entry validation are shared with + * [MutationClassificationLoader] so the two authorities cannot drift. + */ +object MutationClassificationEnrollmentLoader { + const val FILE_NAME = "config/quality/mutation-classification-enrollments.yml" + + private const val SCHEMA_VERSION = "1" + private const val MAX_ALIASES_FOR_COLLECTIONS = 20 + + fun load(repositoryRoot: File): MutationClassificationEnrollments { + val file = File(repositoryRoot, FILE_NAME) + if (!file.isFile) { + return MutationClassificationEnrollments.NONE + } + val raw = readLedger(file) + val schemaVersion = requireSchemaVersion(raw) + val enrollments = parseEnrollments(raw) + requireUniqueIds(enrollments) + return MutationClassificationEnrollments(schemaVersion = schemaVersion, enrollments = enrollments) + } + + private fun readLedger(file: File): Map { + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES_FOR_COLLECTIONS + } + val text = file.readText(Charsets.UTF_8) + return try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(text) ?: emptyMap() + } catch (e: YAMLException) { + throw GradleException("Invalid mutation-classification-enrollments.yml: ${e.message}", e) + } + } + + private fun requireSchemaVersion(raw: Map): String { + val schemaVersion = + raw["schemaVersion"]?.toString() + ?: throw GradleException("mutation-classification-enrollments.yml: schemaVersion is required") + if (schemaVersion != SCHEMA_VERSION) { + throw GradleException( + "Unsupported mutation-classification-enrollments.yml schemaVersion '$schemaVersion'", + ) + } + return schemaVersion + } + + private fun parseEnrollments(raw: Map): List { + val enrollmentsRaw = + raw["enrollments"] as? List<*> + ?: throw GradleException("mutation-classification-enrollments.yml: enrollments must be a list") + return enrollmentsRaw.mapIndexed { index, item -> + MutationClassificationEnrollment( + classification = + MutationClassificationLoader.parseClassificationEntry( + entry = item, + index = index, + fileName = "mutation-classification-enrollments.yml", + collectionName = "enrollments", + ), + ) + } + } + + private fun requireUniqueIds(enrollments: List) { + val seenIds = mutableSetOf() + enrollments.forEach { enrollment -> + val id = enrollment.classification.id + if (!seenIds.add(id)) { + throw GradleException( + "mutation-classification-enrollments.yml: duplicate enrollment id '$id' — " + + "an identity may be authorized once.", + ) + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollments.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollments.kt new file mode 100644 index 000000000..e3cd57813 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollments.kt @@ -0,0 +1,51 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException + +/** + * Base-side preauthorization for a future classification enrollment (0.7.1g1F). + * + * The mutation ratchet (M08/M09) deliberately rejects classifications a + * candidate adds for itself: a PR cannot decide that its own new NON_KILLED + * survivor is acceptable. The verifier's documented complement — new + * classifications "adjudicated on master during an enrollment ceremony and + * becoming part of the base" — had no implementation, so an adjudicated + * survivor could never be enrolled at all. + * + * This ledger supplies that missing transition without a privileged mode. An + * enrollment is an authorization that some *later* transition may consume: + * + * 1. authorization transition — a PR adds an enrollment for an identity that + * is already NON_KILLED in the governing base population. The mutant stays + * unresolved; no classification exists yet. + * 2. consumption transition — a later PR, whose BASE already contains the + * authorization, adds the exact classification and removes the consumed + * authorization. + * + * Trust is temporal and base-derived, never mode-derived: the authorization + * that permits enrollment must exist in the PR's base, so a candidate can + * never create the authority it uses in the same transition. + * + * The payload is the whole [MutationClassification] record, not the identity. + * Authorizing an id alone would let a later transition substitute a different + * classification type or rewrite the rationale; data-class equality over the + * full payload makes "exact authorization" literal. + */ +data class MutationClassificationEnrollment( + val classification: MutationClassification, +) + +data class MutationClassificationEnrollments( + val schemaVersion: String, + val enrollments: List, +) { + fun byIdentity(): Map { + val keyed = enrollments.map { it.classification.id to it.classification } + return keyed.toMap() + } + + companion object { + /** No authorizations: strictly the most restrictive state (M08/M09 unchanged). */ + val NONE = MutationClassificationEnrollments(schemaVersion = "1", enrollments = emptyList()) + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationLoader.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationLoader.kt index bacfaacc1..4bfe46ec0 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationLoader.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationLoader.kt @@ -13,84 +13,65 @@ data class MutationClassification( val classification: String, val reason: String, val issue: String? = null, - val targetPhase: String? = null + val targetPhase: String? = null, ) data class MutationClassifications( val schemaVersion: String, - val classifications: List + val classifications: List, ) { - fun byIdentity(): Map = - classifications.associateBy { it.id } + fun byIdentity(): Map = classifications.associateBy { it.id } } object MutationClassificationLoader { - private val ALLOWED_CLASSIFICATIONS = setOf( - "missing-test", - "equivalent-mutant", - "low-risk-implementation-detail", - "tool-limitation", - "known-design-ambiguity" - ) + private val ALLOWED_CLASSIFICATIONS = + setOf( + "missing-test", + "equivalent-mutant", + "low-risk-implementation-detail", + "tool-limitation", + "known-design-ambiguity", + ) fun load(repositoryRoot: File): MutationClassifications { val file = File(repositoryRoot, "config/quality/mutation-classifications.yml") if (!file.isFile) { return MutationClassifications(schemaVersion = "1", classifications = emptyList()) } - val loaderOptions = LoaderOptions().apply { - isAllowDuplicateKeys = false - maxAliasesForCollections = 20 - } - val raw = try { - @Suppress("UNCHECKED_CAST") - Yaml(loaderOptions).load>(file.readText(Charsets.UTF_8)) - ?: emptyMap() - } catch (e: Exception) { - throw GradleException("Invalid mutation-classifications.yml: ${e.message}", e) - } + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = 20 + } + val raw = + try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(file.readText(Charsets.UTF_8)) + ?: emptyMap() + } catch (e: Exception) { + throw GradleException("Invalid mutation-classifications.yml: ${e.message}", e) + } - val schemaVersion = raw["schemaVersion"]?.toString() - ?: throw GradleException("mutation-classifications.yml: schemaVersion is required") + val schemaVersion = + raw["schemaVersion"]?.toString() + ?: throw GradleException("mutation-classifications.yml: schemaVersion is required") if (schemaVersion != "1") { throw GradleException("Unsupported mutation-classifications.yml schemaVersion '$schemaVersion'") } - val classificationsRaw = raw["classifications"] as? List<*> - ?: throw GradleException("mutation-classifications.yml: classifications must be a list") - - val classifications = classificationsRaw.mapIndexed { index, item -> - val entry = item as? Map<*, *> - ?: throw GradleException("mutation-classifications.yml: classifications[$index] must be a mapping") - val id = (entry["id"] as? String)?.trim().orEmpty() - val classification = (entry["classification"] as? String)?.trim().orEmpty() - val reason = (entry["reason"] as? String)?.trim().orEmpty() - val issue = (entry["issue"] as? String)?.trim()?.takeIf { it.isNotEmpty() } - val targetPhase = (entry["targetPhase"] as? String)?.trim()?.takeIf { it.isNotEmpty() } + val classificationsRaw = + raw["classifications"] as? List<*> + ?: throw GradleException("mutation-classifications.yml: classifications must be a list") - if (id.isEmpty()) { - throw GradleException("mutation-classifications.yml: classifications[$index].id must not be empty") - } - if (classification.isEmpty()) { - throw GradleException("mutation-classifications.yml: classifications[$index].classification must not be empty") - } - if (classification !in ALLOWED_CLASSIFICATIONS) { - throw GradleException( - "mutation-classifications.yml: classifications[$index].classification '$classification' is not allowed. " + - "Allowed values: ${ALLOWED_CLASSIFICATIONS.joinToString()}" + val classifications = + classificationsRaw.mapIndexed { index, item -> + parseClassificationEntry( + entry = item, + index = index, + fileName = "mutation-classifications.yml", + collectionName = "classifications", ) } - if (reason.isEmpty()) { - throw GradleException("mutation-classifications.yml: classifications[$index].reason must not be empty") - } - MutationClassification( - id = id, - classification = classification, - reason = reason, - issue = issue, - targetPhase = targetPhase - ) - } val seenIds = mutableSetOf() classifications.forEach { c -> @@ -102,4 +83,64 @@ object MutationClassificationLoader { return MutationClassifications(schemaVersion = schemaVersion, classifications = classifications) } + + /** + * Parse and validate one classification record. + * + * Shared with [MutationClassificationEnrollmentLoader] on purpose: the + * allowed-classification vocabulary and the field rules are one authority, + * so an enrollment can never accept a classification the classifications + * file would reject (or vice versa). + */ + internal fun parseClassificationEntry( + entry: Any?, + index: Int, + fileName: String, + collectionName: String, + ): MutationClassification { + val mapping = + entry as? Map<*, *> + ?: throw GradleException("$fileName: $collectionName[$index] must be a mapping") + val where = "$collectionName[$index]" + val classification = requiredField(mapping, "classification", fileName, where) + requireAllowedClassification(classification, fileName, where) + return MutationClassification( + id = requiredField(mapping, "id", fileName, where), + classification = classification, + reason = requiredField(mapping, "reason", fileName, where), + issue = optionalField(mapping, "issue"), + targetPhase = optionalField(mapping, "targetPhase"), + ) + } + + private fun requiredField( + mapping: Map<*, *>, + field: String, + fileName: String, + where: String, + ): String { + val value = (mapping[field] as? String)?.trim().orEmpty() + if (value.isEmpty()) { + throw GradleException("$fileName: $where.$field must not be empty") + } + return value + } + + private fun optionalField( + mapping: Map<*, *>, + field: String, + ): String? = (mapping[field] as? String)?.trim()?.takeIf { it.isNotEmpty() } + + private fun requireAllowedClassification( + classification: String, + fileName: String, + where: String, + ) { + if (classification !in ALLOWED_CLASSIFICATIONS) { + throw GradleException( + "$fileName: $where.classification '$classification' is not allowed. " + + "Allowed values: ${ALLOWED_CLASSIFICATIONS.joinToString()}", + ) + } + } } diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationEnrollmentCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationEnrollmentCeremony.kt new file mode 100644 index 000000000..c044b3fae --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationEnrollmentCeremony.kt @@ -0,0 +1,402 @@ +package dev.tramai.build.quality + +/** Identity shortening, taken from the ratchet so both report the same id form. */ +private fun short(id: String): String = MutationRatchetVerifier.short(id) + +/** + * The classification enrollment ceremony (0.7.1g1F): M22-M29 plus the unchanged + * M08/M09 backstop for classifications a transition adds. + * + * Split out of [MutationRatchetVerifier] because a verifier class that keeps + * every rule inline exceeds the maintainability function-count limit; the + * ratchet's semantics are unchanged — the verifier delegates here exactly where + * it used to dispatch added classifications. + * + * The trust model is temporal and base-derived, never mode-derived: + * + * - Authorization is read from [MutationRatchetAuthority.enrollments] (the + * PR's BASE) only. A candidate's own ledger can propose an authorization but + * can never authorize the classification it introduces (M23). + * - An authorization binds the complete [MutationClassification] payload, not + * the identity, so a later transition cannot re-author meaning (M24), and a + * retained authorization cannot be rewritten before it is consumed (M28). + * - An authorization may only ratify an unresolved NON_KILLED survivor that + * the base authority already carries (M25) — never a new, dead or already + * classified identity. + * - Authorizations are single-use: consumed by the transition that enrolls + * them (M26), never silently cancelled (M27), and obsoleted only by the + * mutant itself dying or disappearing. + * - Every active classification — new or retained — must describe a candidate + * mutant that is present and NON_KILLED (M29), so a transition that passes + * can always be used as the next transition's base. + * + * The authorization lifecycle, in one place: + * + * PENDING (authorization in base) + * ├─ target still a live survivor + * │ ├─ retained byte-identically .................... pass (awaiting consumption) + * │ ├─ consumed with the exact classification ....... pass (M26) + * │ ├─ rewritten while retained ..................... fail (M28) + * │ ├─ retained after enrollment .................... fail (M27, single-use) + * │ └─ removed without enrollment ................... fail (M27, no silent cancellation) + * └─ target KILLED or disappeared + * ├─ removed ...................................... pass (cleanup; M04/M21 govern the state change) + * └─ retained or enrolled ......................... fail (M27 stale / M29 invalid) + */ +object MutationEnrollmentCeremony { + /** + * All ceremony diagnostics for one transition. M22 (a successful authorized + * enrollment) is a pass and emits nothing. + */ + fun checks( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): List { + val diagnostics = mutableListOf() + diagnostics += addedClassifications(base, candidate) + diagnostics += duplicateEnrollments(base, candidate) + diagnostics += invalidTargets(base, candidate) + diagnostics += selfAuthorizations(base, candidate) + diagnostics += authorizationLifecycle(base, candidate) + diagnostics += candidatePopulationIntegrity(base, candidate) + return diagnostics + } + + /** + * M22/M24, and the unchanged M08/M09 backstop. A candidate may never approve + * its own new survivors; the single exception is an enrollment whose exact + * payload the BASE already authorized. + */ + private fun addedClassifications( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): List { + val diagnostics = mutableListOf() + val baseClassById = base.classifications.byIdentity() + val candidateClassById = candidate.classifications.byIdentity() + val baseById = base.population.mutants.associateBy { it.identity } + val candidateById = candidate.population.mutants.associateBy { it.identity } + val baseEnrollmentById = base.enrollments.byIdentity() + + (candidateClassById.keys - baseClassById.keys).forEach { id -> + val authorized = baseEnrollmentById[id] + val added = candidateClassById.getValue(id) + if (authorized == null) { + diagnostics += + unauthorizedAddition( + id = id, + candidateMutant = candidateById[id], + baseMutant = baseById[id], + baseSha = base.baseSha, + ) + } else if (authorized != added) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_MISMATCH, + "M24: classification ${short(id)} does not match its base enrollment authorization. " + + "An enrollment authorizes one exact record — classification, reason, issue and " + + "targetPhase — never an identity alone, so a later transition cannot re-author " + + "the approved meaning.", + findingId = id, + modulePath = candidateById[id]?.module, + ) + } + // M22: exact base-authorized payload for a legitimate target -> authorized enrollment. + } + return diagnostics + } + + /** + * M08/M09, byte-identical to the ratchet's pre-ceremony behaviour: nothing + * about the ceremony may soften an unauthorized candidate classification. + */ + private fun unauthorizedAddition( + id: String, + candidateMutant: MutationOutcome?, + baseMutant: MutationOutcome?, + baseSha: String, + ): VerificationDiagnostic { + val reason = + when { + candidateMutant == null -> { + "M09: classification ${short(id)} is fabricated — it references no mutant measured " + + "in the candidate population and none certified in the base." + } + + baseMutant == null -> { + "M08: candidate self-approval — classification ${short(id)} was added for a mutant that " + + "does not exist in the base authority. A PR cannot approve its own new survivors." + } + + else -> { + "M09: classification ${short(id)} was added for a mutant that was never classified in the " + + "base authority (base outcome ${baseMutant.outcome}). New classifications are adjudicated " + + "on master, never by the PR under review." + } + } + return VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID, + "$reason (base $baseSha)", + findingId = id, + modulePath = candidateMutant?.module, + ) + } + + /** M25 duplicate authorization detection for both ledgers. */ + private fun duplicateEnrollments( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): List { + val diagnostics = mutableListOf() + listOf( + "base" to base.enrollments.enrollments, + "candidate" to candidate.enrollments.enrollments, + ).forEach { (label, ledger) -> + ledger + .groupingBy { it.classification.id } + .eachCount() + .filterValues { it > 1 } + .keys + .sorted() + .forEach { id -> + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID, + "M25: $label enrollment ledger contains duplicate authorizations for ${short(id)}. " + + "An identity may be authorized once.", + findingId = id, + ) + } + } + return diagnostics + } + + /** + * M25: an authorization is only meaningful for a survivor the base authority + * already carries — preauthorizing a new, unmeasured, dead or already + * classified identity would let the ledger *create* authority instead of + * ratifying it. A proposed authorization must also target a live survivor in + * the candidate population: a proposal for a mutant this very transition has + * killed would land as a stale authorization in the next base. + */ + private fun invalidTargets( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): List { + val diagnostics = mutableListOf() + val baseMutantById = base.population.mutants.associateBy { it.identity } + val candidateMutantById = candidate.population.mutants.associateBy { it.identity } + val baseClassById = base.classifications.byIdentity() + val baseEnrollmentIds = base.enrollments.byIdentity().keys + val proposedIds = candidate.enrollments.byIdentity().keys - baseEnrollmentIds + + (baseEnrollmentIds + proposedIds).sorted().forEach { id -> + val baseMutant = baseMutantById[id] + val reason = + when { + baseMutant == null -> { + "the identity is absent from the base authority population. An authorization ratifies " + + "an existing base survivor; it cannot preauthorize a new or unmeasured identity." + } + + baseMutant.outcome == MutationRatchetVerifier.KILLED -> { + "the identity is KILLED in the base authority. A killed mutant needs no approval record." + } + + baseClassById.containsKey(id) -> { + "the identity is already actively classified in the base authority." + } + + id in proposedIds -> { + proposedTargetReason(candidateMutantById[id]) + } + + else -> { + null + } + } + if (reason != null) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID, + "M25: enrollment authorization for ${short(id)} is invalid — $reason", + findingId = id, + modulePath = baseMutant?.module, + ) + } + } + return diagnostics + } + + /** Candidate-side half of M25 for an authorization this transition proposes. */ + private fun proposedTargetReason(candidateMutant: MutationOutcome?): String? = + when { + candidateMutant == null -> { + "the transition proposes an authorization for an identity it does not measure. A proposal must " + + "target a live survivor in the candidate population too." + } + + candidateMutant.outcome != MutationRatchetVerifier.NON_KILLED -> { + "the transition proposes an authorization for a mutant it reports as " + + "${candidateMutant.outcome}. The authorization would be stale the moment it became base." + } + + else -> { + null + } + } + + /** + * M23: a candidate may propose an authorization for a LATER transition, but + * never consume it itself — otherwise it would create the authority it uses. + */ + private fun selfAuthorizations( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): List { + val diagnostics = mutableListOf() + val baseEnrollmentById = base.enrollments.byIdentity() + val candidateEnrollmentById = candidate.enrollments.byIdentity() + val candidateClassById = candidate.classifications.byIdentity() + + (candidateEnrollmentById.keys - baseEnrollmentById.keys).sorted().forEach { id -> + if (candidateClassById.containsKey(id)) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_UNAUTHORIZED, + "M23: this transition adds an enrollment authorization for ${short(id)} AND enrolls its " + + "classification. A candidate cannot authorize its own enrollment — the authorization " + + "must already exist in the base, so adjudication happens at least one merge earlier.", + findingId = id, + ) + } + } + return diagnostics + } + + /** + * The authorization lifecycle for every base authorization: M26/M27/M28 and + * the terminal states where the mutant itself stops being a survivor. + */ + private fun authorizationLifecycle( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): List { + val diagnostics = mutableListOf() + val baseEnrollmentById = base.enrollments.byIdentity() + val candidateEnrollmentById = candidate.enrollments.byIdentity() + val candidateClassById = candidate.classifications.byIdentity() + val candidateMutantById = candidate.population.mutants.associateBy { it.identity } + + baseEnrollmentById.toSortedMap().forEach { (id, authorized) -> + val retained = candidateEnrollmentById[id] + val consumedHere = candidateClassById[id] == authorized + val targetAlive = candidateMutantById[id]?.outcome == MutationRatchetVerifier.NON_KILLED + // M28 is a payload mismatch (the authorization disagrees with the one that authorized it); + // M26/M27 are lifecycle problems. Same family, distinct codes. + val failure: Pair? = + when { + retained != null && retained != authorized -> { + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_MISMATCH to + "M28: the base authorization was rewritten while it is still retained. An authorization " + + "binds one exact record — classification, reason, issue and targetPhase — so a " + + "transition cannot re-author an approval it did not consume. Consume it exactly, or " + + "leave it byte-identical." + } + + retained != null && consumedHere -> { + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED to + "M27: the authorization was retained after its authorized classification was enrolled. " + + "An authorization is single-use; retaining it leaves two enduring copies of the " + + "same authority." + } + + retained != null && !targetAlive -> { + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED to + "M27: the authorization was retained for a target that is no longer a present " + + "NON_KILLED survivor. A dead or unmeasured target needs no approval record — remove " + + "the authorization instead of carrying it into the next base." + } + + retained != null -> { + null // pending: still awaiting consumption + } + + consumedHere -> { + null // M26: removed and consumed by the exact authorized classification + } + + !targetAlive -> { + null // cleanup: the mutant died or disappeared; M04 and M21 govern that change + } + + else -> { + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED to + "M27: the base authorization was removed without enrolling the exact authorized " + + "classification. Removing an authorization is consumption, not cancellation; " + + "there is no silent cancellation path." + } + } + if (failure != null) { + diagnostics += + VerificationDiagnostic.failure( + failure.first, + failure.second, + findingId = id, + modulePath = candidateMutantById[id]?.module, + ) + } + } + return diagnostics + } + + /** + * M29: every active candidate classification — new or retained — must describe + * a candidate mutant that is present and NON_KILLED. Without this, a transition + * could enroll a classification for a mutant it just killed, or keep one whose + * mutant vanished, and produce a candidate authority that its own next + * verification rejects (base classification integrity requires NON_KILLED). + * + * The invariant this protects: every transition that passes must be usable as + * the next transition's base. + */ + private fun candidatePopulationIntegrity( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): List { + val diagnostics = mutableListOf() + val baseMutantById = base.population.mutants.associateBy { it.identity } + val candidateMutantById = candidate.population.mutants.associateBy { it.identity } + + candidate.classifications.byIdentity().toSortedMap().forEach { (id, _) -> + // Absence from both populations is M09's territory (fabrication), reported elsewhere. + if (!baseMutantById.containsKey(id)) return@forEach + val target = candidateMutantById[id] + val reason = + when { + target == null -> { + "the classified mutant is absent from the candidate population" + } + + target.outcome != MutationRatchetVerifier.NON_KILLED -> { + "the classified mutant is ${target.outcome} in the candidate population, not a survivor" + } + + else -> { + null + } + } + if (reason != null) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID, + "M29: classification for ${short(id)} is invalid — $reason. A transition may not pass " + + "while producing an authority its own next verification rejects.", + findingId = id, + modulePath = target?.module, + ) + } + } + return diagnostics + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt new file mode 100644 index 000000000..4591dfd47 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremony.kt @@ -0,0 +1,367 @@ +package dev.tramai.build.quality + +/** + * The population-admission ceremony (0.7.1g1G3): M30-M39. + * + * M06 forbids a new NON_KILLED identity from appearing in a candidate measurement: a PR cannot + * certify its own survivor. Left at that, an adjudicated survivor that is *absent from the + * committed population* could never be admitted, because admission would always look exactly like + * the thing M06 exists to stop. This ceremony is the missing, narrowly scoped exception: + * + * ``` + * M06 (unchanged) appearing NON_KILLED, no authorization anywhere -> fail + * M30 appearing NON_KILLED, exact base authorization -> accepted, and the + * authorization is consumed (removed) in the same transition + * M31 authorization created by this same transition -> fail + * M32 authorized identity, different row -> fail + * M33 authorized row, different analyzer semantics -> fail + * M34 authorized row, different population digest -> fail + * M35 new authorization bound to another base SHA -> fail at mint + * M36 retained authorization rewritten -> fail + * M37 authorization removed without a valid consumption-> fail + * M38 authorization retained after being consumed -> fail (single use) + * M39 authorization obsolete (target already in the base population, or now KILLED) + * -> warning: cleanable, never forced into an admission + * ``` + * + * ## Trust properties + * + * The authorization consulted for an admission is **always the base ledger**. An authorization the + * candidate adds is authority for nothing: it can neither admit in its own transition (M31) nor + * rewrite what the base already approved (M36). The single negative that separates this from a + * general M06 bypass is therefore preserved by construction: authorized X plus unauthorized Y in + * the same measurement fails for Y, through unchanged M06. + * + * The digest binds the *whole* transition's AUTHORITY content, not just the identity's row: the + * authorization records the **authority projection** — identity, canonical outcome, family, module, + * family/module topology and analyzer semantics — of the canonical fresh measurement it was minted + * against, and consumption compares it with the verifier's own fresh authority projection + * ([MutationEvolutionEvidence.proof]). "Binds the neighbours" is what makes this all-or-nothing: if + * another identity's canonical outcome, family or module changes between minting and consumption, + * the digest no longer matches and the authorization must be re-minted. A candidate can never + * construct that projection, because the hash compared is never read from the ledger. + * + * Raw PIT status is deliberately outside this digest. C7 ([MutationOutcome]) declares raw status + * diagnostic evidence rather than authority, and four complete unrestricted campaigns at identical + * effective PIT inputs produced four distinct raw-status digests (SURVIVED↔TIMED_OUT on three + * identities of 2544, all NON_KILLED in every campaign) while this projection was byte-identical. + * Raw status remains fully authoritative where it belongs: the fresh↔committed exact comparison + * (M21) and each individual authorization's exact row (M32). + * + * ## Division of responsibility (one implementation, two call sites) + * + * `outcomeRatchet` (M06) decides whether an appearing NON_KILLED identity has an exact, consumable + * base authorization. This ceremony owns the authorization lifecycle: mint authority (M35), + * immutability (M36), payload/digest integrity (M32-M34) and consumption/single-use (M37-M39), plus + * the M30 note. Both call the same [appearanceVerdict], and the lifecycle scan reuses that same + * verdict to decide whether an authorization was actually consumed - so "is X exactly authorized?" + * has exactly one implementation and the two call sites cannot drift apart. + */ +object MutationPopulationAdmissionCeremony { + /** The outcome of judging one appearing candidate-only identity. */ + sealed interface AdmissionVerdict { + /** The exact row was authorized for this exact transition by the base ledger (M30). */ + data object Authorized : AdmissionVerdict + + /** Not admissible, for the stated reason (the message already carries its rule id). */ + data class Rejected( + val code: DiagnosticCode, + val message: String, + ) : AdmissionVerdict + } + + fun checks( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + authority: AdmissionAuthority, + promotion: MutationPopulationPromotion? = null, + ): List = lifecycleChecks(base, candidate, authority, promotion) + + /** + * The verdict for one identity that is present in the candidate population and absent from the + * committed base population. Shared by M06 and by the lifecycle scan, so an authorized identity + * can never be both admitted and reported as a failure. + * + * The M06 message is reproduced verbatim for the unauthorized case: this ceremony narrows M06 + * for exactly authorized identities and otherwise leaves it untouched. + */ + fun appearanceVerdict( + baseAdmission: MutationPopulationAdmission?, + candidateAdmission: MutationPopulationAdmission?, + mutant: MutationOutcome, + candidateAnalyzer: MutationAnalyzerSemantics, + authority: AdmissionAuthority, + ): AdmissionVerdict { + val freshAuthorityProjectionHash = authority.freshAuthorityProjectionHash + val certifiedConsumptions = authority.certifiedConsumptions + val short = short(mutant.identity) + return when { + baseAdmission == null -> { + unauthorizedAppearanceVerdict(mutant, candidateAdmission) + } + + candidateAdmission != null && isRetainedRewrite(baseAdmission, candidateAdmission) -> { + reject( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, + "M36: the retained population admission for $short does not match the base " + + "authorization. An authorization, once introduced, is immutable.", + ) + } + + !baseAdmission.admitsRow(mutant) -> { + reject( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + "M32: ${describe(mutant)} ($short) does not match the row authorized in the base " + + "(status='${baseAdmission.status}', outcome='${baseAdmission.outcome}', " + + "family='${baseAdmission.family}', module='${baseAdmission.module}'). An " + + "authorization admits one exact persisted row, not an identity.", + ) + } + + baseAdmission.analyzer != candidateAnalyzer -> { + reject( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + "M33: $short was authorized under different analyzer semantics (authorized=" + + "${baseAdmission.analyzer}, candidate=$candidateAnalyzer). M16-M19 forbid " + + "consuming an authorization under other settings.", + ) + } + + freshAuthorityProjectionHash == null -> { + reject( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + "M34: $short cannot be admitted: no proof of the canonical fresh measurement exists, " + + "so the authorized population digest cannot be checked. Admission fails closed.", + ) + } + + // M34 with certified migration (Step 3b): a raw-v1 authorization remains consumable when a + // base certificate translates exactly its historical digest into this transition's fresh + // authority projection, bounded to the exact base authorization set. The facts come from + // one production site ([certifiedConsumptions]) and a Valid exists only if M43-M42 passed + // against the base ledger, so this branch cannot be reached by asserting anything. Without + // a certificate the condition is unchanged and M34 fails exactly as it did before. + baseAdmission.populationDigest != freshAuthorityProjectionHash && + certifiedConsumptions.none { it.fromDigest == baseAdmission.populationDigest } -> { + reject( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + "M34: $short was authorized against population digest " + + "${short(baseAdmission.populationDigest)}, but this transition's canonical " + + "authority projection hashes to ${short(freshAuthorityProjectionHash)}. An " + + "authorization binds the measured population's AUTHORITY content — identity, " + + "canonical outcome, family, module, topology and analyzer semantics — and " + + "deliberately not raw PIT status, which C7 declares diagnostic evidence rather " + + "than authority.", + ) + } + + else -> { + AdmissionVerdict.Authorized + } + } + } + + /** + * The verdict for an appearing identity with no base authorization. The M06 rendering here is + * byte-identical to the ratchet's own unauthorized-survivor diagnostic ([MutationRatchetVerifier]), + * because it is produced by the same formatters: this ceremony narrows M06 for authorized + * identities and must not restate it. M31 differs only in naming the self-authorization. + */ + private fun unauthorizedAppearanceVerdict( + mutant: MutationOutcome, + candidateAdmission: MutationPopulationAdmission?, + ): AdmissionVerdict { + val described = describe(mutant) + val short = short(mutant.identity) + return if (candidateAdmission != null) { + reject( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_UNAUTHORIZED, + "M31: $described ($short) is authorized only by this transition: the authorization is " + + "declared in the candidate ledger and absent from the base. A transition cannot create " + + "the authority it consumes.", + ) + } else { + reject( + DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, + "M06: $described ($short) is a NEW NON_KILLED identity absent from the base authority. " + + "New mutants must be killed; a PR cannot certify its own survivors.", + ) + } + } + + private fun reject( + code: DiagnosticCode, + message: String, + ): AdmissionVerdict = AdmissionVerdict.Rejected(code, message) + + /** + * Authorization lifecycle: minting (M35), retention (M36), removal (M37), single use (M38) and + * obsolescence (M39). Independent of M06, which judges the population itself. + */ + private fun lifecycleChecks( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + authority: AdmissionAuthority, + promotion: MutationPopulationPromotion?, + ): List { + val diagnostics = mintChecks(base, candidate, promotion) + return diagnostics + consumptionChecks(base, candidate, authority) + } + + /** + * M35: a newly introduced authorization binds the exact authority base it is proposed against. + * This is where the SHA has enforcement value - an old authorization payload cannot be replayed + * onto a different authority base. It is deliberately NOT re-checked at consumption: a pending + * authorization legitimately survives intermediate merges, and requiring it to track the + * immediate base would make delayed consumption impossible. + * + * One narrow exception (0.7.1i): a row carried across a release promotion boundary by an exact + * [MutationPopulationPromotion] was not minted here, so it is not bound to this base. The + * exception applies only to the digest the declaration carries - a newly minted row has a + * different digest and is rejected exactly as before. + */ + private fun mintChecks( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + promotion: MutationPopulationPromotion?, + ): List { + val diagnostics = mutableListOf() + val baseAdmissions = base.admissions.byIdentity() + for ((id, admission) in candidate.admissions.byIdentity().filterKeys { it !in baseAdmissions }) { + val carriedForward = promotion != null && admission.populationDigest == promotion.populationDigest + if (admission.fromBaseSha != base.baseSha && !carriedForward) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, + "M35: the population admission for ${short(id)} records fromBaseSha " + + "'${admission.fromBaseSha}', not the authority base '${base.baseSha}' this " + + "transition is proposed against. fromBaseSha is the base of the minting transition.", + findingId = id, + modulePath = admission.module, + ) + } + } + return diagnostics + } + + /** + * M36-M39: retention, removal, single use and obsolescence of authorizations the base already + * holds. Independent of M06, which judges the population itself. + */ + private fun consumptionChecks( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + authority: AdmissionAuthority, + ): List { + val diagnostics = mutableListOf() + val baseAdmissions = base.admissions.byIdentity() + val candidateAdmissions = candidate.admissions.byIdentity() + val basePopulationIds = + base.population.mutants + .map { it.identity } + .toSet() + val candidateById = candidate.population.mutants.associateBy { it.identity } + + for ((id, baseAdmission) in baseAdmissions) { + val candidateAdmission = candidateAdmissions[id] + val mutant = candidateById[id] + val consumed = + mutant != null && + appearanceVerdict( + baseAdmission = baseAdmission, + candidateAdmission = candidateAdmission, + mutant = mutant, + candidateAnalyzer = candidate.population.analyzer, + authority = authority, + ) is AdmissionVerdict.Authorized + if (candidateAdmission == null) { + // M37: a retained authorization may only disappear by being consumed. Otherwise a + // transition could silently cancel authority that a later transition is entitled to + // rely on. An obsolete authorization (its target is already committed, or now killed) + // is the exception: cleanup of dead authority is required, not forbidden. + if (!consumed && !isObsolete(mutant, basePopulationIds.contains(id))) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, + "M37: the population admission for ${short(id)} was removed without being " + + "consumed: the candidate population does not contain the authorized row as " + + "NON_KILLED. An authorization may not be cancelled silently.", + findingId = id, + modulePath = baseAdmission.module, + ) + } + } else if (isRetainedRewrite(baseAdmission, candidateAdmission)) { + // M36: an authorization is immutable from the moment it is introduced. A retained + // copy differing in any enforced field - including fromBaseSha - is a rewrite, + // whether or not the target is being consumed in this transition. + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, + "M36: the retained population admission for ${short(id)} does not match the base " + + "authorization. An authorization, once introduced, is immutable.", + findingId = id, + modulePath = candidateAdmission.module, + ) + } else if (consumed) { + // M38: single use. The authorization that admitted the row must be gone. + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_RETAINED, + "M38: the population admission for ${short(id)} was consumed but is still present " + + "in the candidate ledger. An authorization is single-use; retaining it would " + + "re-authorize a future transition.", + findingId = id, + modulePath = candidateAdmission.module, + ) + } else if (isObsolete(mutant, basePopulationIds.contains(id))) { + diagnostics += obsoleteWarning(id) + } + } + return diagnostics + } + + /** + * True when an authorization can no longer admit anything, so its removal is cleanup rather + * than silent cancellation: the target is already committed authority, it is now killed, or it + * is not NON_KILLED at all. + * + * The analogue of M29 for admissions is deliberately weaker: a pending population admission is + * for an identity that is *absent* from the committed population, so absence is the expected + * state and is never invalid. + */ + private fun isObsolete( + mutant: MutationOutcome?, + presentInBasePopulation: Boolean, + ): Boolean = presentInBasePopulation || (mutant != null && mutant.outcome != MutationRatchetVerifier.NON_KILLED) + + /** + * M36, in one place: a retained authorization that differs from the base copy in any enforced + * field is a rewrite. [appearanceVerdict] and the lifecycle scan both call this, so immutability + * is judged identically wherever it matters - and audit-only fields are excluded by + * [MutationPopulationAdmission.enforcedPayload]. + */ + private fun isRetainedRewrite( + base: MutationPopulationAdmission, + candidate: MutationPopulationAdmission, + ): Boolean = candidate.enforcedPayload() != base.enforcedPayload() + + /** + * M39: advisory only. Obsolete authority is cleanable, whether it is removed (the M37 exception) + * or retained; it never forces an admission. + */ + private fun obsoleteWarning(id: String): VerificationDiagnostic = + VerificationDiagnostic.warning( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, + "M39: the population admission for ${short(id)} is obsolete (target already in the committed " + + "base population, or no longer NON_KILLED). It authorizes nothing and may be removed as cleanup.", + ) +} + +/** + * The ratchet's canonical diagnostic rendering, delegated rather than reimplemented: that is what + * makes this ceremony's M06 verdict the SAME string the ratchet itself produces. The aliases exist + * only so the message templates stay readable and inside the line-length limit. + */ +private fun describe(mutant: MutationOutcome): String = MutationRatchetVerifier.describe(mutant) + +private fun short(id: String): String = MutationRatchetVerifier.short(id) diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionLoader.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionLoader.kt new file mode 100644 index 000000000..473503be4 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionLoader.kt @@ -0,0 +1,191 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File + +/** + * Parses config/quality/mutation-population-admissions.yml. + * + * An absent file means "no authorizations", which is fail-closed: population admission is then + * impossible and every appearing candidate-only NON_KILLED identity still fails M06. A present but + * malformed file is a hard failure - a broken ledger must never silently degrade into "no + * authorizations that happen to be unused". + * + * Every enforcement-critical field is required and validated here, because a partially specified + * authorization is indistinguishable from an authorization that authorizes a different row. + */ +object MutationPopulationAdmissionLoader { + const val FILE_NAME = "config/quality/mutation-population-admissions.yml" + + private const val SCHEMA_VERSION = "1" + private const val DEFAULT_TIMEOUT_FACTOR = 1.25 + private const val MAX_ALIASES_FOR_COLLECTIONS = 20 + private val IDENTITY = Regex("[0-9a-f]{64}") + private val COMMIT_SHA = Regex("[0-9a-f]{40}") + + fun load(repositoryRoot: File): MutationPopulationAdmissions { + val file = File(repositoryRoot, FILE_NAME) + if (!file.isFile) { + return MutationPopulationAdmissions.NONE + } + val raw = readLedger(file) + val schemaVersion = requireSchemaVersion(raw) + val admissions = parseAdmissions(raw) + requireUniqueIdentities(admissions) + return MutationPopulationAdmissions(schemaVersion = schemaVersion, admissions = admissions) + } + + private fun readLedger(file: File): Map { + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES_FOR_COLLECTIONS + } + val text = file.readText(Charsets.UTF_8) + return try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(text) ?: emptyMap() + } catch (e: YAMLException) { + throw GradleException("Invalid mutation-population-admissions.yml: ${e.message}", e) + } + } + + private fun requireSchemaVersion(raw: Map): String { + val schemaVersion = + raw["schemaVersion"]?.toString() + ?: throw GradleException("mutation-population-admissions.yml: schemaVersion is required") + if (schemaVersion != SCHEMA_VERSION) { + throw GradleException( + "Unsupported mutation-population-admissions.yml schemaVersion '$schemaVersion'", + ) + } + return schemaVersion + } + + private fun parseAdmissions(raw: Map): List { + val admissionsRaw = + raw["admissions"] as? List<*> + ?: throw GradleException("mutation-population-admissions.yml: admissions must be a list") + return admissionsRaw.mapIndexed { index, item -> + val entry = + item as? Map<*, *> + ?: throw GradleException("mutation-population-admissions.yml: admissions[$index] must be a mapping") + val identity = + requireCanonical(entry, "identity", index, IDENTITY, "a canonical 64-hex schema v2 identity") + val fromBaseSha = requireCanonical(entry, "fromBaseSha", index, COMMIT_SHA, "a 40-hex commit SHA") + val populationDigest = + requireCanonical(entry, "populationDigest", index, IDENTITY, "a 64-hex projection hash") + MutationPopulationAdmission( + identity = identity, + status = requireString(entry, "status", index), + outcome = requireString(entry, "outcome", index), + family = requireString(entry, "family", index), + module = requireString(entry, "module", index), + analyzer = parseAnalyzer(entry, index), + fromBaseSha = fromBaseSha, + populationDigest = populationDigest, + reason = requireString(entry, "reason", index), + issue = optionalString(entry, "issue"), + targetPhase = optionalString(entry, "targetPhase"), + authorizedBy = optionalString(entry, "authorizedBy"), + authorizedAt = optionalString(entry, "authorizedAt"), + ) + } + } + + /** Analyzer semantics are part of the authorized meaning (M16-M19); a partial one is invalid. */ + private fun parseAnalyzer( + entry: Map<*, *>, + index: Int, + ): MutationAnalyzerSemantics { + val analyzer = + entry["analyzer"] as? Map<*, *> + ?: throw GradleException( + "mutation-population-admissions.yml: admissions[$index].analyzer is required", + ) + val pluginVersion = requiredAnalyzerString(analyzer, "pluginVersion", index) + val engineVersion = requiredAnalyzerString(analyzer, "engineVersion", index) + val mutators = + (analyzer["mutators"] as? List<*>)?.map { it?.toString() ?: "" }?.filter { it.isNotBlank() } ?: emptyList() + require(mutators.isNotEmpty()) { + "mutation-population-admissions.yml: admissions[$index].analyzer.mutators must be a non-empty list (M19)" + } + val timeoutConst = (analyzer["timeoutConst"] as? Number)?.toInt() ?: 0 + require(timeoutConst > 0) { + "mutation-population-admissions.yml: admissions[$index].analyzer.timeoutConst must be positive (C1)" + } + val timeoutFactor = (analyzer["timeoutFactor"] as? Number)?.toDouble() ?: DEFAULT_TIMEOUT_FACTOR + // The mutator list is ORDER-SENSITIVE authority, exactly as it is everywhere else the + // analyzer semantics are compared (M16-M19 compare the canonical renderer order). Sorting + // here re-ordered the ledger's own record against the population's recorded semantics, so + // an authorized row could never reach an admission verdict: every consumption stopped at + // M33 instead. Preserve the ledger's declaration order; the canonical order is what a + // ledger must record, and M33 still rejects a genuinely different set or order. + return MutationAnalyzerSemantics( + pluginVersion = pluginVersion, + engineVersion = engineVersion, + mutators = mutators, + timeoutConst = timeoutConst, + timeoutFactor = timeoutFactor, + ) + } + + private fun requiredAnalyzerString( + analyzer: Map<*, *>, + field: String, + index: Int, + ): String = + analyzer[field]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException( + "mutation-population-admissions.yml: admissions[$index].analyzer.$field is required (M19)", + ) + + /** + * Structural form only. The loader deliberately never decides whether a digest corresponds to a + * real campaign - that judgement belongs to the verifier at consumption, against its own + * measurement - so an incorrect digest at mint grants nothing. + */ + private fun requireCanonical( + entry: Map<*, *>, + field: String, + index: Int, + pattern: Regex, + expected: String, + ): String { + val value = requireString(entry, field, index) + require(pattern.matches(value)) { + "mutation-population-admissions.yml: admissions[$index].$field '$value' is not $expected" + } + return value + } + + private fun requireString( + entry: Map<*, *>, + field: String, + index: Int, + ): String = + entry[field]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException( + "mutation-population-admissions.yml: admissions[$index].$field is required and must be non-blank", + ) + + private fun optionalString( + entry: Map<*, *>, + field: String, + ): String? = entry[field]?.toString()?.takeIf { it.isNotBlank() } + + private fun requireUniqueIdentities(admissions: List) { + val seen = mutableSetOf() + admissions.forEach { admission -> + if (!seen.add(admission.identity)) { + throw GradleException( + "mutation-population-admissions.yml: duplicate admission identity '${admission.identity}' - " + + "an identity may be authorized once.", + ) + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissions.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissions.kt new file mode 100644 index 000000000..440ae93b7 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAdmissions.kt @@ -0,0 +1,109 @@ +package dev.tramai.build.quality + +/** + * Base-side preauthorization for admitting an *appearing* candidate-only NON_KILLED identity + * (0.7.1g1G3): the population-admission ceremony (M30+). + * + * The outcome ratchet (M06) forbids a PR from certifying its own new survivor: a mutant that + * appears as NON_KILLED only in the candidate measurement fails unless it is killed. That rule is + * correct, and it is also why an adjudicated survivor that is *absent from the committed + * population* could never be admitted at all. This ledger supplies the missing transition without + * a privileged mode, by the same temporal trust boundary as the classification enrollment ledger + * (M22-M29): **the authorization must already exist in the PR's base**. + * + * ``` + * P1 - MINT base: X absent from the committed population, absent from this ledger + * candidate: population unchanged, proposes an authorization for X + * P2 - CONSUME base: the authorization for X present and byte-identical + * candidate: the exact authorized row appears as NON_KILLED, and the authorization + * is consumed (removed) in the same transition + * ``` + * + * ## Binding semantics, field by field + * + * - [identity] - canonical 64-hex schema v2 identity. The only key; descriptor or source-line + * similarity is never authority. + * - [status], [outcome], [family], [module] - the complete persisted row. Authorizing an identity + * and admitting a *different* row (status laundering, outcome substitution, re-homing into + * another family, module substitution) fails. This is the hole M28 closed for classifications. + * - [analyzer] - the semantics this authorization is valid under (M16-M19); it may never be + * consumed under different analyzer settings. + * - [populationDigest] - the projection hash of the measurement in which X appears as + * NON_KILLED. At consumption it is compared against the verifier's own canonical fresh + * measurement, never against anything the candidate supplies, so a candidate can never construct + * the measurement that authorizes itself. + * - [fromBaseSha] - the exact [MutationRatchetAuthority.baseSha] against which this authorization + * was first proposed. **Enforced on introduction. Thereafter immutable provenance; it is NOT + * required to equal the immediate base SHA of later retaining or consuming transitions.** A + * pending authorization legitimately survives intermediate merges, so requiring it to track the + * immediate base would make delayed consumption impossible and would itself violate + * authorization immutability. Its enforcement value is anti-replay *at mint time* - an old + * authorization payload cannot be replayed onto a different authority base - while + * consumption-time protection comes from base-side existence, immutability, the exact row, the + * analyzer semantics and the trusted measurement digest. + * - [reason], [issue], [targetPhase] - the recorded adjudication rationale; rewriting them after + * the decision fails (M36, retained immutability). + * - [authorizedBy], [authorizedAt] - **audit only, no enforcement value.** Recording them is fine; + * letting identity or actor carry trust would make them a bypass. + * + * A pending authorization is for an identity that is *absent* from the committed population, so - + * unlike an enrollment (M25) - target absence is the expected state, not invalidity. It becomes + * consumable only when the verifier's fresh measurement contains the exact authorized row. If that + * measurement reports X as KILLED, the authorization is no longer needed and is cleanable, and + * admission must never be forced. + */ +data class MutationPopulationAdmission( + val identity: String, + val status: String, + val outcome: String, + val family: String, + val module: String, + val analyzer: MutationAnalyzerSemantics, + val fromBaseSha: String, + val populationDigest: String, + val reason: String, + val issue: String? = null, + val targetPhase: String? = null, + val authorizedBy: String? = null, + val authorizedAt: String? = null, +) { + /** + * The enforceable payload: every bound field except the audit-only ones. Base/candidate + * byte-identity is judged over this, so rewriting any bound field fails while touching audit + * metadata alone can never manufacture authority. + */ + fun enforcedPayload(): List = + listOf( + identity, + status, + outcome, + family, + module, + analyzer, + fromBaseSha, + populationDigest, + reason, + issue, + targetPhase, + ) + + /** True when [mutant] is the exact persisted row this authorization admits. */ + fun admitsRow(mutant: MutationOutcome): Boolean = + mutant.identity == identity && + mutant.status == status && + mutant.outcome == outcome && + mutant.family == family && + mutant.module == module +} + +data class MutationPopulationAdmissions( + val schemaVersion: String, + val admissions: List, +) { + fun byIdentity(): Map = admissions.associateBy { it.identity } + + companion object { + /** No authorizations: strictly the most restrictive state (M06 unchanged). */ + val NONE = MutationPopulationAdmissions(schemaVersion = "1", admissions = emptyList()) + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAggregator.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAggregator.kt index 376f2d10b..6024f23b2 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAggregator.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationAggregator.kt @@ -138,10 +138,35 @@ object MutationPopulationAggregator { throw GradleException("Configured mutation family '$family' produced zero mutants (M02).") } } + rejectUnmeasuredModules(records, configuredFamilies) // M17: no family beyond the configured ones. rejectUnconfiguredFamilies(presentFamilies, configuredFamilies) } + private fun rejectUnmeasuredModules( + records: List, + configuredFamilies: Map, + ) { + // C6/M02 completeness: judged against the CONFIGURED measurement topology, never against the + // modules that happened to report. A family spanning several modules stays non-vacuous while + // one of its modules produces nothing, so family-level non-emptiness alone lets a module + // vanish unnoticed — the population would shrink and still be persisted as authority. + for ((family, target) in configuredFamilies) { + val unmeasuredModules = + target.modules.filter { module -> + records.none { it.family == family && it.module == module } + } + if (unmeasuredModules.isNotEmpty()) { + throw GradleException( + "Configured mutation target(s) ${unmeasuredModules.sorted().joinToString()} produced no " + + "mutants for family '$family' (M02). Every configured family/module pair must be " + + "measured; a family that stays non-vacuous through its other modules must not hide " + + "an unmeasured one.", + ) + } + } + } + private fun rejectUnconfiguredFamilies( presentFamilies: Set, configuredFamilies: Map, diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationEvolution.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationEvolution.kt new file mode 100644 index 000000000..4e3272d55 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationEvolution.kt @@ -0,0 +1,134 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File +import java.io.IOException + +/** Who is allowed to consider a base mutation identity that is absent from the candidate. */ +enum class MutationPopulationEvolution { + /** Absolute default: any base-only identity fails (M21). */ + FORBID, + + /** Requires invocation authority and a per-identity audit record. */ + RECORDED_EVOLUTION, + ; + + companion object { + const val PROPERTY = "tramaiMutationEvolution" + + /** Absent -> FORBID. Unknown non-blank value -> throw. */ + fun fromProperty(raw: String?): MutationPopulationEvolution = + when (raw?.trim().orEmpty()) { + "" -> FORBID + "forbid" -> FORBID + "recorded-evolution" -> RECORDED_EVOLUTION + else -> throw GradleException("Unknown $PROPERTY value '$raw'") + } + } +} + +/** One reviewed population removal: config/quality/mutation-evolution.yml. */ +data class MutationEvolutionRecord( + val id: String, + val fromBaseSha: String, + val reason: String, + val issue: String? = null, + val targetPhase: String? = null, + val authorizedAt: String? = null, + val authorizedBy: String? = null, +) + +data class MutationEvolutionRecords( + val schemaVersion: String, + val records: List, +) { + fun byIdentity(): Map = records.associateBy { it.id } +} + +object MutationEvolutionLoader { + private const val MAX_ALIASES = 20 + + fun load(repositoryRoot: File): MutationEvolutionRecords { + val file = File(repositoryRoot, "config/quality/mutation-evolution.yml") + if (!file.isFile) return MutationEvolutionRecords("1", emptyList()) + val raw = parse(file) + val schemaVersion = schemaVersion(raw) + val records = records(raw) + val parsed = records.mapIndexed(::parseRecord) + ensureUnique(parsed) + return MutationEvolutionRecords(schemaVersion, parsed) + } + + private fun parse(file: File): Map<*, *> { + val loaded = + try { + val options = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES + } + Yaml(options).load(file.readText(Charsets.UTF_8)) ?: emptyMap() + } catch (e: IOException) { + throw GradleException("Invalid mutation-evolution.yml: ${e.message}", e) + } catch (e: YAMLException) { + throw GradleException("Invalid mutation-evolution.yml: ${e.message}", e) + } + return requireMapping(loaded) + } + + private fun requireMapping(value: Any?): Map<*, *> = + value as? Map<*, *> ?: throw GradleException("mutation-evolution.yml must contain a mapping") + + private fun schemaVersion(raw: Map<*, *>): String { + val value = + raw["schemaVersion"]?.toString() + ?: throw GradleException("mutation-evolution.yml: schemaVersion is required") + if (value != "1") throw GradleException("Unsupported mutation-evolution.yml schemaVersion '$value'") + return value + } + + private fun records(raw: Map<*, *>): List<*> = + raw["records"] as? List<*> + ?: throw GradleException("mutation-evolution.yml: records must be a list") + + private fun ensureUnique(records: List) { + val ids = records.map { it.id } + if (ids.size != ids.distinct().size) { + throw GradleException("mutation-evolution.yml: duplicate evolution record id") + } + } + + private fun parseRecord( + index: Int, + item: Any?, + ): MutationEvolutionRecord { + val entry = + item as? Map<*, *> + ?: throw GradleException("mutation-evolution.yml: records[$index] must be a mapping") + return MutationEvolutionRecord( + id = required(entry, "id", index), + fromBaseSha = required(entry, "fromBaseSha", index), + reason = required(entry, "reason", index), + issue = optional(entry, "issue"), + targetPhase = optional(entry, "targetPhase"), + authorizedAt = optional(entry, "authorizedAt"), + authorizedBy = optional(entry, "authorizedBy"), + ) + } + + private fun required( + entry: Map<*, *>, + name: String, + index: Int, + ): String = + (entry[name] as? String)?.trim()?.takeIf { it.isNotEmpty() } + ?: throw GradleException("mutation-evolution.yml: records[$index].$name must not be empty") + + private fun optional( + entry: Map<*, *>, + name: String, + ): String? = (entry[name] as? String)?.trim()?.takeIf { it.isNotEmpty() } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationPromotion.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationPromotion.kt new file mode 100644 index 000000000..a27037dfa --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationPopulationPromotion.kt @@ -0,0 +1,193 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.error.YAMLException +import java.io.File + +/** + * A promotion declaration: the statement that one exact, already-certified evidence population is + * CARRIED ACROSS a release promotion boundary rather than minted by the promotion transition. + * + * It grants no authority. M35 and M45 bind a newly introduced admission/certificate to the authority + * base it was minted against, and a promotion cannot satisfy that binding by any legitimate means: + * re-minting admissions, rewriting `fromBaseSha`, manufacturing replacement certificates and a + * per-entry migration chain are all forbidden. This declaration states the narrow fact the promotion + * actually needs - the appearing evidence is the SAME certified population, unchanged - so the + * mint-binding rules do not apply to it. + * + * SHA-exact and candidate-side: it names the exact base SHA the promotion is proposed against, so it + * can never authorize a different transition, and it travels in the promotion's own candidate because + * the base branch cannot name a commit that does not exist until the declaration is committed. + * + * Fail-closed: an absent or malformed declaration, or a declaration that does not describe the + * candidate exactly, means "no promotion" - every appearing row then fails M35/M45 as before. + */ +data class MutationPopulationPromotion( + /** The exact authority base SHA this promotion is proposed against. */ + val promotionBase: String, + /** The digest of the carried population; the only digest this declaration carries forward. */ + val populationDigest: String, + /** The exact number of admissions in the carried population. */ + val admissions: Int, + /** The exact number of digest-migration certificates in the carried population. */ + val certificates: Int, +) { + companion object { + /** + * The declaration that legitimately carries the candidate's appearing evidence across + * [base], or null when there is none or it does not describe that evidence exactly. + * + * Every field is enforced against the ledgers themselves, so a declaration cannot authorize a + * population other than the one it names: the base SHA must be this transition's, both counts + * must equal the number of rows appearing relative to the base, and every appearing row must + * carry the declared digest. A single mismatch anywhere yields no promotion at all. + */ + fun carried( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + ): MutationPopulationPromotion? = + candidate.promotion?.takeIf { promotion -> + promotion.promotionBase == base.baseSha && + carriesAdmissions(base, candidate, promotion) && + carriesCertificates(base, candidate, promotion) + } + + /** The exact admission count, and every appearing row's digest. */ + private fun carriesAdmissions( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + promotion: MutationPopulationPromotion, + ): Boolean { + val baseAdmissions = base.admissions.byIdentity().keys + val appearing = + candidate.admissions + .byIdentity() + .filterKeys { it !in baseAdmissions } + .values + return appearing.size == promotion.admissions && + appearing.all { it.populationDigest == promotion.populationDigest } + } + + /** The exact certificate count, and every appearing certificate's source digest. */ + private fun carriesCertificates( + base: MutationRatchetAuthority, + candidate: MutationRatchetCandidate, + promotion: MutationPopulationPromotion, + ): Boolean { + val baseCertificates = base.certificates.byFromDigest().keys + val appearing = + candidate.certificates + .byFromDigest() + .filterKeys { it !in baseCertificates } + .values + return appearing.size == promotion.certificates && + appearing.all { it.fromDigest == promotion.populationDigest } + } + } +} + +/** + * Parses config/quality/mutation-population-promotions.yml. + * + * An absent file means "no promotion", which is fail-closed: every appearing admission and + * certificate is then bound to the transition base by M35/M45 exactly as before. A present but + * malformed file is a hard failure - a broken declaration must never silently degrade into "no + * promotion happened to be needed". + * + * Notes carry declaration history, so a later promotion appends rather than replaces: an older + * declaration simply never matches the transition being verified, since the base SHA is exact. + */ +object MutationPopulationPromotionLoader { + const val FILE_NAME = "config/quality/mutation-population-promotions.yml" + + private const val SCHEMA_VERSION = "1" + private const val MAX_ALIASES_FOR_COLLECTIONS = 20 + private val COMMIT_SHA = Regex("[0-9a-f]{40}") + private val DIGEST = Regex("[0-9a-f]{64}") + + fun load(repositoryRoot: File): MutationPopulationPromotion? { + val file = File(repositoryRoot, FILE_NAME) + if (!file.isFile) { + return null + } + val declarations = parseDeclarations(readLedger(file)) + // One transition has exactly one base, so at most one declaration can apply. More than one + // would make the promotion depend on file order, which is not an authority. + require(declarations.size <= 1) { + "$FILE_NAME: at most one promotion declaration may be present; found ${declarations.size}" + } + return declarations.singleOrNull() + } + + private fun readLedger(file: File): Map { + val loaderOptions = + LoaderOptions().apply { + isAllowDuplicateKeys = false + maxAliasesForCollections = MAX_ALIASES_FOR_COLLECTIONS + } + return try { + @Suppress("UNCHECKED_CAST") + Yaml(loaderOptions).load>(file.readText(Charsets.UTF_8)) ?: emptyMap() + } catch (e: YAMLException) { + throw GradleException("Invalid $FILE_NAME: ${e.message}", e) + } + } + + private fun parseDeclarations(raw: Map): List { + val schemaVersion = raw["schemaVersion"]?.toString() + require(schemaVersion == SCHEMA_VERSION) { + "$FILE_NAME: schemaVersion must be '$SCHEMA_VERSION', was '$schemaVersion'" + } + val promotions = raw["promotions"] + require(promotions is List<*>) { "$FILE_NAME: promotions must be a list" } + return promotions.mapIndexed { index, item -> + require(item is Map<*, *>) { "$FILE_NAME: promotions[$index] must be a mapping" } + val promotionBase = + requireCanonical(item, "promotionBase", index, COMMIT_SHA, "a 40-hex commit SHA") + val populationDigest = + requireCanonical(item, "populationDigest", index, DIGEST, "a 64-hex digest") + MutationPopulationPromotion( + promotionBase = promotionBase, + populationDigest = populationDigest, + admissions = requiredCount(item, "admissions", index), + certificates = requiredCount(item, "certificates", index), + ) + } + } + + /** + * Counts are part of the declaration's meaning, so an absent one is invalid rather than zero: a + * declaration that forgot `certificates` must not read as "carries no certificates". + */ + private fun requiredCount( + entry: Map<*, *>, + field: String, + index: Int, + ): Int { + val value = + (entry[field] as? Number)?.toInt() + ?: throw GradleException("$FILE_NAME: promotions[$index].$field is required and must be an integer") + require(value >= 0) { + "$FILE_NAME: promotions[$index].$field must not be negative" + } + return value + } + + private fun requireCanonical( + entry: Map<*, *>, + field: String, + index: Int, + pattern: Regex, + expected: String, + ): String { + val value = + entry[field]?.toString()?.takeIf { it.isNotBlank() } + ?: throw GradleException("$FILE_NAME: promotions[$index].$field is required and must be non-blank") + require(pattern.matches(value)) { + "$FILE_NAME: promotions[$index].$field '$value' is not $expected" + } + return value + } +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt index 8c1552e7e..f01230f34 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetAuthority.kt @@ -23,6 +23,34 @@ data class MutationRatchetAuthority( val population: MutationPopulationBaseline, val classifications: MutationClassifications, val targetFamilies: Map, + /** + * Base-side preauthorizations for future classification enrollment + * (0.7.1g1F). Defaults to [MutationClassificationEnrollments.NONE], which is + * the most restrictive state: no authorization exists, so every + * candidate-side classification addition still fails M08/M09. + */ + val enrollments: MutationClassificationEnrollments = MutationClassificationEnrollments.NONE, + /** + * Base-side preauthorizations for admitting *appearing* candidate-only NON_KILLED identities + * (0.7.1g1G3, M30-M39). A caller that intentionally has no authorizations must pass + * [MutationPopulationAdmissions.NONE] explicitly; that is the most restrictive state, in which + * every appearing survivor still fails M06. + * + * Deliberately has NO default: this input is enforcement authority, and a forgotten call site + * must fail to compile rather than silently degrade the transition to "no authorizations". + */ + val admissions: MutationPopulationAdmissions, + /** + * Base-side digest-migration certificates (0.7.1g1P2, M40-M47). OPTIONAL authority, like + * [enrollments]: a base that predates the certificate ledger simply has none, which is the most + * restrictive state - no certified migration exists, so every raw-v1 admission still fails M34. + * A present ledger is validated by its own loader, so a malformed one fails hard rather than + * degrading into "no certificates". + * + * The default is convenient for fixtures and is always the conservative answer; the loader passes + * it explicitly, read from the base revision. + */ + val certificates: MutationAuthorityDigestCertificates = MutationAuthorityDigestCertificates.NONE, ) /** @@ -34,6 +62,45 @@ data class MutationRatchetCandidate( val population: MutationPopulationBaseline, val classifications: MutationClassifications, val targetFamilies: Map, + /** + * Enrollments proposed by this transition. They may be *validated* here but + * they can never authorize a classification in the same transition: only + * [MutationRatchetAuthority.enrollments] is consulted for authorization. + */ + val enrollments: MutationClassificationEnrollments = MutationClassificationEnrollments.NONE, + /** + * Population admissions proposed by this transition. They may be *validated* here (M35 binds a + * new authorization to the base it is proposed against), but they can never authorize an + * admission in the same transition: only [MutationRatchetAuthority.admissions] is consulted, + * so a candidate that both authorizes and admits fails M31. + * + * Deliberately has NO default, for the same reason as [MutationRatchetAuthority.admissions]: + * the candidate ledger is the transition's proposal, so a task that forgets to load it must + * not compile. Missing it silently turned every candidate proposal into "none". + */ + val admissions: MutationPopulationAdmissions, + /** + * Certificates this transition proposes. They are validated here (M45 binds a newly introduced + * certificate to the base it is proposed against, M46 rejects a rewritten retained one) but they + * can never authorize a consumption in the same transition: only + * [MutationRatchetAuthority.certificates] is consulted, so a candidate that both mints and + * consumes fails M43. + * + * Defaults to [MutationAuthorityDigestCertificates.NONE] for fixtures. The loader always passes it + * explicitly. A call site that forgot would weaken nothing: every removal then looks + * unconsumed and M47 fails loudly, which is the conservative direction. + */ + val certificates: MutationAuthorityDigestCertificates = MutationAuthorityDigestCertificates.NONE, + /** + * Promotion declaration proposed by this transition (0.7.1i). Not authority: it grants nothing + * and authorizes no admission, it states that an already-certified evidence population is + * carried across a release promotion boundary and therefore was not minted here, so M35/M45 do + * not bind it to the promotion base. + * + * Defaults to absent, which is the conservative state: with no promotion every appearing + * admission and certificate must still bind the transition base, exactly as before. + */ + val promotion: MutationPopulationPromotion? = null, ) object MutationRatchetAuthorityLoader { @@ -106,11 +173,53 @@ object MutationRatchetAuthorityLoader { val population = readPopulation(baselineFile, baseSha) val classifications = MutationClassificationLoader.load(tempDir) val configuration = TestQualityConfiguration.load(tempDir) + // Enrollment authorizations are OPTIONAL authority: a base that + // predates the ledger simply has none, which is the most + // restrictive state (no candidate-side classification can be + // authorized). A present file is validated by the same loader as + // any other authority, so a malformed ledger fails hard. + val enrollmentFile = File(qualityDir, "mutation-classification-enrollments.yml") + val enrollmentAtBase = + runGit(rootDir, listOf("show", "$baseSha:${MutationClassificationEnrollmentLoader.FILE_NAME}")) + if (enrollmentAtBase.exitCode == 0) { + enrollmentFile.writeText(enrollmentAtBase.output, Charsets.UTF_8) + } + val enrollments = MutationClassificationEnrollmentLoader.load(tempDir) + // Population admissions (M30-M39) are OPTIONAL authority too, loaded the same way: a + // base that predates the ledger simply has none, which is the most restrictive state - + // no appearing survivor can be admitted, so M06 still rejects every one of them. A + // present ledger is validated by its own loader, so a malformed one fails hard instead + // of degrading into "no authorizations". + val admissionsFile = File(qualityDir, "mutation-population-admissions.yml") + val admissionsAtBase = + runGit(rootDir, listOf("show", "$baseSha:${MutationPopulationAdmissionLoader.FILE_NAME}")) + if (admissionsAtBase.exitCode == 0) { + admissionsFile.writeText(admissionsAtBase.output, Charsets.UTF_8) + } + val admissions = MutationPopulationAdmissionLoader.load(tempDir) + // Digest-migration certificates (0.7.1g1P2) are OPTIONAL authority for the same reason: a + // base that predates the ledger has none, which is the most restrictive state, since no + // certified migration exists and every raw-v1 admission still fails M34. A present ledger + // is validated by its own loader, so a malformed one fails hard instead of degrading into + // "no certificates". + val certificatesFile = File(qualityDir, "mutation-authority-digest-certificates.yml") + val certificatesAtBase = + runGit( + rootDir, + listOf("show", "$baseSha:${MutationAuthorityDigestCertificateLoader.FILE_NAME}"), + ) + if (certificatesAtBase.exitCode == 0) { + certificatesFile.writeText(certificatesAtBase.output, Charsets.UTF_8) + } + val certificates = MutationAuthorityDigestCertificateLoader.load(tempDir) return MutationRatchetAuthority( baseSha = baseSha, population = population, classifications = classifications, targetFamilies = configuration.mutation.targetFamilies, + enrollments = enrollments, + admissions = admissions, + certificates = certificates, ) } finally { tempDir.deleteRecursively() diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt index c31d77790..285c325fc 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt @@ -45,13 +45,28 @@ import dev.tramai.build.quality.TestQualityConfiguration.MutationTargetFamily * - M16/M17/M18 analyzer semantics/mutator/timeout drift, base vs candidate * AND candidate vs the executable PIT renderer = fail * - M19 identity-schema drift = fail - * - M20 malformed / missing / self-inconsistent authority = fail closed + * - M20 malformed / missing / self-inconsistent authority = fail closed + * - M21 base identity absent from the candidate population = fail by default. A recorded evolution + * invocation may downgrade individually recorded removals to warnings only when the candidate has + * a fresh measured population; all other removals fail closed. + * - M22 base-authorized enrollment: candidate adds a classification whose EXACT record (classification, + * reason, issue, targetPhase) is already authorized by the base enrollment ledger, for a target that is + * an unresolved NON_KILLED survivor in the base population = pass (the ceremony) + * - M23 candidate adds an enrollment authorization AND enrolls it in the same transition = fail + * - M24 enrolled classification payload differs from its base authorization = fail + * - M25 enrollment authorization is duplicated, or targets an identity that is absent from the base + * population, KILLED, or already classified = fail closed + * - M26 base authorization removed while its exact authorized classification is enrolled = pass (consumption) + * - M27 authorization removed without enrollment, or retained after its classification was enrolled = fail * * Classification authority semantics: a PR may only REMOVE classifications * (when the underlying mutant dies), never add or re-author one. New * classifications are adjudicated on master during an enrollment ceremony and * become part of the base; anything a candidate adds is either self-approval - * of its own survivor (M08) or fabrication (M09). byFamily metrics are + * of its own survivor (M08) or fabrication (M09). The ceremony itself is the + * base-side enrollment ledger (M22-M27): an authorization merged into the base + * may be consumed by a LATER transition, so a candidate can never create the + * authority it uses. byFamily metrics are * derived truth: they are recomputed from the persisted rows and must equal * the persisted entry exactly; every configured family must stay non-vacuous. * @@ -63,6 +78,8 @@ class MutationRatchetVerifier { base: MutationRatchetAuthority, candidate: MutationRatchetCandidate, executable: MutationAnalyzerSemantics, + evolution: MutationPopulationEvolution = MutationPopulationEvolution.FORBID, + evolutionEvidence: MutationEvolutionEvidence = MutationEvolutionEvidence(), ): List { val diagnostics = mutableListOf() diagnostics += schemaAndStatusChecks(base.population, candidate.population) @@ -72,8 +89,43 @@ class MutationRatchetVerifier { diagnostics += validateClassificationList("base authority", base.classifications) diagnostics += validateClassificationList("candidate", candidate.classifications) diagnostics += baseClassificationIntegrity(base) - diagnostics += outcomeRatchet(base.population.mutants, candidate.population.mutants) + val freshAuthorityProjectionHash = evolutionEvidence.proof?.authorityProjectionHash + // Produced ONCE for the whole transition. M34, the admission lifecycle and the certificate + // lifecycle (M44/M47) all receive this exact instance, so they cannot quietly come to + // different conclusions about which consumptions were proven. + val admissionAuthority = base.admissionAuthority(freshAuthorityProjectionHash) + diagnostics += + outcomeRatchet( + base.population, + candidate.population, + MutationEvolutionContext( + evolution, + evolutionEvidence, + base.baseSha, + AUTHORITY_EXCLUDED_IDENTITIES, + base.admissions, + candidate.admissions, + freshAuthorityProjectionHash, + admissionAuthority, + ), + ) diagnostics += classificationRatchet(base, candidate) + diagnostics += MutationEnrollmentCeremony.checks(base, candidate) + // A promotion declaration carries already-certified evidence across the release promotion + // boundary. Computed once from the same ledgers both ceremonies judge, so the admission and + // certificate rules cannot disagree about what was carried. Null means "no promotion". + val promotion = MutationPopulationPromotion.carried(base, candidate) + diagnostics += MutationPopulationAdmissionCeremony.checks(base, candidate, admissionAuthority, promotion) + // Certificate custody (M44-M47), decided on the same facts M34 uses: one production site, so the + // lifecycle rules cannot disagree with the admission verdicts about what was consumed. + diagnostics += + MutationAuthorityDigestCertificateCeremony.checks( + base = base.certificates, + candidate = candidate.certificates, + baseSha = base.baseSha, + validConsumptions = admissionAuthority.certifiedConsumptions, + promotion = promotion, + ) diagnostics += familyAndTargetChecks( base.population, @@ -245,10 +297,13 @@ class MutationRatchetVerifier { } private fun outcomeRatchet( - baseMutants: List, - candidateMutants: List, + basePopulation: MutationPopulationBaseline, + candidatePopulation: MutationPopulationBaseline, + evolution: MutationEvolutionContext, ): List { val diagnostics = mutableListOf() + val baseMutants = basePopulation.mutants + val candidateMutants = candidatePopulation.mutants val baseById = baseMutants.associateBy { it.identity } val candidateById = candidateMutants.associateBy { it.identity } val baseIds = baseById.keys @@ -278,20 +333,81 @@ class MutationRatchetVerifier { ) } } - for (id in candidateIds - baseIds) { + diagnostics += + appearingIdentityChecks( + newIds = candidateIds - baseIds, + candidatePopulation = candidatePopulation, + baseAdmissions = evolution.baseAdmissions.byIdentity(), + candidateAdmissions = evolution.candidateAdmissions.byIdentity(), + authority = evolution.authority, + ) + + // M21: a base identity that simply stopped being measured. Absence is not evidence of + // improvement — a narrowed target, a module that stopped reporting, a truncated report or an + // aborted campaign all look exactly like this. Measured authority may only shrink through the + // repository's explicit baseline evolution ceremony, never as a side effect of a measurement. + diagnostics += + populationEvolutionDiagnostics( + basePopulation, + candidatePopulation, + evolution, + ) + return diagnostics + } + + /** + * M30-M34: an appearing survivor is admitted only by an exact BASE-SIDE authorization matching + * its exact row, the analyzer semantics AND the canonical fresh-population digest. The judgment + * is shared with the admission ceremony ([MutationPopulationAdmissionCeremony.appearanceVerdict]), + * so an authorized identity is never both admitted and reported as a failure. + * + * Unauthorized appearances still fail M06, unchanged - that is what keeps this from becoming a + * general bypass: authorized X plus unauthorized Y still fails for Y. New KILLED identities pass + * through M07, as before. + */ + private fun appearingIdentityChecks( + newIds: Set, + candidatePopulation: MutationPopulationBaseline, + baseAdmissions: Map, + candidateAdmissions: Map, + authority: AdmissionAuthority, + ): List { + val diagnostics = mutableListOf() + val candidateById = candidatePopulation.mutants.associateBy { it.identity } + for (id in newIds) { val candidate = candidateById.getValue(id) - if (candidate.outcome == NON_KILLED) { - diagnostics += - VerificationDiagnostic.failure( - DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, - "M06: ${describe(candidate)} (${short(id)}) is a NEW NON_KILLED identity absent from the " + - "base authority. New mutants must be killed; a PR cannot certify its own survivors.", - findingId = id, - modulePath = candidate.module, - ) + if (candidate.outcome != NON_KILLED) { + continue } - // M07: new KILLED identities pass — improved protection is the ratchet's goal. - // (Only self-consistent kills pass: rowSelfChecks already rejected forged ones.) + diagnostics += + when ( + val verdict = + MutationPopulationAdmissionCeremony.appearanceVerdict( + baseAdmission = baseAdmissions[id], + candidateAdmission = candidateAdmissions[id], + mutant = candidate, + candidateAnalyzer = candidatePopulation.analyzer, + authority = authority, + ) + ) { + is MutationPopulationAdmissionCeremony.AdmissionVerdict.Authorized -> { + VerificationDiagnostic.accepted( + DiagnosticCode.MUTATION_RATCHET_ADMISSION_ACCEPTED, + "M30: ${describe(candidate)} (${short(id)}) is admitted by an exact base-side " + + "population authorization: the persisted row, the analyzer semantics and " + + "the canonical fresh-population digest all match.", + ) + } + + is MutationPopulationAdmissionCeremony.AdmissionVerdict.Rejected -> { + VerificationDiagnostic.failure( + verdict.code, + verdict.message, + findingId = id, + modulePath = candidate.module, + ) + } + } } return diagnostics } @@ -344,11 +460,6 @@ class MutationRatchetVerifier { // M03: approved survivor stays NON_KILLED with an identical classification -> pass. } - // Added classifications (M08/M09): a candidate may never add one. - (candidateClassById.keys - baseClassById.keys).forEach { id -> - diagnostics += addedClassificationDiagnostic(id, candidateById[id], baseById[id], base.baseSha) - } - // Removed classifications (M04/M11). (baseClassById.keys - candidateClassById.keys).forEach { id -> val mutant = candidateById[id] @@ -368,38 +479,6 @@ class MutationRatchetVerifier { return diagnostics } - private fun addedClassificationDiagnostic( - id: String, - candidateMutant: MutationOutcome?, - baseMutant: MutationOutcome?, - baseSha: String, - ): VerificationDiagnostic { - val reason = - when { - candidateMutant == null -> { - "M09: classification ${short(id)} is fabricated — it references no mutant measured " + - "in the candidate population and none certified in the base." - } - - baseMutant == null -> { - "M08: candidate self-approval — classification ${short(id)} was added for a mutant that " + - "does not exist in the base authority. A PR cannot approve its own new survivors." - } - - else -> { - "M09: classification ${short(id)} was added for a mutant that was never classified in the " + - "base authority (base outcome ${baseMutant.outcome}). New classifications are adjudicated " + - "on master, never by the PR under review." - } - } - return VerificationDiagnostic.failure( - DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID, - "$reason (base $baseSha)", - findingId = id, - modulePath = candidateMutant?.module, - ) - } - private fun familyAndTargetChecks( basePopulation: MutationPopulationBaseline, baseTargetFamilies: Map, @@ -490,7 +569,7 @@ class MutationRatchetVerifier { return diagnostics } - private companion object { + internal companion object { const val KILLED = "KILLED" const val NON_KILLED = "NON_KILLED" const val IDENTITY_SCHEMA_VERSION = "2" @@ -696,3 +775,330 @@ class MutationRatchetVerifier { "score=${population.mutationScore}" } } + +private const val M21_ID_SHORT_LENGTH = 8 +private const val HEX_BYTE_MASK = 0xff + +data class MutationEvolutionEvidence( + val records: MutationEvolutionRecords = MutationEvolutionRecords("1", emptyList()), + val proof: MutationPopulationEvolutionProof? = null, +) + +private data class MutationEvolutionContext( + val mode: MutationPopulationEvolution, + val evidence: MutationEvolutionEvidence, + val baseSha: String, + val excludedIdentities: Set, + /** Base-side population admissions (M30-M39): the ONLY authority an appearing identity can use. */ + val baseAdmissions: MutationPopulationAdmissions = MutationPopulationAdmissions.NONE, + /** Admissions this transition proposes: validated, but never authority for its own admission. */ + val candidateAdmissions: MutationPopulationAdmissions = MutationPopulationAdmissions.NONE, + /** + * Authority projection hash of the canonical fresh measurement + * ([MutationPopulationEvolutionProof.authorityProjectionHash]) - the digest a population + * authorization must match to be consumable (M34). Raw PIT status is excluded by design (C7). + * Null means no trusted measurement proof exists, and admission then fails closed. + */ + val freshAuthorityProjectionHash: String? = null, + /** + * Authority context for appearing-identity verdicts (M34, Step 3b): the fresh projection plus the + * consumptions it certifies. Defaults to no projection and no consumptions, which is the + * fail-closed state: an appearing identity with no trusted measurement proof still fails M34. + */ + val authority: AdmissionAuthority = AdmissionAuthority(null), +) + +// No population hash is stored in mutation-evolution.yml: exact measurement +// equality already binds the candidate, while this proof binds the verifier call. +// [projectionHash] hashes the FULL canonical comparison projection — identity, raw status, +// canonical outcome, family, module, family/module topology and analyzer semantics — not just the +// identity set, so it cannot be reused for a population that shares the identities but differs in +// outcomes. It stays raw-exact: that is the measurement-truth question. +// [authorityProjectionHash] hashes the authority projection — identity, canonical outcome, family, +// module, topology and analyzer semantics, with raw status deliberately excluded. That is the +// population-context question M34 asks, and it is computed from the same fresh measurement by this +// verifier, so it can never be supplied by a candidate. +class MutationPopulationEvolutionProof private constructor( + val projectionHash: String, + val authorityProjectionHash: String, +) { + fun matches(population: MutationPopulationBaseline): Boolean = projectionHash == population.projectionHash() + + companion object { + fun exactComparison( + fresh: MutationPopulationBaseline, + candidate: MutationPopulationBaseline, + ): MutationPopulationExactComparison { + val diagnostics = mutableListOf() + val freshById = fresh.mutants.associateBy { it.identity } + val candidateById = candidate.mutants.associateBy { it.identity } + diagnostics += rowDifferences(freshById, candidateById) + val freshTopology = fresh.byFamily.mapValues { it.value.modules.toSet() } + val candidateTopology = candidate.byFamily.mapValues { it.value.modules.toSet() } + if (freshTopology != candidateTopology) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "M21: fresh and committed mutation population family/module topology differs: " + + "fresh=$freshTopology, candidate=$candidateTopology.", + ) + } + if (fresh.analyzer != candidate.analyzer) { + diagnostics += + exactDifference( + "analyzer", + "analyzer semantics fresh=${fresh.analyzer} != candidate=${candidate.analyzer}", + ) + } + (fresh.byFamily + candidate.byFamily).forEach { (family, population) -> + if (population.totalMutants == 0) { + diagnostics += exactDifference(family, "family is empty") + } + } + val proof = + if (diagnostics.isEmpty()) { + MutationPopulationEvolutionProof( + projectionHash = fresh.projectionHash(), + authorityProjectionHash = fresh.authorityProjectionHash(), + ) + } else { + null + } + return MutationPopulationExactComparison(proof, diagnostics) + } + + private fun exactDifference( + id: String, + difference: String, + ): VerificationDiagnostic = + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "M21: fresh measurement differs from committed candidate for identity " + + "${id.take(M21_ID_SHORT_LENGTH)}: $difference.", + findingId = id, + ) + + private fun rowDifferences( + fresh: Map, + candidate: Map, + ): List { + val diagnostics = mutableListOf() + for (id in (fresh.keys union candidate.keys).sorted()) { + val measured = fresh[id] + val committed = candidate[id] + val difference = + when { + measured == null -> { + "identity is absent from fresh measurement" + } + + committed == null -> { + "identity is absent from committed candidate" + } + + measured.status != committed.status -> { + "raw status ${measured.status} != ${committed.status}" + } + + measured.outcome != committed.outcome -> { + "canonical status ${measured.outcome} != ${committed.outcome}" + } + + measured.family != committed.family -> { + "family ${measured.family} != ${committed.family}" + } + + measured.module != committed.module -> { + "module ${measured.module} != ${committed.module}" + } + + else -> { + null + } + } + if (difference != null) diagnostics += exactDifference(id, difference) + } + return diagnostics + } + + private fun String.sha256(): String = + java.security.MessageDigest + .getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and HEX_BYTE_MASK) } + + private fun MutationPopulationBaseline.projectionHash(): String = canonicalProjection().sha256() + + /** + * The canonical exact-comparison projection: the complete, order-independent evidence a + * fresh measurement must reproduce. Hashed by [projectionHash] so the proof binds + * everything the comparison actually checked, not merely the identity set. + */ + private fun MutationPopulationBaseline.canonicalProjection(): String = + buildString { + mutants + .map { "${it.identity}|${it.status}|${it.outcome}|${it.family}|${it.module}" } + .sorted() + .forEach { appendLine(it) } + append(contextLines()) + } + + /** + * The canonical authority projection: the population context a population authorization is + * bound to (M34). Raw PIT status is deliberately EXCLUDED. + * + * C7 ([MutationOutcome]) declares raw `status` diagnostic evidence and not authority, and + * anticipates TIMED_OUT↔SURVIVED scheduler races. Four complete unrestricted campaigns at + * identical effective PIT inputs produced four distinct raw-status digests while this + * projection was byte-identical every time, so binding neighbours' raw status here let a + * scheduler race invalidate adjudicated authority that was itself stable. + * + * What remains is exactly the authority-relevant content: identity, canonical outcome, + * family, module, family/module topology and analyzer semantics. Use this for M34; the + * raw-exact [canonicalProjection] stays the measurement proof (M21, [exactComparison]). + */ + private fun MutationPopulationBaseline.authorityProjection(): String = + buildString { + mutants + .map { "${it.identity}|${it.outcome}|${it.family}|${it.module}" } + .sorted() + .forEach { appendLine(it) } + append(contextLines()) + } + + private fun MutationPopulationBaseline.authorityProjectionHash(): String = authorityProjection().sha256() + + /** + * The serialization suffix every projection shares: family/module topology, then analyzer + * semantics. It lives in one place because the authority projection IS the raw projection + * minus raw status - duplicating the suffix would let the two drift apart silently. + */ + private fun MutationPopulationBaseline.contextLines(): String = + buildString { + appendLine( + "topology=" + + byFamily.toSortedMap().entries.joinToString(",") { + "${it.key}:${it.value.modules.sorted().joinToString("+")}" + }, + ) + appendLine( + "analyzer=" + + analyzer.pluginVersion + "|" + analyzer.engineVersion + "|" + + analyzer.mutators + .sorted() + .joinToString("+") + "|" + + analyzer.timeoutConst + "|" + analyzer.timeoutFactor, + ) + } + } +} + +data class MutationPopulationExactComparison( + val proof: MutationPopulationEvolutionProof?, + val diagnostics: List, +) + +private fun populationEvolutionDiagnostics( + basePopulation: MutationPopulationBaseline, + candidatePopulation: MutationPopulationBaseline, + evolution: MutationEvolutionContext, +): List { + val diagnostics = mutableListOf() + val baseById = basePopulation.mutants.associateBy { it.identity } + val candidateIds = candidatePopulation.mutants.map { it.identity }.toSet() + diagnostics += evolutionEvidenceDiagnostics(baseById, candidateIds, candidatePopulation, evolution) + val recordsById = evolution.evidence.records.byIdentity() + for (id in baseById.keys - candidateIds) { + val base = baseById.getValue(id) + if (id in evolution.excludedIdentities) continue + val record = recordsById[id] + val description = + "${base.module} ${base.className}#${base.method}${base.methodDescription} " + + "[${base.mutator}] family '${base.family}'" + val message = + "M21: $description (${id.take(M21_ID_SHORT_LENGTH)}) exists in the base authority but is " + + "absent from the candidate population. " + when { + evolution.mode == MutationPopulationEvolution.FORBID -> { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + message + + "Absence alone cannot distinguish a legitimate code evolution from an " + + "incomplete or narrowed measurement, so normal ratchet verification fails " + + "closed. Population removal requires explicit baseline-evolution authority.", + findingId = id, + modulePath = base.module, + ) + } + + record == null -> { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + message + "Authorized evolution does not name this identity.", + findingId = id, + modulePath = base.module, + ) + } + + else -> { + diagnostics += + VerificationDiagnostic.warning( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + message + "Authorized population evolution: ${record.reason}; " + + "issue=${record.issue ?: "none"}, targetPhase=${record.targetPhase ?: "none"}.", + ) + } + } + } + return diagnostics +} + +private fun evolutionEvidenceDiagnostics( + baseById: Map, + candidateIds: Set, + candidatePopulation: MutationPopulationBaseline, + evolution: MutationEvolutionContext, +): List { + if (evolution.mode != MutationPopulationEvolution.RECORDED_EVOLUTION) return emptyList() + val diagnostics = mutableListOf() + val proof = evolution.evidence.proof + if (proof == null) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "M21: recorded evolution requires a proof from an exact fresh canonical measurement; " + + "authority without measurement evidence is not accepted.", + ) + } else if (!proof.matches(candidatePopulation)) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "M21: recorded evolution proof does not match the committed candidate population.", + ) + } + val removedIds = baseById.keys - candidateIds - evolution.excludedIdentities + val records = evolution.evidence.records + for (id in records.byIdentity().keys - removedIds) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "M21: evolution record ${id.take(M21_ID_SHORT_LENGTH)} does not name a base identity " + + "disappearing in this transition.", + findingId = id, + ) + } + for (record in records.records) { + if (record.fromBaseSha != evolution.baseSha) { + diagnostics += + VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "M21: evolution record ${record.id.take(M21_ID_SHORT_LENGTH)} is bound to base " + + "${record.fromBaseSha}, not this transition base ${evolution.baseSha}.", + findingId = record.id, + ) + } + } + return diagnostics +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationReportParser.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationReportParser.kt index 337b08601..80a8c067f 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationReportParser.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/MutationReportParser.kt @@ -53,6 +53,25 @@ class MutationReportParser { } catch (e: Exception) { throw GradleException("Malformed PITest XML for $family/$module: ${e.message}", e) } + // Structural validity: a document that is not a PITest mutation report at all must never be + // read as one. Without this, any well-formed XML — a stale report from another tool, a + // partially written file that happens to close its root early — silently parses to zero + // mutants and looks like a legitimately empty measurement. + // + // Deliberately NOT checked here: ownership of emptiness. A valid `` root carrying + // zero mutations is a measuring concern, not a syntax concern, and belongs to + // MutationPopulationAggregator's non-vacuity check. + // + // Also deliberately not a signal here: the root's `partial` attribute. PIT writes it in + // runStart() from ReportOptions.shouldReportCoverage, i.e. before a single mutant is + // analysed — it means "this report carries coverage data", never "the run was incomplete". + val root = document.documentElement + if (root.tagName != PITEST_ROOT_ELEMENT) { + throw GradleException( + "Malformed PITest XML for $family/$module: expected <$PITEST_ROOT_ELEMENT> root, found " + + "<${root.tagName}>. A non-PITest document must not be measured as an empty population.", + ) + } val elements = document.getElementsByTagName("mutation") val records = (0 until elements.length) @@ -246,6 +265,9 @@ class MutationReportParser { } companion object { + /** The only document root PITest emits for a mutation report. */ + const val PITEST_ROOT_ELEMENT: String = "mutations" + /** Terminal PIT statuses we understand. Anything else is a hard failure (M11). */ val KNOWN_STATUSES = setOf( diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/TramaiVersions.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/TramaiVersions.kt new file mode 100644 index 000000000..431d3ae76 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/TramaiVersions.kt @@ -0,0 +1,28 @@ +package dev.tramai.build.quality + +/** + * Version vocabulary for the quality gates, kept dependency-free so build-logic tasks and the + * docs guards share one interpretation. + * + * The repository has a single version authority: the `tramaiVersion` Gradle property, which is both + * the artifact version and the value the gates read. On a development line it carries a + * `-SNAPSHOT` suffix (release publishing refuses such a value, which is what marks it as + * pre-release), while every release-scoped contract — migration target versions, CHANGELOG + * sections, release-readiness documents, roadmap release trains — names the release the line will + * become. + */ +object TramaiVersions { + /** + * The release a project version belongs to: `0.7.0-SNAPSHOT` -> `0.7.0`, `0.7.0` -> `0.7.0`. + * + * Deliberately narrow: only the exact `-SNAPSHOT` suffix is stripped, so a migration entry + * remains truthful both during development and after the release cut. A general SemVer + * pre-release parser is not needed by any current consumer. + */ + fun releaseVersionOf(projectVersion: String): String = projectVersion.removeSuffix(SNAPSHOT_SUFFIX) + + /** True when the project version is a development snapshot rather than a promoted release. */ + fun isSnapshot(projectVersion: String): Boolean = projectVersion.endsWith(SNAPSHOT_SUFFIX) + + private const val SNAPSHOT_SUFFIX = "-SNAPSHOT" +} diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/VerificationDiagnostic.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/VerificationDiagnostic.kt index 134a69deb..cff6da756 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/VerificationDiagnostic.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/VerificationDiagnostic.kt @@ -180,6 +180,25 @@ enum class DiagnosticCode { // M20 malformed / missing / self-inconsistent authority or candidate. MUTATION_RATCHET_AUTHORITY_INVALID, + + // M23 a transition authorizes an enrollment AND consumes it itself. + MUTATION_RATCHET_ENROLLMENT_UNAUTHORIZED, + + // M24 enrolled classification does not match its base authorization payload. + MUTATION_RATCHET_ENROLLMENT_MISMATCH, + + // M25 enrollment authorization is malformed or targets an invalid identity. + MUTATION_RATCHET_ENROLLMENT_INVALID, + MUTATION_RATCHET_ADMISSION_UNAUTHORIZED, + MUTATION_RATCHET_ADMISSION_MISMATCH, + MUTATION_RATCHET_ADMISSION_INVALID, + MUTATION_RATCHET_ADMISSION_RETAINED, + + // M30: an appearing survivor admitted by an exact base-side population authorization. + MUTATION_RATCHET_ADMISSION_ACCEPTED, + + // M27 authorization removed without enrollment, or retained after enrollment. + MUTATION_RATCHET_ENROLLMENT_ORPHANED, } /** diff --git a/build-logic/src/main/kotlin/dev/tramai/build/quality/VerifyArchitectureTask.kt b/build-logic/src/main/kotlin/dev/tramai/build/quality/VerifyArchitectureTask.kt index f0f7cea6e..08cfe8e52 100644 --- a/build-logic/src/main/kotlin/dev/tramai/build/quality/VerifyArchitectureTask.kt +++ b/build-logic/src/main/kotlin/dev/tramai/build/quality/VerifyArchitectureTask.kt @@ -486,6 +486,13 @@ abstract class VerifyArchitectureTask : DefaultTask() { // verifyMutationRatchet task directly, not the maintainability baseline. DiagnosticCode.MUTATION_RATCHET_REGRESSION, DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, + // Population admission ceremony (0.7.1g1G3): M06's narrow, exact-authorization + // exception. Enforced by verifyMutationRatchet, never by the maintainability baseline. + DiagnosticCode.MUTATION_RATCHET_ADMISSION_UNAUTHORIZED, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_RETAINED, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_ACCEPTED, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_REMOVED, DiagnosticCode.MUTATION_RATCHET_DUPLICATE_IDENTITY, @@ -495,6 +502,10 @@ abstract class VerifyArchitectureTask : DefaultTask() { DiagnosticCode.MUTATION_RATCHET_SEMANTICS_DRIFT, DiagnosticCode.MUTATION_RATCHET_SCHEMA_DRIFT, DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_UNAUTHORIZED, + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_MISMATCH, + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID, + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED, -> null } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/docs/ExampleGuideReleaseAssertionTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/docs/ExampleGuideReleaseAssertionTest.kt new file mode 100644 index 000000000..ca5bdf02f --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/docs/ExampleGuideReleaseAssertionTest.kt @@ -0,0 +1,81 @@ +package dev.tramai.build.docs + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertFailsWith + +/** + * The example guide names the Maven coordinates it consumes, so the release it names must be the + * last promoted one — derived from CHANGELOG.md, never a literal that goes stale at every release + * cut. A literal here previously made the full release closure red on the Epic base while every + * PR-scoped gate stayed green. + */ +class ExampleGuideReleaseAssertionTest { + @TempDir + lateinit var tempDir: File + + private val repoRoot: File by lazy { + var dir = File(System.getProperty("user.dir")).absoluteFile + while (!File(dir, ".git").isDirectory) { + dir = dir.parentFile ?: error("repo root not found from ${System.getProperty("user.dir")}") + } + dir + } + + @Test + fun `the promoted release is derived from the dated changelog heading`() { + val dir = fixture(promotedRelease = "1.2.3") + + assertEquals("1.2.3", promotedReleaseVersion(dir)) + } + + @Test + fun `the real example guide is accepted when it names the promoted release`() { + val dir = fixture() + + // No exception: the guide names the release the CHANGELOG promotes. + RootDocGuardVerifiers.exampleSelectionGuide(dir) + } + + @Test + fun `the example guide is rejected when it names a release other than the promoted one`() { + val dir = fixture() + val guide = File(dir, "examples/README.md") + val text = guide.readText() + // Version-agnostic: whatever release the real guide names, naming another must fail. + guide.writeText(text.replace(Regex("released TramAI \\d+\\.\\d+\\.\\d+"), "released TramAI 0.0.1")) + + val failure = assertFailsWith { RootDocGuardVerifiers.exampleSelectionGuide(dir) } + + assertTrue(failure.message.orEmpty().contains("must name the last promoted release"), failure.message.orEmpty()) + } + + /** Real guide + its link targets (the guard resolves them) + a dated CHANGELOG. */ + private fun fixture(promotedRelease: String = promotedReleaseVersion(repoRoot)): File { + val dir = File(tempDir, "root").apply { mkdirs() } + File(dir, "CHANGELOG.md").writeText("## Unreleased\n\n## $promotedRelease - 2026-07-06\n") + File(dir, "settings.gradle.kts").writeText(File(repoRoot, "settings.gradle.kts").readText()) + File(dir, "examples/README.md") + .apply { parentFile.mkdirs() } + .writeText(File(repoRoot, "examples/README.md").readText()) + for (target in LINK_TARGETS) { + File(dir, target).apply { parentFile.mkdirs() }.writeText("# ${target.substringAfterLast('/')}\n") + } + return dir + } + + private companion object { + val LINK_TARGETS = + listOf( + "examples/kotlin-native-smoke-example/README.md", + "examples/kotlin-springboot-example/README.md", + "examples/sovereign-lab/README.md", + "examples/spring-sovereign-starter/README.md", + "docs/STATUS.md", + "scripts/verify-zero-egress.sh", + ) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPluginTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPluginTest.kt index c70207ce8..ae3a87704 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPluginTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPluginTest.kt @@ -108,6 +108,66 @@ class TramaiDocsGuardsPluginTest { target.writeText(content) } + /** + * The version the fixture tree actually declares. The version-alignment + * fixture overwrites build.gradle.kts (the real one needs a version + * catalog), so its fallback must be derived from the copied + * gradle.properties rather than hardcoded — a hardcoded fallback silently + * rots every time the project version advances. + */ + private fun fixtureVersion(dir: File): String = + File(dir, "gradle.properties") + .readLines() + .single { it.startsWith("tramaiVersion=") } + .substringAfter("=") + .trim() + + /** + * Version-alignment fixture: the real version surfaces plus the minimal build file the verifier + * needs (the real one requires a version catalog). The version surfaces are copied by directory + * where possible, so a release cut cannot leave this fixture pointing at a document that no + * longer exists. + */ + private fun versionAlignmentFixture(): File { + val dir = fixture() + copyFromRepo( + dir, + "gradle.properties", + "build.gradle.kts", + "CHANGELOG.md", + "docs/STATUS.md", + "docs/POST-SOVEREIGNTY-ROADMAP.md", + "docs/releases", + "README.md", + "docs/guides", + "docs/module-guide.md", + "docs/reference/releasing.md", + "examples/README.md", + "examples/support-agent/build.gradle.kts", + "examples/kotlin-springboot-example/build.gradle.kts", + "examples/kotlin-native-smoke-example/build.gradle.kts", + "examples/sovereign-runtime-consumer-smoke/build.gradle.kts", + "examples/spring-sovereign-starter/build.gradle.kts", + "docs/modules", + ) + val version = fixtureVersion(dir) + writeFile( + dir, + "build.gradle.kts", + """ + plugins { id("tramai.docs-guards") } + version = providers.gradleProperty("tramaiVersion").orElse("$version") + """.trimIndent(), + ) + return dir + } + + /** The row of the STATUS.md released-versions table that claims a release is current. */ + private fun currentReleaseRow(dir: File): String = + File(dir, "docs/STATUS.md") + .readLines() + .first { it.contains("| Current release") } + private fun runTask( dir: File, task: String, @@ -206,6 +266,8 @@ class TramaiDocsGuardsPluginTest { "examples/spring-sovereign-starter/README.md", "examples/kotlin-springboot-example/README.md", "examples/sovereign-lab/README.md", + // The guide must name the promoted release, which the guard derives from this file. + "CHANGELOG.md", ) runTask(dir, "verifyExampleSelectionGuide") } @@ -226,40 +288,33 @@ class TramaiDocsGuardsPluginTest { @Test fun `verifyVersionAlignment passes on real version surfaces`() { - val dir = fixture() - copyFromRepo( - dir, - "gradle.properties", - "build.gradle.kts", - "CHANGELOG.md", - "docs/STATUS.md", - "docs/POST-SOVEREIGNTY-ROADMAP.md", - "docs/releases/0.6.0-release-readiness.md", - "docs/releases/sovereign-runtime-release-readiness.md", - "README.md", - "docs/guides", - "docs/module-guide.md", - "docs/reference/releasing.md", - "examples/README.md", - "examples/support-agent/build.gradle.kts", - "examples/kotlin-springboot-example/build.gradle.kts", - "examples/kotlin-native-smoke-example/build.gradle.kts", - "examples/sovereign-runtime-consumer-smoke/build.gradle.kts", - "examples/spring-sovereign-starter/build.gradle.kts", - "docs/modules", - ) - // The real build.gradle.kts uses version-catalog aliases that need a - // libs.versions.toml; restore a minimal fixture build file that still - // carries the orElse("0.6.0") fallback the verifier checks. - writeFile( - dir, - "build.gradle.kts", - """ - plugins { id("tramai.docs-guards") } - version = providers.gradleProperty("tramaiVersion").orElse("0.6.0") - """.trimIndent(), + runTask(versionAlignmentFixture(), "verifyVersionAlignment") + } + + @Test + fun `verifyVersionAlignment fails when the promoted release is no longer named current`() { + val dir = versionAlignmentFixture() + val status = File(dir, "docs/STATUS.md") + val row = currentReleaseRow(dir) + val version = row.split("|")[1].trim() + // The exact drift the guard exists to catch: the promoted release still exists in the + // table, but the tree no longer claims it is the current one. + status.writeText(status.readText().replace(row, row.replaceFirst(version, "0.0.1"))) + val result = runner(dir, "verifyVersionAlignment", "--no-build-cache").buildAndFail() + assertContains(result.output, "as the current release in its released-versions table") + } + + @Test + fun `verifyVersionAlignment fails when the promoted release date drifts`() { + val dir = versionAlignmentFixture() + val changelog = File(dir, "CHANGELOG.md") + val dated = Regex("""(?m)^## \d+\.\d+\.\d+ - \d{4}-\d{2}-\d{2}$""") + val heading = dated.find(changelog.readText())?.value ?: error("no dated release heading in fixture") + changelog.writeText( + changelog.readText().replace(heading, heading.replace(Regex("""\d{4}-\d{2}-\d{2}"""), "1999-01-01")), ) - runTask(dir, "verifyVersionAlignment") + val result = runner(dir, "verifyVersionAlignment", "--no-build-cache").buildAndFail() + assertContains(result.output, "must date the promoted release") } @Test @@ -344,37 +399,7 @@ class TramaiDocsGuardsPluginTest { @Test fun `verifyVersionAlignment fails on a stale snapshot reference`() { - val dir = fixture() - copyFromRepo( - dir, - "gradle.properties", - "build.gradle.kts", - "CHANGELOG.md", - "docs/STATUS.md", - "docs/POST-SOVEREIGNTY-ROADMAP.md", - "docs/releases/0.6.0-release-readiness.md", - "docs/releases/sovereign-runtime-release-readiness.md", - "README.md", - "docs/guides", - "docs/module-guide.md", - "docs/reference/releasing.md", - "examples/README.md", - "examples/support-agent/build.gradle.kts", - "examples/kotlin-springboot-example/build.gradle.kts", - "examples/kotlin-native-smoke-example/build.gradle.kts", - "examples/sovereign-runtime-consumer-smoke/build.gradle.kts", - "examples/spring-sovereign-starter/build.gradle.kts", - "docs/modules", - ) - // Restore minimal fixture build file (see positive version-alignment test). - writeFile( - dir, - "build.gradle.kts", - """ - plugins { id("tramai.docs-guards") } - version = providers.gradleProperty("tramaiVersion").orElse("0.6.0") - """.trimIndent(), - ) + val dir = versionAlignmentFixture() writeFile(dir, "docs/guides/getting-started.md", "Use dev.tramai:tramai-core:0.5.0-SNAPSHOT now.\n") val result = runner(dir, "verifyVersionAlignment", "--no-build-cache").buildAndFail() assertContains(result.output, "still contains dev.tramai:*:0.5.0-SNAPSHOT dependency reference") @@ -410,6 +435,8 @@ class TramaiDocsGuardsPluginTest { "examples/spring-sovereign-starter/README.md", "examples/kotlin-springboot-example/README.md", "examples/sovereign-lab/README.md", + // The guide must name the promoted release, which the guard derives from this file. + "CHANGELOG.md", ) val guide = File(dir, "examples/README.md") val text = guide.readText() diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/ApiCompatibilityMutationTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/ApiCompatibilityMutationTest.kt index d0e1bd6be..eab4529c1 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/ApiCompatibilityMutationTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/ApiCompatibilityMutationTest.kt @@ -119,10 +119,10 @@ class ApiCompatibilityMutationTest { ) } - // ── B1: Contract 2 — stable base→current change fails, additive too ── + // ── B1: Contract 2 — stable is a compatibility promise, not byte-identity ── @Test - fun `B1 stable additive API change fails without migration rescue`() { + fun `B1 stable additive API change is allowed and needs no migration rescue`() { val base = mapOf(":tramai-core" to dump(":tramai-core", "\tpublic fun a ()V")) val current = mapOf(":tramai-core" to dump(":tramai-core", "\tpublic fun a ()V", "\tpublic fun b ()V")) val migration = @@ -135,12 +135,34 @@ class ApiCompatibilityMutationTest { migration = "No action needed.", ) val diagnostics = verifier().verify(evidence(committed = current, base = base), migrations = listOf(migration)) + assertTrue( + diagnostics.none { it.code == DiagnosticCode.API_COMPATIBILITY_FAILED }, + "an additive stable change keeps every existing consumer working, so it must pass " + + "(a migration entry neither helps nor is required), got: $diagnostics", + ) + } + + @Test + fun `B1b stable removal fails and no migration entry can authorize it`() { + val base = mapOf(":tramai-core" to dump(":tramai-core", "\tpublic fun a ()V", "\tpublic fun b ()V")) + val current = mapOf(":tramai-core" to dump(":tramai-core", "\tpublic fun a ()V")) + val migration = + ApiMigrationEntry( + module = ":tramai-core", + fromSha256 = sha256(base.getValue(":tramai-core")), + toSha256 = sha256(current.getValue(":tramai-core")), + targetVersion = "0.6.0", + rationale = "Removed b", + migration = "Consumers drop b.", + ) + val diagnostics = verifier().verify(evidence(committed = current, base = base), migrations = listOf(migration)) assertTrue( diagnostics.any { it.code == DiagnosticCode.API_COMPATIBILITY_FAILED && - it.message.contains("stable") && it.message.contains(":tramai-core") + it.modulePath == ":tramai-core" && + it.message.contains("stable") }, - "stable additive change must fail even with a migration entry, got: $diagnostics", + "stable breakage must fail even when an exact migration entry exists, got: $diagnostics", ) } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/ApiStabilityPolicyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/ApiStabilityPolicyTest.kt new file mode 100644 index 000000000..f68d8fec9 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/ApiStabilityPolicyTest.kt @@ -0,0 +1,484 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import java.security.MessageDigest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Discriminators for the release-line API policy settled in 0.7.0 development: + * + * - **Stable = backward compatible**, not byte-identical. Additions pass; removals, signature + * changes and visibility reductions fail; migration entries can never authorize stable breakage. + * - **Migration targets are release-bound**: an ACTIVE entry declares the release its project + * version belongs to (`0.7.0-SNAPSHOT` -> `0.7.0`), so the same entry is truthful during + * development and after the release cut. + * + * Each case below is a row of the agreed policy table. + */ +class ApiStabilityPolicyTest { + // ── fixtures ────────────────────────────────────────────────────────── + + private fun entry( + path: String, + apiStability: String, + ): ModuleCatalog.ModuleEntry = + ModuleCatalog.ModuleEntry( + path = path, + layer = ModuleLayer.fromYaml("core-contracts") ?: error("bad layer"), + maturity = + ModuleMaturity.fromYaml(if (apiStability == "stable") "stable" else "preview") + ?: error("bad maturity"), + publishability = ModulePublishability.fromYaml("published") ?: error("bad publishability"), + apiStability = ModuleApiStability.fromYaml(apiStability) ?: error("bad apiStability"), + visibility = ModuleVisibility.fromYaml("public") ?: error("bad visibility"), + owner = "test", + dependencyPolicy = "core", + releaseInclusion = ReleaseInclusion.fromYaml("included") ?: error("bad release inclusion"), + rationale = "Policy fixture.", + description = "Policy fixture.", + ) + + private val catalog = + mapOf( + ":tramai-core" to entry(":tramai-core", "stable"), + ":tramai-engine" to entry(":tramai-engine", "preview"), + ) + + private fun verifier(projectVersion: String) = + ApiCompatibilityVerifier( + catalogModules = catalog, + projectVersion = projectVersion, + ) + + private fun dump( + module: String, + vararg body: String, + ): String = + ( + listOf( + "public final class dev/tramai/${module.removePrefix(":tramai-").replace('-', '/')}/Main {", + ) + + body.map { "\t$it" } + + listOf("}") + ).joinToString("\n") + "\n" + + private fun sha256(content: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(content.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + private fun migration( + module: String, + base: String, + current: String, + targetVersion: String, + ) = ApiMigrationEntry( + module = module, + fromSha256 = sha256(base), + toSha256 = sha256(current), + targetVersion = targetVersion, + rationale = "Fixture transition.", + migration = "No consumer action.", + ) + + private fun failures( + base: Map, + current: Map, + migrations: List = emptyList(), + projectVersion: String = "0.6.0", + ): List = + verifier(projectVersion) + .verify( + ApiDumpEvidence(generated = current, committed = current, base = base), + migrations, + ).filter { it.code == DiagnosticCode.API_COMPATIBILITY_FAILED && it.severity == DiagnosticSeverity.FAILURE } + + private fun assertCompatible( + base: String, + current: String, + ) { + val breakages = ApiDumpCompatibility.incompatibleDeclarations(base, current) + assertTrue(breakages.isEmpty(), "expected backward compatible, got: $breakages") + } + + private fun assertIncompatible( + base: String, + current: String, + ) { + val breakages = ApiDumpCompatibility.incompatibleDeclarations(base, current) + assertFalse(breakages.isEmpty(), "expected an incompatibility, got none") + } + + // ── stable: compatibility promise ───────────────────────────────────── + + @Test + fun `stable unchanged API passes`() { + val a = dump(":tramai-core", "public fun a ()V") + assertCompatible(a, a) + } + + @Test + fun `stable additive class and member pass`() { + val base = dump(":tramai-core", "public fun a ()V") + val current = + dump(":tramai-core", "public fun a ()V", "public fun b ()V") + + "public final class dev/tramai/core/Added {" + + "\n\tpublic fun x ()V\n}\n" + assertCompatible(base, current) + } + + @Test + fun `stable additive change passes through the verifier without any migration entry`() { + val base = mapOf(":tramai-core" to dump(":tramai-core", "public fun a ()V")) + val current = mapOf(":tramai-core" to dump(":tramai-core", "public fun a ()V", "public fun b ()V")) + assertTrue( + failures(base, current).isEmpty(), + "additive stable change must not need a migration entry", + ) + } + + @Test + fun `stable member removal fails`() { + val base = dump(":tramai-core", "public fun a ()V", "public fun b ()V") + val current = dump(":tramai-core", "public fun a ()V") + assertIncompatible(base, current) + assertTrue( + failures(mapOf(":tramai-core" to base), mapOf(":tramai-core" to current)).isNotEmpty(), + "removal must fail the gate", + ) + } + + @Test + fun `stable method signature change fails`() { + val base = dump(":tramai-core", "public fun a ()V") + val current = dump(":tramai-core", "public fun a (Ljava/lang/String;)V") + assertIncompatible(base, current) + } + + @Test + fun `stable class removal fails`() { + val base = dump(":tramai-core", "public fun a ()V") + val current = "public final class dev/tramai/core/Other {\n}\n" + assertIncompatible(base, current) + } + + @Test + fun `stable supertype removal fails`() { + val base = "public final class dev/tramai/core/Main : dev/tramai/core/Base, dev/tramai/core/Marker {\n}\n" + val current = "public final class dev/tramai/core/Main : dev/tramai/core/Base {\n}\n" + assertIncompatible(base, current) + } + + @Test + fun `stable supertype addition passes`() { + val base = "public final class dev/tramai/core/Main : dev/tramai/core/Base {\n}\n" + val current = "public final class dev/tramai/core/Main : dev/tramai/core/Base, dev/tramai/core/Marker {\n}\n" + assertCompatible(base, current) + } + + @Test + fun `stable visibility reduction fails`() { + val base = "public final class dev/tramai/core/Main {\n}\n" + val current = "protected final class dev/tramai/core/Main {\n}\n" + assertIncompatible(base, current) + } + + @Test + fun `stable breaking change fails even with an exact migration entry`() { + val base = mapOf(":tramai-core" to dump(":tramai-core", "public fun a ()V", "public fun b ()V")) + val current = mapOf(":tramai-core" to dump(":tramai-core", "public fun a ()V")) + val entry = migration(":tramai-core", base.getValue(":tramai-core"), current.getValue(":tramai-core"), "0.6.0") + assertTrue( + failures(base, current, listOf(entry)).isNotEmpty(), + "migration entries must never authorize stable breakage", + ) + } + + // ── preview/experimental: exact, release-bound transitions ──────────── + + @Test + fun `preview change without an ACTIVE entry fails`() { + val base = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V")) + val current = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V", "public fun b ()V")) + assertTrue(failures(base, current).isNotEmpty(), "preview drift needs an exact entry") + } + + @Test + fun `preview change passes with an ACTIVE entry targeting the release of a snapshot project version`() { + val base = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V")) + val current = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V", "public fun b ()V")) + val baseDump = base.getValue(":tramai-engine") + val currentDump = current.getValue(":tramai-engine") + val entry = migration(":tramai-engine", baseDump, currentDump, "0.7.0") + assertTrue( + failures(base, current, listOf(entry), projectVersion = "0.7.0-SNAPSHOT").isEmpty(), + "a 0.7.0 entry must authorize a transition on the 0.7.0-SNAPSHOT development line", + ) + } + + @Test + fun `preview change fails when the entry targets the wrong release`() { + val base = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V")) + val current = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V", "public fun b ()V")) + val baseDump = base.getValue(":tramai-engine") + val currentDump = current.getValue(":tramai-engine") + val entry = migration(":tramai-engine", baseDump, currentDump, "0.6.0") + val diagnostics = failures(base, current, listOf(entry), projectVersion = "0.7.0-SNAPSHOT") + assertTrue(diagnostics.isNotEmpty(), "an entry for another release must not authorize the transition") + assertTrue( + diagnostics.first().message.contains("targetVersion=0.7.0"), + "the diagnostic must name the release the project version belongs to, got: ${diagnostics.first().message}", + ) + } + + @Test + fun `preview change fails when the entry hash-bound to another transition`() { + val base = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V")) + val current = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V", "public fun b ()V")) + val stale = + ApiMigrationEntry( + module = ":tramai-engine", + fromSha256 = sha256("some other base"), + toSha256 = sha256("some other current"), + targetVersion = "0.7.0", + rationale = "Fixture transition.", + migration = "No consumer action.", + ) + assertTrue( + failures(base, current, listOf(stale), projectVersion = "0.7.0-SNAPSHOT").isNotEmpty(), + "a hash-mismatched entry must not authorize the transition", + ) + } + + @Test + fun `landed historical entry under a later development line is retained history and authorizes nothing`() { + val base = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V")) + val current = mapOf(":tramai-engine" to dump(":tramai-engine", "public fun a ()V", "public fun b ()V")) + // LANDED: the entry's target hash has landed, i.e. it is now the base of a further + // in-flight change — retained history that authorizes nothing. + val historical = + ApiMigrationEntry( + module = ":tramai-engine", + fromSha256 = sha256("0.5.0 dump"), + toSha256 = sha256(base.getValue(":tramai-engine")), + targetVersion = "0.5.0", + rationale = "Landed history.", + migration = "No consumer action.", + ) + val diagnostics = failures(base, current, listOf(historical), projectVersion = "0.7.0-SNAPSHOT") + assertTrue( + diagnostics.any { it.message.contains("changed without an exact hash-bound migration entry") }, + "a landed 0.5.0 entry must not authorize a live 0.7.0 transition, got: $diagnostics", + ) + assertTrue( + diagnostics.none { it.message.contains("is stale, orphaned") }, + "a landed entry must not itself be reported as stale, got: $diagnostics", + ) + } + + @Test + fun `landed entry for an unchanged module is accepted as history`() { + val dump = dump(":tramai-engine", "public fun a ()V") + val evidence = mapOf(":tramai-engine" to dump) + val historical = + ApiMigrationEntry( + module = ":tramai-engine", + fromSha256 = sha256(dump), + toSha256 = sha256(dump), + targetVersion = "0.5.0", + rationale = "Landed history.", + migration = "No consumer action.", + ) + assertTrue( + failures(evidence, evidence, listOf(historical), projectVersion = "0.7.0-SNAPSHOT").isEmpty(), + "a landed entry whose hash is the committed dump is retained history, not a failure", + ) + } + + // ── retained history is a CHAIN, not a set of connected edges ──────── + // + // The previous rule recognized one generation of history (an entry counted as landed when its + // `toSha256` was the current base hash). Once a module transitions a second time and that + // transition lands, the older entry was then permanently reported stale — which is exactly what + // merging the 0.7.1d transitions did to the real registry. Validity is reachability to the + // module's actual base hash; disconnected chains, ambiguous branches and cycles are not history. + + @Test + fun `two retained generations are both valid when the base is the chain terminal`() { + val module = ":tramai-engine" + val a = dump(module, "public fun a ()V") + val b = dump(module, "public fun a ()V", "public fun b ()V") + val c = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V") + + val diagnostics = + failures( + mapOf(module to c), + mapOf(module to c), + listOf(migration(module, a, b, "0.5.0"), migration(module, b, c, "0.5.0")), + ) + + assertTrue(diagnostics.isEmpty(), "A→B→C with base C must accept both entries, got: $diagnostics") + } + + @Test + fun `three retained generations are all valid when the base is the chain terminal`() { + val module = ":tramai-engine" + val a = dump(module, "public fun a ()V") + val b = dump(module, "public fun a ()V", "public fun b ()V") + val c = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V") + val d = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V", "public fun d ()V") + + val diagnostics = + failures( + mapOf(module to d), + mapOf(module to d), + listOf( + migration(module, a, b, "0.5.0"), + migration(module, b, c, "0.5.0"), + migration(module, c, d, "0.5.0"), + ), + ) + + assertTrue(diagnostics.isEmpty(), "A→B→C→D with base D must accept all three, got: $diagnostics") + } + + @Test + fun `retained history never authorizes the live transition`() { + val module = ":tramai-engine" + val a = dump(module, "public fun a ()V") + val b = dump(module, "public fun a ()V", "public fun b ()V") + val c = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V") + val d = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V", "public fun d ()V") + + // base C, current D, no entry for C→D: the chain A→B→C is history and authorizes nothing. + val diagnostics = + failures( + mapOf(module to c), + mapOf(module to d), + listOf(migration(module, a, b, "0.5.0"), migration(module, b, c, "0.5.0")), + projectVersion = "0.7.0-SNAPSHOT", + ) + + assertTrue( + diagnostics.any { it.message.contains("changed without an exact hash-bound migration entry") }, + "the live C→D transition must still require an exact entry, got: $diagnostics", + ) + assertTrue( + diagnostics.none { it.message.contains("is stale, orphaned") }, + "retained history must not be reported as stale, got: $diagnostics", + ) + } + + @Test + fun `an exact active entry for the live transition still authorizes it`() { + val module = ":tramai-engine" + val a = dump(module, "public fun a ()V") + val b = dump(module, "public fun a ()V", "public fun b ()V") + val c = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V") + val d = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V", "public fun d ()V") + + val diagnostics = + failures( + mapOf(module to c), + mapOf(module to d), + listOf( + migration(module, a, b, "0.5.0"), + migration(module, b, c, "0.5.0"), + migration(module, c, d, "0.7.0"), + ), + projectVersion = "0.7.0-SNAPSHOT", + ) + + assertTrue( + diagnostics.isEmpty(), + "an ACTIVE C→D entry must authorize with history behind it, got: $diagnostics", + ) + } + + @Test + fun `a chain that does not reach the base is not history`() { + val module = ":tramai-engine" + val a = dump(module, "public fun a ()V") + val b = dump(module, "public fun a ()V", "public fun b ()V") + val c = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V") + val x = dump(module, "public fun x ()V") + + // A→B→X: the edges connect to each other and to nothing that ever landed. + val diagnostics = + failures( + mapOf(module to c), + mapOf(module to c), + listOf(migration(module, a, b, "0.5.0"), migration(module, b, x, "0.5.0")), + ) + + assertEquals( + 2, + diagnostics.count { it.message.contains("is stale, orphaned") }, + "both entries of a disconnected chain must fail, got: $diagnostics", + ) + } + + @Test + fun `a branching history is ambiguous and the predecessor chain is rejected`() { + val module = ":tramai-engine" + val a = dump(module, "public fun a ()V") + val b = dump(module, "public fun a ()V", "public fun b ()V") + val c = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V") + val x = dump(module, "public fun x ()V") + + // B has two successors (C and X). A history is a sequence, not a graph: A→B cannot claim + // validity, while B→C stays valid because its terminal IS the base. + val diagnostics = + failures( + mapOf(module to c), + mapOf(module to c), + listOf( + migration(module, a, b, "0.5.0"), + migration(module, b, c, "0.5.0"), + migration(module, b, x, "0.5.0"), + ), + ) + val stale = diagnostics.filter { it.message.contains("is stale, orphaned") } + + assertEquals(2, stale.size, "A→B (ambiguous successor) and B→X (unreachable) must fail, got: $diagnostics") + assertTrue( + stale.none { it.message.contains(sha256(b)) && it.message.contains(sha256(c)) }, + "B→C terminates at the base and must remain valid, got: $diagnostics", + ) + } + + @Test + fun `a cyclic history is rejected`() { + val module = ":tramai-engine" + val a = dump(module, "public fun a ()V") + val b = dump(module, "public fun a ()V", "public fun b ()V") + val c = dump(module, "public fun a ()V", "public fun b ()V", "public fun c ()V") + + val diagnostics = + failures( + mapOf(module to c), + mapOf(module to c), + listOf(migration(module, a, b, "0.5.0"), migration(module, b, a, "0.5.0")), + ) + + assertEquals( + 2, + diagnostics.count { it.message.contains("is stale, orphaned") }, + "a cycle reaches no base and must fail for every entry, got: $diagnostics", + ) + } + + // ── version vocabulary ──────────────────────────────────────────────── + + @Test + fun `release version strips only the exact snapshot suffix`() { + assertTrue(TramaiVersions.releaseVersionOf("0.7.0-SNAPSHOT") == "0.7.0") + assertTrue(TramaiVersions.releaseVersionOf("0.7.0") == "0.7.0") + assertTrue(TramaiVersions.releaseVersionOf("0.7.0-RC1") == "0.7.0-RC1") + assertTrue(TramaiVersions.isSnapshot("0.7.0-SNAPSHOT")) + assertFalse(TramaiVersions.isSnapshot("0.7.0")) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/CanonicalProbeFunctionalTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/CanonicalProbeFunctionalTest.kt index cc308acc2..a599426f5 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/CanonicalProbeFunctionalTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CanonicalProbeFunctionalTest.kt @@ -316,11 +316,16 @@ class CanonicalProbeFunctionalTest { ) { copyFixtureToDir(tempDir) - // Run gradle compile tasks for lib-core using the system gradle + // Compile with the repository's own wrapper. 'gradle' is only guaranteed on CI's PATH + // (setup-gradle), so looking it up made this test un-runnable locally — and every CI + // command must have a local equivalent (.github/AGENTS.md rule 1). No PATH fallback: a + // half-installed wrapper must fail here with the real reason, not degrade into an opaque + // "gradle not found" later. + val launcher = installGradleWrapper(tempDir) val process = ProcessBuilder( listOf( - "gradle", + launcher, ":lib-core:compileJava", ":lib-core:compileTestJava", "--no-daemon", @@ -353,39 +358,49 @@ class CanonicalProbeFunctionalTest { // ── CanonicalGradleProbe End-to-End Tests ── /** - * Copies the real project's gradlew and gradle-wrapper.jar into the fixture. - * Uses the tramai.repositoryRoot system property (set by build.gradle.kts) - * to find the project root directly. Falls back to walking up from the fixture - * dir if the property isn't set. + * Copies the real project's gradlew and gradle-wrapper.jar into the fixture, returns the + * fixture's launcher path. Uses the tramai.repositoryRoot system property (set by + * build.gradle.kts) to find the project root directly. Falls back to walking up from the + * fixture dir if the property isn't set. + * + * Fails loud when the wrapper cannot be installed. A fixture left with only the script, or + * with nothing, previously produced an opaque failure at the call site; falling back to a PATH + * `gradle` would also re-introduce the CI-only dependency this helper exists to remove. */ - private fun installGradleWrapper(fixtureDir: File) { + private fun installGradleWrapper(fixtureDir: File): String { val repoRoot = System.getProperty("tramai.repositoryRoot") val projectRoot = if (repoRoot != null) { File(repoRoot) } else { // Fallback: walk up to find gradlew - var candidate = fixtureDir.parentFile ?: return - while (candidate.parentFile != null) { - if (File(candidate, "gradlew").isFile) break + var candidate = fixtureDir.parentFile ?: error("fixture dir has no parent: $fixtureDir") + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { candidate = candidate.parentFile!! } candidate } val srcGradlew = File(projectRoot, "gradlew") - if (srcGradlew.isFile) { - srcGradlew.copyTo(File(fixtureDir, "gradlew"), overwrite = true) - File(fixtureDir, "gradlew").setExecutable(true) - val jar = File(projectRoot, "gradle/wrapper/gradle-wrapper.jar") - if (jar.isFile) { - File(fixtureDir, "gradle/wrapper").mkdirs() - jar.copyTo( - File(fixtureDir, "gradle/wrapper/gradle-wrapper.jar"), - overwrite = true, - ) - } + check(srcGradlew.isFile) { + "repository wrapper script not found at $srcGradlew " + + "(tramai.repositoryRoot=${repoRoot ?: "unset"}, fixture=$fixtureDir)" + } + srcGradlew.copyTo(File(fixtureDir, "gradlew"), overwrite = true) + File(fixtureDir, "gradlew").setExecutable(true) + + val srcJar = File(projectRoot, "gradle/wrapper/gradle-wrapper.jar") + check(srcJar.isFile) { "repository wrapper jar not found at $srcJar" } + File(fixtureDir, "gradle/wrapper").mkdirs() + srcJar.copyTo(File(fixtureDir, "gradle/wrapper/gradle-wrapper.jar"), overwrite = true) + + val properties = File(fixtureDir, "gradle/wrapper/gradle-wrapper.properties") + check(properties.isFile) { + "fixture gradle-wrapper.properties missing at $properties: the copied wrapper would " + + "have no distribution to run" } + + return File(fixtureDir, "gradlew").absolutePath } /** diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateBaseRevisionTransportTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateBaseRevisionTransportTest.kt new file mode 100644 index 000000000..95723be1a --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateBaseRevisionTransportTest.kt @@ -0,0 +1,72 @@ +package dev.tramai.build.quality + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Base-revision transport for the digest-migration certificate ledger (Step 3b). + * + * M43/M47 are judged against the certificate ledger **as it exists in the base**, so the base side + * must be readable at a revision - not only from the working tree. This proves both directions + * against real repository history, using the same `git show`-into-a-temp-tree path the admissions, + * enrollments and baseline already use, so one authority snapshot carries the whole base context: + * population, classifications, enrollments, admissions and certificates. + * + * Both SHAs are real and immutable: `9ebe7b44` is the P1M merge that minted the certificate, and + * `64d05450` is the base P1M was proposed against, which predates the ledger entirely. + */ +class CertificateBaseRevisionTransportTest { + private val repositoryRoot = repositoryRoot() + + @Test + fun `the revision that minted the certificate exposes it through the authority snapshot`() { + val authority = MutationRatchetAuthorityLoader.load(repositoryRoot, P1M_MERGE) + + val certificate = authority.certificates.certificates.single() + assertEquals( + authority.admissions.admissions + .map { it.populationDigest } + .toSet(), + setOf(certificate.fromDigest), + ) + } + + @Test + fun `a base that predates the certificate ledger exposes no certificates`() { + val authority = MutationRatchetAuthorityLoader.load(repositoryRoot, PRE_P1M_BASE) + + assertTrue( + authority.certificates.certificates.isEmpty(), + "a base predating the ledger must expose no certificates, got " + + "${authority.certificates.certificates.map { it.fromDigest }}", + ) + // The same base still carries its authorizations: this isolates the certificate ledger + // rather than proving that an unrelated empty snapshot is empty. + assertTrue(authority.admissions.admissions.isNotEmpty()) + } + + /** + * The repository root, found by walking up to the `gradlew` marker (the idiom the other + * real-task tests use) rather than assuming a fixed depth below it. + */ + private fun repositoryRoot(): File { + var candidate = File(System.getProperty("user.dir")) + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { + candidate = candidate.parentFile!! + } + check(File(candidate, "gradlew").isFile) { + "no repository root (gradlew marker) found above ${System.getProperty("user.dir")}" + } + return candidate + } + + private companion object { + /** The P1M merge commit: the first revision whose tree contains the certificate ledger. */ + const val P1M_MERGE = "9ebe7b4430760ac313874750e7c5ac9bbe56ef1e" + + /** The base the P1M transition was proposed against: predates the certificate ledger. */ + const val PRE_P1M_BASE = "64d05450c285ecd9cf3635ca2935da816f649856" + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt new file mode 100644 index 000000000..f2efb9bf8 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateCustodyTransportTest.kt @@ -0,0 +1,197 @@ +package dev.tramai.build.quality + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Real-task custody transport for the certificate lifecycle (Step 3b, M44-M47). + * + * The committed certificate ledger is loaded through the real loader and driven through the + * certificate ceremony with the facts the verifier itself produces, so these are the real rules over + * real base authority rather than fixtures. The last case is the one the whole ordering exists for: + * a certificate may disappear only because a consumption was **independently proven** in the same + * transition - never because it was absent. + */ +class CertificateCustodyTransportTest { + private val repositoryRoot = repositoryRoot() + private val base = MutationAuthorityDigestCertificateLoader.load(repositoryRoot) + private val admissions = MutationPopulationAdmissionLoader.load(repositoryRoot) + private val real = base.certificates.single() + + private fun checks( + candidate: MutationAuthorityDigestCertificates, + validConsumptions: Set = emptySet(), + ) = MutationAuthorityDigestCertificateCeremony.checks( + base = base, + candidate = candidate, + baseSha = base.certificates.single().fromBaseSha, + validConsumptions = validConsumptions, + ) + + /** The facts the verifier produces for this base: same production site, same inputs. */ + private fun facts() = certifiedConsumptions(base, admissions, base.certificates.single().toDigest) + + @Test + fun `the committed certificate survives an unchanged transition`() { + val diagnostics = checks(base) + + assertTrue(diagnostics.isEmpty(), diagnostics.joinToString { it.message }) + } + + @Test + fun `the committed certificate cannot be dropped without a proven consumption`() { + val diagnostics = checks(MutationAuthorityDigestCertificates.NONE) + + assertTrue(diagnostics.single().message.contains("M47"), diagnostics.single().message) + } + + @Test + fun `dropping it is permitted once the consumption is proven from the real ledgers`() { + val diagnostics = checks(MutationAuthorityDigestCertificates.NONE, facts()) + + assertTrue(diagnostics.isEmpty(), diagnostics.joinToString { it.message }) + } + + @Test + fun `the committed ledger really does prove a consumption for its own target digest`() { + assertEquals(1, facts().size) + } + + // ── The remaining lifecycle discriminators, over the real committed ledger ── + + @Test + fun `T18 a retained certificate whose enforced payload was rewritten fails M46`() { + val rewritten = + MutationAuthorityDigestCertificate( + fromAlgorithm = real.fromAlgorithm, + fromDigest = real.fromDigest, + toAlgorithm = real.toAlgorithm, + toDigest = real.toDigest, + admissionSetDigest = real.admissionSetDigest, + fromBaseSha = real.fromBaseSha, + reason = "rewritten after it was minted", + ) + + val diagnostics = checks(MutationAuthorityDigestCertificates("1", listOf(rewritten))) + + assertTrue(diagnostics.single().message.contains("M46"), diagnostics.single().message) + } + + @Test + fun `T17 a certificate introduced against another base fails M45`() { + val foreign = foreignCertificate(fromDigest = "2".repeat(64), fromBaseSha = "b".repeat(40)) + + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = base, + candidate = MutationAuthorityDigestCertificates("1", listOf(real, foreign)), + baseSha = real.fromBaseSha, + ) + + assertTrue(diagnostics.single().message.contains("M45"), diagnostics.single().message) + } + + @Test + fun `T14 a certificate absent from the base cannot be consumed, however it is cited`() { + val foreign = foreignCertificate(fromDigest = "3".repeat(64), fromBaseSha = real.fromBaseSha) + + val verdict = + verifyCertificateConsumption( + certificate = foreign, + baseCertificates = base, + citedAdmissionPopulationDigest = foreign.fromDigest, + baseAdmissionIdentities = admissions.admissions.map { it.identity }, + freshAuthorityProjectionHash = foreign.toDigest, + ) + + assertTrue(verdict is CertificateConsumption.Invalid, "expected a refusal, got $verdict") + assertTrue( + (verdict as CertificateConsumption.Invalid).diagnostic.message.contains("M43"), + verdict.diagnostic.message, + ) + } + + /** A certificate with the real one's shape but a different provenance or source digest. */ + private fun foreignCertificate( + fromDigest: String, + fromBaseSha: String, + ) = MutationAuthorityDigestCertificate( + fromAlgorithm = real.fromAlgorithm, + fromDigest = fromDigest, + toAlgorithm = real.toAlgorithm, + toDigest = real.toDigest, + admissionSetDigest = real.admissionSetDigest, + fromBaseSha = fromBaseSha, + reason = "foreign certificate", + ) + + @Test + fun `T7 the real committed baseline cannot supply the authority context itself`() { + val committed = + ReportNormalizer.readJson( + File(repositoryRoot, "config/quality/mutation-baseline.json"), + MutationPopulationBaseline::class.java, + ) + + // The real task's authority context is derived from the fresh measurement: an identical + // committed population yields the proof, and its authority projection is the measured one. + val matched = MutationPopulationEvolutionProof.exactComparison(committed, committed) + assertTrue(matched.diagnostics.isEmpty(), matched.diagnostics.toString()) + assertTrue(matched.proof != null, "an identical measurement must produce a proof") + + // A baseline edited on the candidate side - here by one extra row - obtains no proof at all, + // so an edited ledger cannot put its own authority context in front of M34/M44/M47. + val tampered = + committed.copy( + mutants = committed.mutants + committed.mutants.first().copy(identity = "fabricated-identity"), + ) + assertEquals(null, MutationPopulationEvolutionProof.exactComparison(committed, tampered).proof) + } + + @Test + fun `the authority facts are produced exactly once and shared with every consumer`() { + val verifier = source("MutationRatchetVerifier.kt") + val ceremony = source("MutationPopulationAdmissionCeremony.kt") + + // One production event for the whole transition. Value-equal recomputation is not the same + // property as shared evidence: only one production site makes it impossible for M34, the + // admission lifecycle and M44/M47 to disagree about which consumptions were proven. + assertEquals( + 1, + Regex("admissionAuthority\\(").findAll(verifier).count(), + "the transition must produce its authority facts exactly once", + ) + // The ceremony receives the authority; it must never rebuild it. + assertEquals( + 0, + Regex("admissionAuthority\\(").findAll(ceremony).count(), + "the admission ceremony must not re-derive authority facts", + ) + // And the certificate lifecycle consumes that same local rather than its own copy. + assertTrue( + verifier.contains("validConsumptions = admissionAuthority.certifiedConsumptions"), + "M44/M47 must consume the same local authority M34 was judged on", + ) + } + + private val qualitySource = "build-logic/src/main/kotlin/dev/tramai/build/quality" + + private fun source(name: String) = File(repositoryRoot, "$qualitySource/$name").readText() + + /** + * The repository root, found by walking up to the `gradlew` marker (the idiom the other + * real-task tests use) rather than assuming a fixed depth below it. + */ + private fun repositoryRoot(): File { + var candidate = File(System.getProperty("user.dir")) + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { + candidate = candidate.parentFile!! + } + check(File(candidate, "gradlew").isFile) { + "no repository root (gradlew marker) found above ${System.getProperty("user.dir")}" + } + return candidate + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateLedgerTransportTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateLedgerTransportTest.kt new file mode 100644 index 000000000..09cd4dbfc --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/CertificateLedgerTransportTest.kt @@ -0,0 +1,116 @@ +package dev.tramai.build.quality + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Real-task authority transport for certificate consumption (Step 3b, T7 first). + * + * Per `build-logic/AGENTS.md` the rule-level tests prove semantics; this proves the transport: the + * **real** committed certificate ledger and the **real** committed admission ledger are loaded + * through the **real** loaders and fed to the real consumption verifier. Changing either committed + * file changes this outcome, which is the property a fixture can never establish. + * + * This is also the load path P1M reported as unwired: until now nothing loaded the canonical + * certificate ledger from the repository root. + */ +class CertificateLedgerTransportTest { + private val repositoryRoot = repositoryRoot() + private val certificates = MutationAuthorityDigestCertificateLoader.load(repositoryRoot) + private val admissions = MutationPopulationAdmissionLoader.load(repositoryRoot) + + private fun facts( + certificate: MutationAuthorityDigestCertificate, + freshAuthorityProjectionHash: String = certificate.toDigest, + ) = verifyCertificateConsumption( + certificate = certificate, + baseCertificates = certificates, + citedAdmissionPopulationDigest = certificate.fromDigest, + baseAdmissionIdentities = admissions.admissions.map { it.identity }, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + ) + + @Test + fun `the canonical certificate ledger loads through the real loader`() { + assertEquals(1, certificates.certificates.size) + } + + @Test + fun `the committed certificate binds exactly the committed admission set`() { + val certificate = certificates.certificates.single() + + assertEquals( + MutationAuthorityDigestCertificates.admissionSetDigest(admissions.admissions.map { it.identity }), + certificate.admissionSetDigest, + ) + } + + @Test + fun `the committed certificate translates the digest the committed admissions are bound to`() { + val certificate = certificates.certificates.single() + + assertEquals( + setOf(certificate.fromDigest), + admissions.admissions.map { it.populationDigest }.toSet(), + ) + } + + @Test + fun `real base authority produces a valid consumption`() { + val result = facts(certificates.certificates.single()) + + assertTrue(result is CertificateConsumption.Valid, "expected a valid consumption, got $result") + } + + @Test + fun `a certificate absent from the real base ledger cannot be consumed`() { + val real = certificates.certificates.single() + val absent = + MutationAuthorityDigestCertificate( + fromAlgorithm = real.fromAlgorithm, + fromDigest = "0".repeat(64), + toAlgorithm = real.toAlgorithm, + toDigest = real.toDigest, + admissionSetDigest = real.admissionSetDigest, + fromBaseSha = real.fromBaseSha, + reason = real.reason, + ) + + val result = facts(absent) + + assertTrue(result is CertificateConsumption.Invalid, "expected a refusal, got $result") + assertTrue( + (result as CertificateConsumption.Invalid).diagnostic.message.contains("M43"), + result.diagnostic.message, + ) + } + + @Test + fun `real base authority refuses a target digest this transition did not measure`() { + val result = facts(certificates.certificates.single(), freshAuthorityProjectionHash = "0".repeat(64)) + + assertTrue(result is CertificateConsumption.Invalid, "expected a refusal, got $result") + assertTrue( + (result as CertificateConsumption.Invalid).diagnostic.message.contains("M40"), + result.diagnostic.message, + ) + } + + /** + * The repository root, found by walking up to the `gradlew` marker (the same idiom + * `CanonicalProbeFunctionalTest` and the Step-2 loader test use) rather than assuming this test + * starts a fixed number of levels below it. + */ + private fun repositoryRoot(): File { + var candidate = File(System.getProperty("user.dir")) + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { + candidate = candidate.parentFile!! + } + check(File(candidate, "gradlew").isFile) { + "no repository root (gradlew marker) found above ${System.getProperty("user.dir")}" + } + return candidate + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/JUnitTestSignatureCiWiringTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/JUnitTestSignatureCiWiringTest.kt new file mode 100644 index 000000000..b98c2a83d --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/JUnitTestSignatureCiWiringTest.kt @@ -0,0 +1,103 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import java.io.File +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * CI wiring for the JUnit test-signature authority. + * + * The 0.7.1d review found that a legal-looking `@Test fun x() = runBlocking { ... }` could be + * silently discarded by JUnit while `verifyJUnitTestSignatures` was reachable only through the + * local `verifyPr` path, so nothing remote stopped that class of defect. This proves the + * authority runs in the `quality` job. + * + * The assertion is entry-scoped and conjunctive — a matrix entry named + * `junit-test-signatures` AND `gates: verifyJUnitTestSignatures` inside that same entry — because + * a workflow-wide search for the task name would also pass if the task were mentioned in some + * unrelated job or comment. + */ +class JUnitTestSignatureCiWiringTest { + @Test + fun `quality job runs the JUnit test-signature authority`() { + assertJUnitSignatureWiring(ciWorkflow()) + } + + @Test + fun `discriminator associating a different gate with the entry fails`() { + val mutated = + ciWorkflow().replace( + "gates: verifyJUnitTestSignatures", + "gates: verifySomethingElse", + ) + val error = assertFailsWith { assertJUnitSignatureWiring(mutated) } + assertTrue(error.message!!.contains("verifyJUnitTestSignatures")) + } + + @Test + fun `discriminator dropping the matrix entry fails`() { + val mutated = ciWorkflow().replace(MATRIX_ENTRY, "") + val error = assertFailsWith { assertJUnitSignatureWiring(mutated) } + assertTrue(error.message!!.contains("junit-test-signatures")) + } + + @Test + fun `discriminator moving the gate into another job fails`() { + val content = ciWorkflow() + val moved = + content + .replace(MATRIX_ENTRY, "") + .replaceFirst(" contract-tests:", " contract-tests:\n" + MATRIX_ENTRY.trimEnd()) + val error = assertFailsWith { assertJUnitSignatureWiring(moved) } + assertTrue(error.message!!.contains("junit-test-signatures")) + } + + private fun ciWorkflow(): String { + val repoRoot = File(System.getProperty("tramai.repositoryRoot")) + val workflow = File(repoRoot, ".github/workflows/ci.yml") + assertTrue(workflow.isFile, "CI workflow file must exist: ${workflow.absolutePath}") + return workflow.readText() + } + + private fun assertJUnitSignatureWiring(content: String) { + val entry = extractMatrixEntry(extractJob(content, "quality"), "junit-test-signatures") + assertTrue( + entry.contains("gates: verifyJUnitTestSignatures"), + "quality matrix entry 'junit-test-signatures' must run verifyJUnitTestSignatures, got:\n$entry", + ) + } + + /** Returns the block of [jobName] up to the next top-level job. */ + private fun extractJob( + content: String, + jobName: String, + ): String { + val marker = " $jobName:" + val start = content.indexOf("\n" + marker) + assertTrue(start != -1, "ci.yml must declare job '$jobName'") + val after = content.substring(start + 1 + marker.length) + val next = Regex("\n [a-zA-Z0-9_-]+:").find(after) + val end = next?.range?.first ?: after.length + return content.substring(start + 1, start + 1 + marker.length + end) + } + + /** Returns the matrix include-entry named [entryName] up to the next entry. */ + private fun extractMatrixEntry( + content: String, + entryName: String, + ): String { + val marker = "- name: $entryName" + val start = content.indexOf(marker) + assertTrue(start != -1, "matrix must declare entry '$entryName'") + val after = content.substring(start + marker.length) + val next = Regex("\n *- name: ").find(after) + val end = next?.range?.first ?: after.length + return content.substring(start, start + marker.length + end) + } + + private companion object { + const val MATRIX_ENTRY = + " - name: junit-test-signatures\n gates: verifyJUnitTestSignatures\n" + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifierTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifierTest.kt index 4ce1afe1b..eb9840c12 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifierTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/JUnitTestSignatureVerifierTest.kt @@ -1,18 +1,20 @@ package dev.tramai.build.quality -import java.nio.file.Files -import kotlin.test.assertEquals -import kotlin.test.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertTrue class JUnitTestSignatureVerifierTest { - @TempDir lateinit var tempDir: Path - private fun writeTest(fileName: String, content: String): Path { + private fun writeTest( + fileName: String, + content: String, + ): Path { val dir = Files.createDirectories(tempDir.resolve("src/test/kotlin/demo")) val file = dir.resolve(fileName) Files.writeString(file, content) @@ -26,145 +28,154 @@ class JUnitTestSignatureVerifierTest { @Test fun `rejects expression body ending in a chainable assertion`() { - val violations = violationsFor( - """ - import kotlinx.coroutines.runBlocking - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test - fun `reject me`() = runBlocking { - assertThat(true).isTrue() + class SampleTest { + @Test + fun `reject me`() = runBlocking { + assertThat(true).isTrue() + } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") assertEquals("`reject me`", violations.single().functionName) } @Test fun `rejects bare assertion expression body`() { - val violations = violationsFor( - """ - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test - fun `reject me`() = assertThat(1).isEqualTo(1) - } - """.trimIndent(), - ) + class SampleTest { + @Test + fun `reject me`() = assertThat(1).isEqualTo(1) + } + """.trimIndent(), + ) assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") } @Test fun `rejects assertThatThrownBy expression body`() { - val violations = violationsFor( - """ - import org.assertj.core.api.Assertions.assertThatThrownBy + val violations = + violationsFor( + """ + import org.assertj.core.api.Assertions.assertThatThrownBy - class SampleTest { - @Test - fun `reject me`() = assertThatThrownBy { - throw IllegalStateException("boom") - }.isInstanceOf(IllegalStateException::class.java) - } - """.trimIndent(), - ) + class SampleTest { + @Test + fun `reject me`() = assertThatThrownBy { + throw IllegalStateException("boom") + }.isInstanceOf(IllegalStateException::class.java) + } + """.trimIndent(), + ) assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") } @Test fun `accepts block-bodied coroutine test`() { - val violations = violationsFor( - """ - import kotlinx.coroutines.runBlocking - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test - fun `accept me`() { - runBlocking { - assertThat(true).isTrue() + class SampleTest { + @Test + fun `accept me`() { + runBlocking { + assertThat(true).isTrue() + } } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") } @Test fun `accepts runBlocking with explicit Unit type argument`() { - val violations = violationsFor( - """ - import kotlinx.coroutines.runBlocking - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test - fun `accept me`() = runBlocking { - assertThat(true).isTrue() + class SampleTest { + @Test + fun `accept me`() = runBlocking { + assertThat(true).isTrue() + } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") } @Test fun `accepts explicit Unit return type`() { - val violations = violationsFor( - """ - class SampleTest { - @Test - fun `accept me`(): Unit = runBlocking { - assertThat(true).isTrue() + val violations = + violationsFor( + """ + class SampleTest { + @Test + fun `accept me`(): Unit = runBlocking { + assertThat(true).isTrue() + } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") } @Test fun `rejects same-line expression body`() { - val violations = violationsFor( - """ - import org.assertj.core.api.Assertions.assertThatThrownBy + val violations = + violationsFor( + """ + import org.assertj.core.api.Assertions.assertThatThrownBy - class SampleTest { - @Test fun `reject me`() = assertThatThrownBy { - throw IllegalStateException("boom") - }.isInstanceOf(IllegalStateException::class.java) - } - """.trimIndent(), - ) + class SampleTest { + @Test fun `reject me`() = assertThatThrownBy { + throw IllegalStateException("boom") + }.isInstanceOf(IllegalStateException::class.java) + } + """.trimIndent(), + ) assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") assertEquals("`reject me`", violations.single().functionName) } @Test fun `accepts same-line runTest expression body`() { - val violations = violationsFor( - """ - import kotlinx.coroutines.test.runTest - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import kotlinx.coroutines.test.runTest + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test fun `accept me`() = runTest { - assertThat(true).isTrue() + class SampleTest { + @Test fun `accept me`() = runTest { + assertThat(true).isTrue() + } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") } @Test fun `ignores expression bodies inside raw string fixtures`() { - val content = """ + val content = + """ import org.assertj.core.api.Assertions.assertThat class SampleTest { @@ -186,80 +197,249 @@ class JUnitTestSignatureVerifierTest { @Test fun `rejects multiline parameter signature`() { - val violations = violationsFor( - """ - import kotlinx.coroutines.runBlocking - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test - fun `reject me`( - someParameter: String, - ) = runBlocking { - assertThat(someParameter).isEqualTo("x") + class SampleTest { + @Test + fun `reject me`( + someParameter: String, + ) = runBlocking { + assertThat(someParameter).isEqualTo("x") + } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") assertEquals("`reject me`", violations.single().functionName) } @Test fun `rejects multiline annotation between Test and fun`() { - val violations = violationsFor( - """ - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test - @SomeAnnotation( - value = "x", - ) - fun `reject me`() = assertThat(true).isTrue() - } - """.trimIndent(), - ) + class SampleTest { + @Test + @SomeAnnotation( + value = "x", + ) + fun `reject me`() = assertThat(true).isTrue() + } + """.trimIndent(), + ) assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") assertEquals("`reject me`", violations.single().functionName) } @Test fun `accepts multiline parameter signature with block body`() { - val violations = violationsFor( - """ - import kotlinx.coroutines.runBlocking - import org.assertj.core.api.Assertions.assertThat + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat - class SampleTest { - @Test - fun `accept me`( - someParameter: String, - ) { - runBlocking { - assertThat(someParameter).isEqualTo("x") + class SampleTest { + @Test + fun `accept me`( + someParameter: String, + ) { + runBlocking { + assertThat(someParameter).isEqualTo("x") + } } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) + assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") + } + + @Test + fun `accepts runBlocking with explicit Unit type argument on a continuation line`() { + // Regression: the head of a multi-line expression body was read from the line + // containing `=` only, so an empty head was rejected before the next line could be + // appended. This is the documented Unit-safe form and must be accepted. + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat + + class SampleTest { + @Test + fun `accept me`() = + runBlocking { + assertThat(true).isTrue() + } + } + """.trimIndent(), + ) assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") } + @Test + fun `accepts explicit Unit return type on a continuation line`() { + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat + + class SampleTest { + @Test + fun `accept me`(): Unit = + runBlocking { + assertThat(true).isTrue() + } + } + """.trimIndent(), + ) + assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") + } + + @Test + fun `accepts bare Unit on a continuation line`() { + val violations = + violationsFor( + """ + class SampleTest { + @Test + fun `accept me`() = + Unit + } + """.trimIndent(), + ) + assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") + } + + @Test + fun `accepts multiline signature followed by Unit-safe head on a continuation line`() { + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat + import org.junit.jupiter.api.Test + + class SampleTest { + @Test + fun `accept me`( + someParameter: String, + ) = + runBlocking { + assertThat(someParameter).isEqualTo("x") + } + } + """.trimIndent(), + ) + assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") + } + + @Test + fun `accepts runTest on a continuation line`() { + val violations = + violationsFor( + """ + import kotlinx.coroutines.test.runTest + import org.assertj.core.api.Assertions.assertThat + + class SampleTest { + @Test + fun `accept me`() = + runTest { + assertThat(true).isTrue() + } + } + """.trimIndent(), + ) + assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") + } + + @Test + fun `rejects continuation-line expression body ending in a chainable assertion`() { + // The critical adversarial case: waiting for a continuation line must not become a + // fail-open. A non-Unit head on the next line is still a violation. + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat + + class SampleTest { + @Test + fun `reject me`() = + runBlocking { + assertThat(true).isTrue() + } + } + """.trimIndent(), + ) + assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") + assertEquals("`reject me`", violations.single().functionName) + } + + @Test + fun `rejects continuation-line bare assertion expression body`() { + val violations = + violationsFor( + """ + import org.assertj.core.api.Assertions.assertThat + + class SampleTest { + @Test + fun `reject me`() = + assertThat(1).isEqualTo(1) + } + """.trimIndent(), + ) + assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") + assertEquals("`reject me`", violations.single().functionName) + } + + @Test + fun `rejects multiline parameter signature with non-Unit head on a continuation line`() { + val violations = + violationsFor( + """ + import kotlinx.coroutines.runBlocking + import org.assertj.core.api.Assertions.assertThat + + class SampleTest { + @Test + fun `reject me`( + someParameter: String, + ) = + runBlocking { + assertThat(someParameter).isEqualTo("x") + } + } + """.trimIndent(), + ) + assertTrue(violations.size == 1, "expected 1 violation, got ${violations.size}") + assertEquals("`reject me`", violations.single().functionName) + } + @Test fun `ignores non-test expression-bodied functions`() { - val violations = violationsFor( - """ - class SampleTest { - private fun helper() = assertThat(1).isEqualTo(1) + val violations = + violationsFor( + """ + class SampleTest { + private fun helper() = assertThat(1).isEqualTo(1) - @Test - fun `accept me`() { - helper() + @Test + fun `accept me`() { + helper() + } } - } - """.trimIndent(), - ) + """.trimIndent(), + ) assertTrue(violations.isEmpty(), "expected no violations, got ${violations.map { it.functionName }}") } } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScannerTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScannerTest.kt index 22ef273db..109706648 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScannerTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/KotlinCancellationCatchScannerTest.kt @@ -12,87 +12,104 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class KotlinCancellationCatchScannerTest { - @Test fun `detects catch (e Exception) syntax`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - logError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + logError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch (e: Exception)") assertEquals("Exception", findings.first().catchType) } @Test fun `detects catch (_ Throwable) syntax`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (_: Throwable) { - // swallow silently + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (_: Throwable) { + // swallow silently + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch (_: Throwable)") assertEquals("Throwable", findings.first().catchType) } @Test fun `detects runCatching`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - runCatching { - doSomething() + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + runCatching { + doSomething() + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find runCatching") } @Test fun `classifies explicit cancellation rethrow as accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - if (e is CancellationException) throw e - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + if (e is CancellationException) throw e + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("accepted", findings.first().risk, - "Cancellation rethrow inside nested if-block should be accepted") + assertEquals( + "accepted", + findings.first().risk, + "Cancellation rethrow inside nested if-block should be accepted", + ) } @Test fun `classifies suspend catch without rethrow as critical`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty()) assertEquals("critical", findings.first().risk) } @@ -100,90 +117,114 @@ class KotlinCancellationCatchScannerTest { @Test fun `genuine multiline catch preserves suspend position`() { // A real multiline catch declaration split across three lines - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch ( - e: Exception - ) { - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch ( + e: Exception + ) { + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find multiline catch") assertEquals("Exception", findings.first().catchType) - assertEquals("riskyOperation", findings.first().function, - "Should identify enclosing function despite multiline catch") - assertEquals("critical", findings.first().risk, - "Suspend catch without rethrow should be critical even when multiline") + assertEquals( + "riskyOperation", + findings.first().function, + "Should identify enclosing function despite multiline catch", + ) + assertEquals( + "critical", + findings.first().risk, + "Suspend catch without rethrow should be critical even when multiline", + ) } @Test fun `nested cancellation rethrow is accepted`() { // CancellationException check and throw on separate lines in nested block - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - if (e is java.util.concurrent.CancellationException) { - throw e + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + if (e is java.util.concurrent.CancellationException) { + throw e + } + handleError(e) } - handleError(e) } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("accepted", findings.first().risk, - "Nested cancellation rethrow on separate lines should be accepted") + assertEquals( + "accepted", + findings.first().risk, + "Nested cancellation rethrow on separate lines should be accepted", + ) } @Test fun `handles qualified exception names`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: java.lang.IllegalArgumentException) { - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: java.lang.IllegalArgumentException) { + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isEmpty(), "Qualified non-base exception should not match broad catch pattern") } @Test fun `runCatching outside suspend is medium risk`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - fun regularFunction() { - runCatching { - doSomething() + val findings = + KotlinCancellationCatchScanner.scan( + """ + fun regularFunction() { + runCatching { + doSomething() + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty()) assertEquals("medium", findings.first().risk, "runCatching outside suspend should be medium") } @Test fun `catch in string literal is not detected`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - val msg = "catch (e: Exception)" - doSomething() - } - """.trimIndent(), "test", "Test.kt" - ) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + val msg = "catch (e: Exception)" + doSomething() + } + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isEmpty(), "String literal should not produce a finding") } @@ -191,207 +232,328 @@ class KotlinCancellationCatchScannerTest { @Test fun `cancellation check followed by throw DomainException is not accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - if (e is CancellationException) { - auditCancellation(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + if (e is CancellationException) { + auditCancellation(e) + } + throw DomainOperationException(e) } - throw DomainOperationException(e) } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("high", findings.first().risk, - "Cancellation check followed by DomainException throw should be high, not accepted") + assertEquals( + "high", + findings.first().risk, + "Cancellation check followed by DomainException throw should be high, not accepted", + ) } @Test fun `cancellation check followed by unrelated throw is not accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - if (e is CancellationException) { - logCancellation(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + if (e is CancellationException) { + logCancellation(e) + } + throw IllegalStateException("unexpected") } - throw IllegalStateException("unexpected") } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("high", findings.first().risk, - "Cancellation check followed by unrelated throw should be high, not accepted") + assertEquals( + "high", + findings.first().risk, + "Cancellation check followed by unrelated throw should be high, not accepted", + ) } @Test fun `identifier containing rethrow does not trigger rethrow detection`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - if (e is CancellationException) { - rethrowPolicy.record(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + if (e is CancellationException) { + rethrowPolicy.record(e) + } + handleError(e) } - handleError(e) } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "RethrowPolicy reference should not be accepted as cancellation rethrow") + assertNotEquals( + "accepted", + findings.first().risk, + "RethrowPolicy reference should not be accepted as cancellation rethrow", + ) } @Test fun `CancellationException in comment is not treated as rethrow check`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - // need to handle CancellationException here - throw DomainException(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + // need to handle CancellationException here + throw DomainException(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("high", findings.first().risk, - "CancellationException in comment should not elevate to accepted") + assertEquals( + "high", + findings.first().risk, + "CancellationException in comment should not elevate to accepted", + ) } @Test fun `CancellationException in string is not treated as rethrow check`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - val msg = "CancellationException occurred" - throw DomainException(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + val msg = "CancellationException occurred" + throw DomainException(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("high", findings.first().risk, - "CancellationException in string should not elevate to accepted") + assertEquals( + "high", + findings.first().risk, + "CancellationException in string should not elevate to accepted", + ) } @Test fun `rethrowIfCancellation extension is recognized as accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - e.rethrowIfCancellation() - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + e.rethrowIfCancellation() + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("accepted", findings.first().risk, - "rethrowIfCancellation() should be recognized as accepted risk") + assertEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() should be recognized as accepted risk", + ) } @Test fun `rethrowIfCancellation with different variable name is recognized`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (error: Throwable) { - error.rethrowIfCancellation() - throw ProviderException(error) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (error: Throwable) { + error.rethrowIfCancellation() + throw ProviderException(error) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("accepted", findings.first().risk, - "rethrowIfCancellation() with different variable name should be accepted") + assertEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() with different variable name should be accepted", + ) } @Test fun `rethrowIfCancellation with trailing whitespace in parens is recognized`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - e.rethrowIfCancellation( ) - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + e.rethrowIfCancellation( ) + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertEquals("accepted", findings.first().risk, - "rethrowIfCancellation() with whitespace should be accepted") + assertEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() with whitespace should be accepted", + ) } @Test fun `different variable rethrowIfCancellation is not accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - val other = RuntimeException() - other.rethrowIfCancellation() - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + val other = RuntimeException() + other.rethrowIfCancellation() + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "rethrowIfCancellation() on different variable should NOT be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() on different variable should NOT be accepted", + ) } @Test fun `throwing caught variable in unrelated condition is not accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun run() { - try { - work() - } catch (e: Exception) { - if (e is CancellationException) audit(e) - if (shouldRethrow()) throw e - handle(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun run() { + try { + work() + } catch (e: Exception) { + if (e is CancellationException) audit(e) + if (shouldRethrow()) throw e + handle(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "throw e outside cancellation branch should not be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "throw e outside cancellation branch should not be accepted", + ) } // ── Regression tests requested in PR #203 review ── + /** Writes [sourceCode] as a discoverable module in [tempDir] and returns the two scans. */ + private fun scanBothContexts( + tempDir: Path, + sourceCode: String, + ): Pair, List> { + val srcDir = tempDir.resolve("src/main/kotlin").toFile() + srcDir.mkdirs() + File(srcDir, "TestModule.kt").writeText(sourceCode) + + File(tempDir.toFile(), "build.gradle.kts").writeText("") + File(tempDir.toFile(), "settings.gradle.kts").writeText("""include("test-module")""") + val moduleDir = tempDir.resolve("test-module").toFile() + moduleDir.mkdirs() + File(moduleDir, "build.gradle.kts").writeText("") + val moduleSrcDir = File(moduleDir, "src/main/kotlin") + moduleSrcDir.mkdirs() + File(moduleSrcDir, "TestModule.kt").writeText(sourceCode) + + val dirFindings = + CancellationCatchInventory(MeasurementContext.fromDirectory(tempDir.toFile())) + .inventory() + .filter { it.file.contains("TestModule.kt") } + .sortedBy { "${it.function}::${it.catchType}" } + val directFindings = + KotlinCancellationCatchScanner + .scan(sourceCode, "test-module", "test-module/src/main/kotlin/TestModule.kt") + .sortedBy { "${it.function}::${it.catchType}" } + return dirFindings to directFindings + } + + /** Directory discovery and a direct scan must agree, and agree on the expected scenarios. */ + private fun assertDirectoryScanMatches( + tempDir: Path, + sourceCode: String, + expected: List>, + ) { + val (dirFindings, directFindings) = scanBothContexts(tempDir, sourceCode) + assertEquals( + expected, + directFindings.map { it.function to it.catchType }, + "Direct scan should find exactly the expected scenarios", + ) + assertEquals( + directFindings.size, + dirFindings.size, + "Directory-mode inventory should find same number of catches as direct scan", + ) + for (i in directFindings.indices) { + assertEquals(directFindings[i].function, dirFindings[i].function) + assertEquals(directFindings[i].catchType, dirFindings[i].catchType) + assertEquals( + directFindings[i].risk, + dirFindings[i].risk, + "Risk mismatch for ${directFindings[i].function}::${directFindings[i].catchType}", + ) + } + } + @Test - fun `project and directory contexts produce identical findings`(@TempDir tempDir: Path) { - val sourceCode = """ + fun `directory discovery matches direct scan for an accepted cancellation rethrow`( + @TempDir tempDir: Path, + ) { + val sourceCode = + """ suspend fun riskyOperation() { try { doSomething() @@ -402,7 +564,17 @@ class KotlinCancellationCatchScannerTest { handleError(e) } } - + """.trimIndent() + + assertDirectoryScanMatches(tempDir, sourceCode, listOf("riskyOperation" to "Exception")) + } + + @Test + fun `directory discovery matches direct scan for a broad catch without rethrow`( + @TempDir tempDir: Path, + ) { + val sourceCode = + """ fun regularFunction() { try { doSomething() @@ -410,52 +582,25 @@ class KotlinCancellationCatchScannerTest { logError(e) } } - + """.trimIndent() + + assertDirectoryScanMatches(tempDir, sourceCode, listOf("regularFunction" to "Exception")) + } + + @Test + fun `directory discovery matches direct scan for runCatching in suspend`( + @TempDir tempDir: Path, + ) { + val sourceCode = + """ suspend fun anotherSuspend() { runCatching { doSomething() } } - """.trimIndent() - - // Create a minimal module structure in temp dir - val srcDir = tempDir.resolve("src/main/kotlin").toFile() - srcDir.mkdirs() - val sourceFile = File(srcDir, "TestModule.kt") - sourceFile.writeText(sourceCode) - - // Create a minimal build file so directory discovery finds it - File(tempDir.toFile(), "build.gradle.kts").writeText("") - // Create minimal settings - File(tempDir.toFile(), "settings.gradle.kts").writeText( - """include("test-module")""" - ) - val moduleDir = tempDir.resolve("test-module").toFile() - moduleDir.mkdirs() - File(moduleDir, "build.gradle.kts").writeText("") - val moduleSrcDir = File(moduleDir, "src/main/kotlin") - moduleSrcDir.mkdirs() - File(moduleSrcDir, "TestModule.kt").writeText(sourceCode) - - // Scan via CancellationCatchInventory with MeasurementContext.fromDirectory - val dirCtx = MeasurementContext.fromDirectory(tempDir.toFile()) - val dirFindings = CancellationCatchInventory(dirCtx).inventory() - .filter { it.file.contains("TestModule.kt") } - .sortedBy { "${it.function}::${it.catchType}" } + """.trimIndent() - // Direct scan of the same source (bypassing inventory, pure scanner) - val directFindings = KotlinCancellationCatchScanner.scan( - sourceCode, "test-module", "test-module/src/main/kotlin/TestModule.kt" - ).sortedBy { "${it.function}::${it.catchType}" } - - assertEquals(directFindings.size, dirFindings.size, - "Directory-mode inventory should find same number of catches as direct scan") - for (i in directFindings.indices) { - assertEquals(directFindings[i].function, dirFindings[i].function) - assertEquals(directFindings[i].catchType, dirFindings[i].catchType) - assertEquals(directFindings[i].risk, dirFindings[i].risk, - "Risk mismatch for ${directFindings[i].function}::${directFindings[i].catchType}") - } + assertDirectoryScanMatches(tempDir, sourceCode, listOf("anotherSuspend" to "runCatching")) } // ─── Deviation scope matching tests ─── @@ -502,25 +647,45 @@ class KotlinCancellationCatchScannerTest { @Test fun `file scope requires module AND path match`() { val scope = DeviationParser.DeviationScope(":tramai-engine", "src/main/Foo.kt", null, isWildcard = false) - assertTrue(scope.covers(DeviationParser.FindingScope( - ":tramai-engine", "src/main/Foo.kt", null))) + assertTrue( + scope.covers( + DeviationParser.FindingScope(":tramai-engine", "src/main/Foo.kt", null), + ), + ) // Wrong module - assertTrue(!scope.covers(DeviationParser.FindingScope( - ":tramai-core", "src/main/Foo.kt", null))) + assertTrue( + !scope.covers( + DeviationParser.FindingScope(":tramai-core", "src/main/Foo.kt", null), + ), + ) // Wrong path - assertTrue(!scope.covers(DeviationParser.FindingScope( - ":tramai-engine", "src/main/Bar.kt", null))) + assertTrue( + !scope.covers( + DeviationParser.FindingScope(":tramai-engine", "src/main/Bar.kt", null), + ), + ) } @Test fun `declaration scope requires module path and declaration`() { - val scope = DeviationParser.DeviationScope( - ":tramai-engine", "src/main/Foo.kt", "TramaiInvocationHandler", isWildcard = false) - assertTrue(scope.covers(DeviationParser.FindingScope( - ":tramai-engine", "src/main/Foo.kt", "TramaiInvocationHandler"))) + val scope = + DeviationParser.DeviationScope( + ":tramai-engine", + "src/main/Foo.kt", + "TramaiInvocationHandler", + isWildcard = false, + ) + assertTrue( + scope.covers( + DeviationParser.FindingScope(":tramai-engine", "src/main/Foo.kt", "TramaiInvocationHandler"), + ), + ) // Wrong declaration - assertTrue(!scope.covers(DeviationParser.FindingScope( - ":tramai-engine", "src/main/Foo.kt", "OtherHandler"))) + assertTrue( + !scope.covers( + DeviationParser.FindingScope(":tramai-engine", "src/main/Foo.kt", "OtherHandler"), + ), + ) } // ─── Deviation parser scope grammar tests ─── @@ -572,10 +737,11 @@ class KotlinCancellationCatchScannerTest { val committedCounts = committed.groupBy { it }.mapValues { it.value.size } val currentCounts = current.groupBy { it }.mapValues { it.value.size } - val addedCounts = currentCounts.mapNotNull { (key, currentCount) -> - val delta = currentCount - (committedCounts[key] ?: 0) - if (delta > 0) key to delta else null - } + val addedCounts = + currentCounts.mapNotNull { (key, currentCount) -> + val delta = currentCount - (committedCounts[key] ?: 0) + if (delta > 0) key to delta else null + } // id2 went from 2 to 3 occurrences assertEquals(1, addedCounts.size) @@ -591,10 +757,11 @@ class KotlinCancellationCatchScannerTest { val committedCounts = committed.groupBy { it }.mapValues { it.value.size } val currentCounts = current.groupBy { it }.mapValues { it.value.size } - val addedCounts = currentCounts.mapNotNull { (key, currentCount) -> - val delta = currentCount - (committedCounts[key] ?: 0) - if (delta > 0) key to delta else null - } + val addedCounts = + currentCounts.mapNotNull { (key, currentCount) -> + val delta = currentCount - (committedCounts[key] ?: 0) + if (delta > 0) key to delta else null + } assertEquals(0, addedCounts.size) } @@ -607,10 +774,11 @@ class KotlinCancellationCatchScannerTest { val committedCounts = committed.groupBy { it }.mapValues { it.value.size } val currentCounts = current.groupBy { it }.mapValues { it.value.size } - val addedCounts = currentCounts.mapNotNull { (key, currentCount) -> - val delta = currentCount - (committedCounts[key] ?: 0) - if (delta > 0) key to delta else null - } + val addedCounts = + currentCounts.mapNotNull { (key, currentCount) -> + val delta = currentCount - (committedCounts[key] ?: 0) + if (delta > 0) key to delta else null + } assertEquals(2, addedCounts.size) // id2 and id3 are new assertEquals(1, addedCounts.find { it.first == "id2" }?.second) @@ -621,137 +789,179 @@ class KotlinCancellationCatchScannerTest { @Test fun `rethrowIfCancellation after side effect is NOT accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - logError(e) - e.rethrowIfCancellation() - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + logError(e) + e.rethrowIfCancellation() + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "rethrowIfCancellation() after a side effect should NOT be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() after a side effect should NOT be accepted", + ) } @Test fun `rethrowIfCancellation inside string is not recognized as helper`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - val msg = "e.rethrowIfCancellation()" - handleError(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + val msg = "e.rethrowIfCancellation()" + handleError(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "rethrowIfCancellation() inside string should NOT be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() inside string should NOT be accepted", + ) } @Test fun `rethrowIfCancellation inside conditional is NOT accepted as first statement`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - if (someCondition) { - e.rethrowIfCancellation() + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + if (someCondition) { + e.rethrowIfCancellation() + } + handleError(e) } - handleError(e) } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "rethrowIfCancellation() inside a conditional branch should NOT be accepted as first-statement helper") + assertNotEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() inside a conditional branch should NOT be accepted as first-statement helper", + ) } @Test fun `rethrowIfCancellation after semicolon-prefixed side effect on same line is NOT accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (error: Throwable) { - auditFailure(error); error.rethrowIfCancellation() - handleError(error) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (error: Throwable) { + auditFailure(error); error.rethrowIfCancellation() + handleError(error) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "rethrowIfCancellation() after side effect on same line should NOT be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() after side effect on same line should NOT be accepted", + ) } @Test fun `rethrowIfCancellation inside inline conditional is NOT accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (error: Throwable) { - if (shouldPropagate) error.rethrowIfCancellation() - handleError(error) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (error: Throwable) { + if (shouldPropagate) error.rethrowIfCancellation() + handleError(error) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "rethrowIfCancellation() inside inline conditional should NOT be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() inside inline conditional should NOT be accepted", + ) } @Test fun `rethrowIfCancellation after inline block comment is NOT accepted`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (error: Throwable) { - /* intent: suppress cancellation */ error.rethrowIfCancellation() - handleError(error) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (error: Throwable) { + /* intent: suppress cancellation */ error.rethrowIfCancellation() + handleError(error) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "rethrowIfCancellation() after inline block comment should NOT be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "rethrowIfCancellation() after inline block comment should NOT be accepted", + ) } @Test fun `multiple statements on first line without helper are NOT accepted as rethrow`() { - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun riskyOperation() { - try { - doSomething() - } catch (e: Exception) { - logError(e); notifyAlert(e); throw DomainException(e) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + logError(e); notifyAlert(e); throw DomainException(e) + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty(), "Should find catch") - assertNotEquals("accepted", findings.first().risk, - "Multiple statements without rethrowIfCancellation() should NOT be accepted") + assertNotEquals( + "accepted", + findings.first().risk, + "Multiple statements without rethrowIfCancellation() should NOT be accepted", + ) } // ── Fingerprint (source-content relocation evidence) tests ── @@ -762,7 +972,10 @@ class KotlinCancellationCatchScannerTest { return findings.first().sourceFingerprint } - private fun fingerprintOf(source: String, catchType: String): String { + private fun fingerprintOf( + source: String, + catchType: String, + ): String { val findings = KotlinCancellationCatchScanner.scan(source, "test", "Test.kt") val finding = findings.firstOrNull { it.catchType == catchType } assertNotNull(finding, "Expected a $catchType finding in: $source") @@ -771,47 +984,58 @@ class KotlinCancellationCatchScannerTest { @Test fun `identical multiline runCatching moved across files has same fingerprint`() { - val base = """ + val base = + """ suspend fun execute() { runCatching { deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { deleteTemporaryState() } } - """.trimIndent() - assertEquals(fingerprintOf(base), fingerprintOf(current), - "Identical multiline runCatching must have identical fingerprints") + """.trimIndent() + assertEquals( + fingerprintOf(base), + fingerprintOf(current), + "Identical multiline runCatching must have identical fingerprints", + ) } @Test fun `multiline runCatching with different bodies has different fingerprints`() { - val base = """ + val base = + """ suspend fun execute() { runCatching { deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { publishExternalResult() } } - """.trimIndent() - assertNotEquals(fingerprintOf(base), fingerprintOf(current), - "Different runCatching bodies must not collide") + """.trimIndent() + assertNotEquals( + fingerprintOf(base), + fingerprintOf(current), + "Different runCatching bodies must not collide", + ) } @Test fun `catch blocks with different bodies have different fingerprints`() { - val base = """ + val base = + """ suspend fun execute() { try { doSomething() @@ -819,8 +1043,9 @@ class KotlinCancellationCatchScannerTest { logError(e) } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { try { doSomething() @@ -828,75 +1053,89 @@ class KotlinCancellationCatchScannerTest { publishFailure(e) } } - """.trimIndent() - assertNotEquals(fingerprintOf(base), fingerprintOf(current), - "Different catch bodies must not collide") + """.trimIndent() + assertNotEquals( + fingerprintOf(base), + fingerprintOf(current), + "Different catch bodies must not collide", + ) } @Test fun `genuine move landing on same line has same fingerprint`() { // Same construct, same line number — the fingerprint is content-based, // so a genuine move that lands on the same line is still a relocation. - val source = """ + val source = + """ suspend fun execute() { runCatching { abort() } } - """.trimIndent() + """.trimIndent() assertEquals(fingerprintOf(source), fingerprintOf(source)) } @Test fun `strings containing a b vs ab do not collapse`() { - val withSpace = """ + val withSpace = + """ suspend fun execute() { runCatching { log("a b") } } - """.trimIndent() - val withoutSpace = """ + """.trimIndent() + val withoutSpace = + """ suspend fun execute() { runCatching { log("ab") } } - """.trimIndent() - assertNotEquals(fingerprintOf(withSpace), fingerprintOf(withoutSpace), - "String contents must be preserved — 'a b' must not collapse to 'ab'") + """.trimIndent() + assertNotEquals( + fingerprintOf(withSpace), + fingerprintOf(withoutSpace), + "String contents must be preserved — 'a b' must not collapse to 'ab'", + ) } @Test fun `strings containing https are preserved`() { - val source = """ + val source = + """ suspend fun execute() { runCatching { post("https://example.com/api") } } - """.trimIndent() + """.trimIndent() val fingerprint = fingerprintOf(source) - assertTrue(fingerprint.contains("https://"), - "Comment marker inside string literal must be preserved, got: $fingerprint") + assertTrue( + fingerprint.contains("https://"), + "Comment marker inside string literal must be preserved, got: $fingerprint", + ) } @Test fun `fingerprint covers full body not just opening line`() { // The fingerprint must include body content — an opening line alone // (runCatching{) would not distinguish these two constructs. - val base = """ + val base = + """ suspend fun execute() { runCatching { deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { publishExternalResult() } } - """.trimIndent() + """.trimIndent() val baseFp = fingerprintOf(base) val currentFp = fingerprintOf(current) assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must cover body, got: $baseFp") @@ -908,13 +1147,16 @@ class KotlinCancellationCatchScannerTest { fun `fingerprint is not serialized into baseline json`() { // "Zero schema change" is a protected invariant: the ephemeral // relocation evidence must never leak into the persisted baseline. - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun execute() { - runCatching { abort() } - } - """.trimIndent(), "test", "Test.kt" - ) + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun execute() { + runCatching { abort() } + } + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty()) val json = ReportNormalizer.toJson(findings.first()) assertFalse(json.contains("sourceFingerprint"), "sourceFingerprint must not be serialized: $json") @@ -926,22 +1168,24 @@ class KotlinCancellationCatchScannerTest { // A `}` in a comment is not structural — the construct continues past // it and the differing executable statements must produce different // fingerprints. - val base = """ + val base = + """ suspend fun execute() { runCatching { // } deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { // } publishExternalResult() } } - """.trimIndent() + """.trimIndent() val baseFp = fingerprintOf(base) val currentFp = fingerprintOf(current) assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must not truncate at comment brace, got: $baseFp") @@ -951,7 +1195,8 @@ class KotlinCancellationCatchScannerTest { @Test fun `brace inside block comment does not terminate fingerprint`() { - val base = """ + val base = + """ suspend fun execute() { runCatching { /* } @@ -959,8 +1204,9 @@ class KotlinCancellationCatchScannerTest { deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { /* } @@ -968,7 +1214,7 @@ class KotlinCancellationCatchScannerTest { publishExternalResult() } } - """.trimIndent() + """.trimIndent() val baseFp = fingerprintOf(base) val currentFp = fingerprintOf(current) assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must not truncate at block-comment brace, got: $baseFp") @@ -981,7 +1227,8 @@ class KotlinCancellationCatchScannerTest { // A `}` inside a triple-quoted raw string is content, not structure — // the state must carry across lines so the construct is not truncated. val tq = "\"\"\"" - val base = """ + val base = + """ suspend fun execute() { runCatching { val json = $tq @@ -990,8 +1237,9 @@ class KotlinCancellationCatchScannerTest { deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { val json = $tq @@ -1000,7 +1248,7 @@ class KotlinCancellationCatchScannerTest { publishExternalResult() } } - """.trimIndent() + """.trimIndent() val baseFp = fingerprintOf(base) val currentFp = fingerprintOf(current) assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must not truncate at raw-string brace, got: $baseFp") @@ -1010,22 +1258,24 @@ class KotlinCancellationCatchScannerTest { @Test fun `brace inside regular string does not terminate fingerprint`() { - val base = """ + val base = + """ suspend fun execute() { runCatching { log("}") deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { log("}") publishExternalResult() } } - """.trimIndent() + """.trimIndent() val baseFp = fingerprintOf(base) val currentFp = fingerprintOf(current) assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must not truncate at string brace, got: $baseFp") @@ -1038,7 +1288,8 @@ class KotlinCancellationCatchScannerTest { // Kotlin block comments nest — an inner `/* */` inside an outer // `/* */` must not return to CODE early, and a `}` inside the still- // open outer comment must not close the construct. - val base = """ + val base = + """ suspend fun execute() { runCatching { /* outer @@ -1048,8 +1299,9 @@ class KotlinCancellationCatchScannerTest { deleteTemporaryState() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { runCatching { /* outer @@ -1059,7 +1311,7 @@ class KotlinCancellationCatchScannerTest { publishExternalResult() } } - """.trimIndent() + """.trimIndent() val baseFp = fingerprintOf(base) val currentFp = fingerprintOf(current) assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must not truncate at nested-comment brace, got: $baseFp") @@ -1073,7 +1325,8 @@ class KotlinCancellationCatchScannerTest { // outer catch whose opening line also contains an inner runCatching // must balance the OUTER construct (statement after the inner // runCatching included), not stop at the inner one. - val base = """ + val base = + """ suspend fun execute() { try { work() @@ -1081,8 +1334,9 @@ class KotlinCancellationCatchScannerTest { oldWork() } } - """.trimIndent() - val current = """ + """.trimIndent() + val current = + """ suspend fun execute() { try { work() @@ -1090,11 +1344,14 @@ class KotlinCancellationCatchScannerTest { completelyDifferentWork() } } - """.trimIndent() + """.trimIndent() val baseFp = fingerprintOf(base, "Exception") val currentFp = fingerprintOf(current, "Exception") assertTrue(baseFp.contains("oldWork"), "Outer-catch fingerprint must include statement after inner runCatching, got: $baseFp") - assertTrue(currentFp.contains("completelyDifferentWork"), "Outer-catch fingerprint must include statement after inner runCatching, got: $currentFp") + assertTrue( + currentFp.contains("completelyDifferentWork"), + "Outer-catch fingerprint must include statement after inner runCatching, got: $currentFp", + ) assertNotEquals(baseFp, currentFp) } @@ -1103,19 +1360,25 @@ class KotlinCancellationCatchScannerTest { // Unbalanced source (unterminated raw string before the construct's // real close) → blank fingerprint → Phase 2 refuses relocation. val tq = "\"\"\"" - val findings = KotlinCancellationCatchScanner.scan( - """ - suspend fun execute() { - runCatching { - val json = $tq - deleteTemporaryState() + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun execute() { + runCatching { + val json = $tq + deleteTemporaryState() + } } - } - """.trimIndent(), "test", "Test.kt" - ) + """.trimIndent(), + "test", + "Test.kt", + ) assertTrue(findings.isNotEmpty()) - assertEquals("", findings.first().sourceFingerprint, - "Unbalanced construct must produce blank fingerprint (refuse relocation)") + assertEquals( + "", + findings.first().sourceFingerprint, + "Unbalanced construct must produce blank fingerprint (refuse relocation)", + ) } @Test @@ -1127,7 +1390,8 @@ class KotlinCancellationCatchScannerTest { // medium instead of critical. The declaration here follows another // block's closing brace directly (previous findBlockEnd == declaration // line), reproducing the Workflow.kt:1763 layout. - val source = """ + val source = + """ suspend fun previousBlock() { doWork() } @@ -1137,23 +1401,27 @@ class KotlinCancellationCatchScannerTest { runCatching { abort() } .onFailure { error.addSuppressed(it) } } - """.trimIndent() + """.trimIndent() val findings = KotlinCancellationCatchScanner.scan(source, "test", "Test.kt") val rc = findings.firstOrNull { it.catchType == "runCatching" } assertNotNull(rc, "runCatching finding expected") - assertEquals("critical", rc.risk, - "runCatching inside suspend fun directly after another block must be critical, got ${rc.risk}") + assertEquals( + "critical", + rc.risk, + "runCatching inside suspend fun directly after another block must be critical, got ${rc.risk}", + ) assertEquals(true, rc.isSuspendCapable) } @Test fun `broad catch immediately after suspend function is not suspend capable`() { - val source = """ + val source = + """ suspend fun previousBlock() { doWork() } val result = runCatching { doOutsideWork() } - """.trimIndent() + """.trimIndent() val findings = KotlinCancellationCatchScanner.scan(source, "test", "Test.kt") @@ -1164,4 +1432,213 @@ class KotlinCancellationCatchScannerTest { assertEquals("medium", rc.risk) } + // ── Sibling catch cancellation-preservation recognition ── + + @Test + fun `earlier sibling CancellationException catch that rolls back then rethrows is accepted`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (c: CancellationException) { + transactionalRollback() + auditCancellation(c) + throw c + } catch (e: Exception) { + handleError(e) + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertEquals( + "accepted", + findings.single().risk, + "An earlier sibling CancellationException catch that rethrows its own variable " + + "must make the broad catch cancellation-safe", + ) + } + + @Test + fun `earlier sibling CancellationException catch throwing a different variable is not accepted`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (c: CancellationException) { + transactionalRollback() + throw lastObservedFailure + } catch (e: Exception) { + handleError(e) + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertNotEquals( + "accepted", + findings.single().risk, + "A sibling cancellation catch that throws a different variable does not preserve cancellation", + ) + } + + @Test + fun `earlier sibling CancellationException catch throwing a new exception is not accepted`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (c: CancellationException) { + transactionalRollback() + throw IllegalStateException("cancellation swallowed", c) + } catch (e: Exception) { + handleError(e) + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertNotEquals( + "accepted", + findings.single().risk, + "A sibling cancellation catch that throws a new exception does not preserve cancellation", + ) + } + + @Test + fun `earlier sibling CancellationException catch transforming cancellation is not accepted`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (c: CancellationException) { + transactionalRollback() + throw c.cause ?: c + } catch (e: Exception) { + handleError(e) + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertNotEquals( + "accepted", + findings.single().risk, + "A sibling cancellation catch that rewraps or transforms cancellation does not preserve it", + ) + } + + @Test + fun `CancellationException catch after the broad catch is not accepted`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + handleError(e) + } catch (c: CancellationException) { + transactionalRollback() + throw c + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertEquals( + "critical", + findings.single().risk, + "A cancellation catch appearing AFTER the broad catch is not an earlier sibling", + ) + } + + @Test + fun `CancellationException catch alone with rollback and rethrow reports no finding`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (c: CancellationException) { + transactionalRollback() + throw c + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertTrue( + findings.isEmpty(), + "A narrow cancellation catch with cleanup and rethrow is not a broad catch", + ) + } + + @Test + fun `CancellationException catch in a nested try is not a sibling of the broad catch`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + try { + nestedRecovery() + } catch (c: CancellationException) { + transactionalRollback() + throw c + } + handleError(e) + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertNotEquals( + "accepted", + findings.single().risk, + "A cancellation catch belonging to a nested try expression is not a sibling", + ) + } + + @Test + fun `regression rethrowIfCancellation first statement is still accepted`() { + val findings = + KotlinCancellationCatchScanner.scan( + """ + suspend fun riskyOperation() { + try { + doSomething() + } catch (e: Exception) { + e.rethrowIfCancellation() + handleError(e) + } + } + """.trimIndent(), + "test", + "Test.kt", + ) + assertEquals( + "accepted", + findings.single().risk, + "The existing rethrowIfCancellation()-first acceptance must survive sibling recognition", + ) + } } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/LegacyPublicationDescriptions.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/LegacyPublicationDescriptions.kt index 6b682c653..9bf20cae7 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/LegacyPublicationDescriptions.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/LegacyPublicationDescriptions.kt @@ -9,65 +9,88 @@ package dev.tramai.build.quality * published POM description byte-identical to this table — no copy editing in * B8. Delete this object once the catalog is the single source of truth and * no parity regression is possible. + * + * The frozen table covers the modules that existed before the freeze. A module + * published *after* it has no pre-B8 POM description, so there is nothing to + * preserve: for those, the catalog description is the authored policy and is + * recorded in [postFreeze]. Keeping them separate means this table stays a + * verbatim record of the removed function while D5 stays total over the + * published set — a new published module must be recorded here rather than + * silently inheriting the generic fallback. */ object LegacyPublicationDescriptions { + private val explicit = + mapOf( + "tramai-core" to "Core annotations, request models, provider registry, and exception types for Tramai.", + "tramai-embedding" to "Embedding model SPI with OpenAI and Ollama implementations for Tramai.", + "tramai-engine" to "Runtime engine that turns annotated Tramai service interfaces into executable proxies.", + "tramai-structured" to "Structured output schema generation, parsing, and validation support for Tramai.", + "tramai-anthropic" to "Anthropic provider integration for Tramai.", + "tramai-gemini" to "Google Gemini provider integration for Tramai.", + "tramai-azure-openai" to "Azure OpenAI provider integration for Tramai.", + "tramai-bedrock" to "AWS Bedrock provider integration for Tramai.", + "tramai-deepseek" to "Deepseek provider integration for Tramai.", + "tramai-memory" to "In-memory memory and state helpers for Tramai (memory primitives and adapters).", + "tramai-openai" to "OpenAI and OpenAI-compatible provider integrations for Tramai.", + "tramai-ollama" to "Ollama provider integration for Tramai.", + "tramai-observability" to "OpenTelemetry-based observability hooks for Tramai.", + "tramai-orchestration" to "Typed workflow orchestration and coordination layer for Tramai.", + "tramai-platform" to "Platform services for plugins, tenancy, API keys, rate limiting, and audit logging.", + "tramai-standalone" to "Minimal standalone runtime bundle for Tramai.", + "tramai-sovereign" to "Secure embedded runtime profile for sovereign TramAI deployments.", + "tramai-spring" to "Spring Boot auto-configuration and integration support for Tramai.", + "tramai-testing" to "Testing utilities and deterministic assertion support for Tramai.", + "tramai-bom" to "Bill of materials for aligning Tramai module versions.", + "tramai-vectorstore-spi" to "Vector store SPI with data models and in-memory implementation for Tramai.", + "tramai-vectorstore-chroma" to "ChromaDB vector store adapter for Tramai.", + "tramai-vectorstore-pgvector" to "PostgreSQL pgvector vector store adapter for Tramai.", + "tramai-rag" to "RAG pipeline: document loading, chunking, retrieval, and context injection for Tramai.", + ) - private val explicit = mapOf( - "tramai-core" to "Core annotations, request models, provider registry, and exception types for Tramai.", - "tramai-embedding" to "Embedding model SPI with OpenAI and Ollama implementations for Tramai.", - "tramai-engine" to "Runtime engine that turns annotated Tramai service interfaces into executable proxies.", - "tramai-structured" to "Structured output schema generation, parsing, and validation support for Tramai.", - "tramai-anthropic" to "Anthropic provider integration for Tramai.", - "tramai-gemini" to "Google Gemini provider integration for Tramai.", - "tramai-azure-openai" to "Azure OpenAI provider integration for Tramai.", - "tramai-bedrock" to "AWS Bedrock provider integration for Tramai.", - "tramai-deepseek" to "Deepseek provider integration for Tramai.", - "tramai-memory" to "In-memory memory and state helpers for Tramai (memory primitives and adapters).", - "tramai-openai" to "OpenAI and OpenAI-compatible provider integrations for Tramai.", - "tramai-ollama" to "Ollama provider integration for Tramai.", - "tramai-observability" to "OpenTelemetry-based observability hooks for Tramai.", - "tramai-orchestration" to "Typed workflow orchestration and coordination layer for Tramai.", - "tramai-platform" to "Platform services for plugins, tenancy, API keys, rate limiting, and audit logging.", - "tramai-standalone" to "Minimal standalone runtime bundle for Tramai.", - "tramai-sovereign" to "Secure embedded runtime profile for sovereign TramAI deployments.", - "tramai-spring" to "Spring Boot auto-configuration and integration support for Tramai.", - "tramai-testing" to "Testing utilities and deterministic assertion support for Tramai.", - "tramai-bom" to "Bill of materials for aligning Tramai module versions.", - "tramai-vectorstore-spi" to "Vector store SPI with data models and in-memory implementation for Tramai.", - "tramai-vectorstore-chroma" to "ChromaDB vector store adapter for Tramai.", - "tramai-vectorstore-pgvector" to "PostgreSQL pgvector vector store adapter for Tramai.", - "tramai-rag" to "RAG pipeline: document loading, chunking, retrieval, and context injection for Tramai.", - ) + /** + * Published modules added after the B8 freeze. Their catalog entries are the + * first and only publication policy for the artifact, so these strings were + * authored (not migrated) and are recorded here as the expected values. + */ + private val postFreeze = + mapOf( + "tramai-control-plane" to + "Authoritative control-plane registration and lifecycle state boundary for governed workloads.", + ) /** The pre-B8 policy for a module name: explicit description or generic fallback. */ fun descriptionFor(moduleName: String): String = - explicit[moduleName] ?: "Tramai module ${moduleName.removePrefix("tramai-")}." + explicit[moduleName] + ?: postFreeze[moduleName] + ?: "Tramai module ${moduleName.removePrefix("tramai-")}." /** Full map for every module name, mirroring the old `when` exhaustiveness. */ fun byModule(): Map { - val names = explicit.keys + listOf( - "tramai-persistence-file", - "tramai-persistence-jdbc", - "tramai-scheduler", - "tramai-security", - "tramai-spring-core", - "tramai-spring-provider-anthropic", - "tramai-spring-provider-ollama", - "tramai-spring-provider-openai", - "tramai-spring-secrets-aws", - "tramai-spring-secrets-file", - "tramai-spring-secrets-vault", - "tramai-spring-boot-starter-local-provider-openai", - "tramai-spring-sovereign", - "tramai-spring-boot-starter", - "tramai-spring-boot-starter-sovereign-ops", - "tramai-spring-boot-starter-sovereign-ops-actuator", - "tramai-spring-boot-starter-sovereign-ops-micrometer", - "tramai-spring-boot-starter-sovereign-ops-observability", - "tramai-spring-boot-starter-sovereign-ops-rest", - "tramai-spring-boot-starter-sovereign-persistence-file", - "tramai-spring-boot-starter-sovereign-persistence-jdbc", - ) + val names = + explicit.keys + postFreeze.keys + + listOf( + "tramai-persistence-file", + "tramai-persistence-jdbc", + "tramai-scheduler", + "tramai-security", + "tramai-spring-core", + "tramai-spring-provider-anthropic", + "tramai-spring-provider-ollama", + "tramai-spring-provider-openai", + "tramai-spring-secrets-aws", + "tramai-spring-secrets-file", + "tramai-spring-secrets-vault", + "tramai-spring-boot-starter-local-provider-openai", + "tramai-spring-sovereign", + "tramai-spring-boot-starter", + "tramai-spring-boot-starter-sovereign-ops", + "tramai-spring-boot-starter-sovereign-ops-actuator", + "tramai-spring-boot-starter-sovereign-ops-micrometer", + "tramai-spring-boot-starter-sovereign-ops-observability", + "tramai-spring-boot-starter-sovereign-ops-rest", + "tramai-spring-boot-starter-sovereign-persistence-file", + "tramai-spring-boot-starter-sovereign-persistence-jdbc", + ) return names.associateWith { descriptionFor(it) } } } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt new file mode 100644 index 000000000..65fa08177 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateCeremonyTest.kt @@ -0,0 +1,161 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The digest-migration certificate's authority lifecycle (M45/M46/M47). + * + * A certificate is itself base authority, so a strong consumption check would still be worthless if + * its creation, retention or disappearance were weak. Each rule gets a discriminator in both + * directions: the property holds for the legitimate transition, and fails for the attack that + * property exists to defeat. + * + * M40-M44 are consumption rules and are not implemented here. The consequence is visible in + * [M47 removal custody is never satisfied by the mere absence of the certificate]: with no + * certificate-aware consumption path there is no way to *prove* a valid consumption, so a + * disappearance always fails. That is the fail-closed half of M47, not an unfinished branch - a + * disappearance must never become its own evidence. + */ +class MutationAuthorityDigestCertificateCeremonyTest { + private val baseSha = "ec8b4da59bc9e22711b0cd802417d297f803b4e0" + private val otherSha = "e6e0d69ed2adfa47c6cdfa4485ef30bc184bb881" + private val rawV1Digest = "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + private val authorityV2Digest = "e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad" + private val admissionSetDigest = "98a9587a1068ec0cd7158a05ba6909c61c522308c272e7fa9cb27d5edd93a79c" + + /** The legitimate certificate: raw-v1 to authority-v2, minted against [baseSha]. */ + private val certificate = + MutationAuthorityDigestCertificate( + fromAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + fromDigest = rawV1Digest, + toAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + toDigest = authorityV2Digest, + admissionSetDigest = admissionSetDigest, + fromBaseSha = baseSha, + reason = "digest migration", + ) + + /** A copy of [certificate] with exactly one bound field replaced. */ + private fun rewritten( + field: String, + value: String, + ): MutationAuthorityDigestCertificate = + when (field) { + "fromAlgorithm" -> certificate.copy(fromAlgorithm = value) + "toAlgorithm" -> certificate.copy(toAlgorithm = value) + "toDigest" -> certificate.copy(toDigest = value) + "admissionSetDigest" -> certificate.copy(admissionSetDigest = value) + "fromBaseSha" -> certificate.copy(fromBaseSha = value) + "reason" -> certificate.copy(reason = value) + else -> error("$field is not a field of the enforced payload") + } + + // ── M45 mint-time base binding (T17) ── + + @Test + fun `M45 a certificate minted against the authority base is accepted`() { + val diagnostics = checks(base = ledger(), candidate = ledger(certificate)) + + assertEquals(emptyList(), diagnostics, "the legitimate mint must pass") + } + + @Test + fun `M45 a certificate minted against a different base is rejected`() { + val diagnostics = checks(base = ledger(), candidate = ledger(certificate.copy(fromBaseSha = otherSha))) + + val failure = diagnostics.single() + assertTrue(failure.message.contains("M45"), failure.message) + assertEquals(rawV1Digest, failure.findingId, "the certificate's source digest identifies it") + } + + @Test + fun `M45 binding is enforced at mint only, so a retained certificate survives later merges`() { + // A pending certificate legitimately outlives intermediate merges: requiring fromBaseSha to + // track the immediate base would make delayed consumption impossible and would itself violate + // certificate immutability. If this fails, mint binding has been turned into a retention rule. + val retained = certificate.copy(fromBaseSha = otherSha) + + val diagnostics = checks(base = ledger(retained), candidate = ledger(retained)) + + assertEquals(emptyList(), diagnostics, "anti-replay applies at introduction, not forever") + } + + // ── M46 retained immutability (T18) ── + + @Test + fun `M46 a retained certificate rewritten in any enforced field is rejected`() { + // Driven as a loop so the enforced payload is proven to cover exactly these fields: adding a + // bound field without classifying it would leave it unprotected and fail this discriminator. + val rewrites = + listOf( + "fromAlgorithm" to "raw-v2", + "toAlgorithm" to "authority-v3", + "toDigest" to "f".repeat(64), + "admissionSetDigest" to "f".repeat(64), + "fromBaseSha" to otherSha, + "reason" to "a later, more convenient story", + ) + + for ((field, value) in rewrites) { + val diagnostics = checks(base = ledger(certificate), candidate = ledger(rewritten(field, value))) + assertTrue(diagnostics.isNotEmpty(), "rewriting $field must fail M46") + assertTrue( + diagnostics.any { it.message.contains("M46") }, + "rewriting $field: ${diagnostics.map { it.message }}", + ) + } + } + + @Test + fun `M46 audit-only metadata may change, and cannot manufacture authority`() { + val diagnostics = + checks( + base = ledger(certificate.copy(authorizedBy = "giona", authorizedAt = "2026-10-01T00:00:00Z")), + candidate = ledger(certificate.copy(authorizedBy = "someone", authorizedAt = "2027-01-01T00:00:00Z")), + ) + + assertEquals(emptyList(), diagnostics, "who/when carry no enforcement value in either direction") + } + + // ── M47 removal custody (T19, lifecycle half) ── + + @Test + fun `M47 a retained certificate is not a removal`() { + val diagnostics = checks(base = ledger(certificate), candidate = ledger(certificate)) + + assertEquals(emptyList(), diagnostics) + } + + @Test + fun `M47 removal custody is never satisfied by the mere absence of the certificate`() { + // The exact attack T19 exists to defeat: a transition that simply deletes the certificate and + // calls the deletion evidence of consumption. With no consumption path implemented, no + // disappearance can be proven valid, so every disappearance must fail closed. + val diagnostics = checks(base = ledger(certificate), candidate = ledger()) + + val failure = diagnostics.single() + assertTrue(failure.message.contains("M47"), failure.message) + assertTrue(failure.message.contains("removed without being consumed"), failure.message) + assertEquals(rawV1Digest, failure.findingId) + } + + @Test + fun `a certificate introduced and removed by the same transition is not a way to consume it`() { + // Net-zero: nothing is gained, and M45 still judges the introduction against the base. What + // must never happen is this being read as a consumption, which is M43's job in Step 3. + val diagnostics = checks(base = ledger(), candidate = ledger(certificate.copy(fromBaseSha = otherSha))) + + assertTrue(diagnostics.single().message.contains("M45"), "the introduction is still judged") + } + + private fun checks( + base: MutationAuthorityDigestCertificates, + candidate: MutationAuthorityDigestCertificates, + ): List = + MutationAuthorityDigestCertificateCeremony.checks(base = base, candidate = candidate, baseSha = baseSha) + + private fun ledger(vararg certificates: MutationAuthorityDigestCertificate) = + MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = certificates.toList()) +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateConsumptionTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateConsumptionTest.kt new file mode 100644 index 000000000..8fe22cf50 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateConsumptionTest.kt @@ -0,0 +1,204 @@ +package dev.tramai.build.quality + +import dev.tramai.build.quality.CertificateConsumption.Valid +import dev.tramai.build.quality.MutationAuthorityDigestCertificates.Companion.ALGORITHM_AUTHORITY_V2 +import dev.tramai.build.quality.MutationAuthorityDigestCertificates.Companion.ALGORITHM_RAW_V1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * T14, T15, T16 and T19 at the rule level: consumption is a fact produced from base authority, and + * the lifecycle rules act only on facts the verifier attributed. + * + * Every `Valid` fact below is obtained by running the real verifier over a real base ledger - none is + * hand-constructed - so these tests exercise the same route a Gradle call site will use. + */ +class MutationAuthorityDigestCertificateConsumptionTest { + private val raw = "1".repeat(64) + private val otherRaw = "7".repeat(64) + private val authority = "2".repeat(64) + private val baseSha = "a".repeat(40) + private val identities = listOf("id-a", "id-b") + private val setDigest = MutationAuthorityDigestCertificates.admissionSetDigest(identities) + + private fun certificate( + fromDigest: String = raw, + fromAlgorithm: String = ALGORITHM_RAW_V1, + admissionSetDigest: String = setDigest, + reason: String = "test certificate", + ) = MutationAuthorityDigestCertificate( + fromAlgorithm = fromAlgorithm, + fromDigest = fromDigest, + toAlgorithm = ALGORITHM_AUTHORITY_V2, + toDigest = authority, + admissionSetDigest = admissionSetDigest, + fromBaseSha = baseSha, + reason = reason, + ) + + private fun ledger(vararg certificates: MutationAuthorityDigestCertificate) = + MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = certificates.toList()) + + private fun verify( + certificate: MutationAuthorityDigestCertificate = certificate(), + base: MutationAuthorityDigestCertificates = ledger(certificate()), + citedAdmissionPopulationDigest: String = raw, + baseAdmissionIdentities: List = identities, + freshAuthorityProjectionHash: String = authority, + ) = verifyCertificateConsumption( + certificate = certificate, + baseCertificates = base, + citedAdmissionPopulationDigest = citedAdmissionPopulationDigest, + baseAdmissionIdentities = baseAdmissionIdentities, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + ) + + private fun validFact(certificate: MutationAuthorityDigestCertificate = certificate()): Valid { + val result = + verify( + certificate = certificate, + base = ledger(certificate), + citedAdmissionPopulationDigest = certificate.fromDigest, + ) + assertTrue(result is CertificateConsumption.Valid, "expected a valid consumption, got $result") + return result as CertificateConsumption.Valid + } + + private fun refusal( + certificate: MutationAuthorityDigestCertificate = certificate(), + base: MutationAuthorityDigestCertificates = ledger(certificate), + citedAdmissionPopulationDigest: String = raw, + baseAdmissionIdentities: List = identities, + freshAuthorityProjectionHash: String = authority, + ): VerificationDiagnostic { + val result = + verify( + certificate = certificate, + base = base, + citedAdmissionPopulationDigest = citedAdmissionPopulationDigest, + baseAdmissionIdentities = baseAdmissionIdentities, + freshAuthorityProjectionHash = freshAuthorityProjectionHash, + ) + assertTrue(result is CertificateConsumption.Invalid, "expected a refusal, got $result") + return (result as CertificateConsumption.Invalid).diagnostic + } + + @Test + fun `a certificate satisfying every predicate yields the attributed fact`() { + val fact = validFact() + + assertEquals(raw, fact.fromDigest) + assertEquals(authority, fact.toDigest) + assertEquals(setDigest, fact.admissionSetDigest) + } + + @Test + fun `M43 a certificate absent from the base cannot be consumed`() { + val diagnostic = refusal(base = MutationAuthorityDigestCertificates.NONE) + + assertTrue(diagnostic.message.contains("M43"), diagnostic.message) + assertEquals(raw, diagnostic.findingId) + } + + @Test + fun `M43 a candidate-minted certificate cannot become a consumption fact`() { + val diagnostic = refusal(base = ledger(certificate(fromDigest = otherRaw))) + + assertTrue(diagnostic.message.contains("M43"), diagnostic.message) + } + + @Test + fun `M43 a certificate whose base payload was rewritten cannot be consumed`() { + val diagnostic = refusal(base = ledger(certificate(reason = "rewritten in the candidate"))) + + assertTrue(diagnostic.message.contains("M43"), diagnostic.message) + } + + @Test + fun `M40 a certificate whose target digest is not this measurement's projection is refused`() { + val diagnostic = refusal(freshAuthorityProjectionHash = "3".repeat(64)) + + assertTrue(diagnostic.message.contains("M40"), diagnostic.message) + } + + @Test + fun `M41 a certificate for a different source digest than the cited admission is refused`() { + val diagnostic = refusal(citedAdmissionPopulationDigest = otherRaw) + + assertTrue(diagnostic.message.contains("M41"), diagnostic.message) + } + + @Test + fun `M41 a certificate declaring a different migration pair is refused`() { + val diagnostic = refusal(certificate = certificate(fromAlgorithm = ALGORITHM_AUTHORITY_V2)) + + assertTrue(diagnostic.message.contains("M41"), diagnostic.message) + } + + @Test + fun `M42 a certificate bounding a different admission set is refused`() { + val diagnostic = refusal(certificate = certificate(admissionSetDigest = "5".repeat(64))) + + assertTrue(diagnostic.message.contains("M42"), diagnostic.message) + } + + @Test + fun `M42 a certificate that also covers an unauthorized identity is refused`() { + val diagnostic = refusal(baseAdmissionIdentities = identities + "id-c") + + assertTrue(diagnostic.message.contains("M42"), diagnostic.message) + } + + @Test + fun `T16 M44 a consumed certificate retained in the candidate fails`() { + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(certificate()), + baseSha = baseSha, + validConsumptions = setOf(validFact()), + ) + + assertTrue(diagnostics.single().message.contains("M44"), diagnostics.single().message) + } + + @Test + fun `T19 M47 a removed certificate with no established consumption still fails`() { + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(), + baseSha = baseSha, + ) + + assertTrue(diagnostics.single().message.contains("M47"), diagnostics.single().message) + } + + @Test + fun `T19 M47 removal is permitted by the fact for that exact certificate`() { + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(), + baseSha = baseSha, + validConsumptions = setOf(validFact()), + ) + + assertTrue(diagnostics.isEmpty(), diagnostics.joinToString { it.message }) + } + + @Test + fun `T19 M47 removal is not permitted by a fact proving a different certificate`() { + val other = certificate(fromDigest = otherRaw) + val diagnostics = + MutationAuthorityDigestCertificateCeremony.checks( + base = ledger(certificate()), + candidate = ledger(), + baseSha = baseSha, + validConsumptions = setOf(validFact(other)), + ) + + assertTrue(diagnostics.single().message.contains("M47"), diagnostics.single().message) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt new file mode 100644 index 000000000..8ec44a9cc --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationAuthorityDigestCertificateLoaderTest.kt @@ -0,0 +1,228 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The digest-migration certificate ledger's shape contract, and the one value a future mint must be + * able to reproduce exactly. + * + * The loader validates *shape* only: mint-time base binding (M45), retained immutability (M46) and + * removal custody (M47) are transition properties and are tested in + * [MutationAuthorityDigestCertificateCeremonyTest]. What is tested here is that a partially + * specified certificate - indistinguishable from one describing a different migration - is a hard + * failure rather than a silently weaker authority. + */ +class MutationAuthorityDigestCertificateLoaderTest { + @TempDir + lateinit var tempDir: File + + private val fromDigest = "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + private val toDigest = "e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad" + private val admissionSetDigest = "98a9587a1068ec0cd7158a05ba6909c61c522308c272e7fa9cb27d5edd93a79c" + private val fromBaseSha = "ec8b4da59bc9e22711b0cd802417d297f803b4e0" + + @Test + fun `an absent certificate ledger is no migration authority rather than a failure`() { + val loaded = MutationAuthorityDigestCertificateLoader.load(tempDir) + + assertEquals(MutationAuthorityDigestCertificates.NONE.certificates, loaded.certificates) + assertEquals("1", loaded.schemaVersion) + } + + @Test + fun `a fully specified certificate round-trips, audit metadata included`() { + writeLedger(auditedEntry("giona", "2026-10-01T00:00:00Z")) + + val certificate = + MutationAuthorityDigestCertificateLoader + .load(tempDir) + .certificates + .single() + + assertEquals("raw-v1", certificate.fromAlgorithm) + assertEquals(fromDigest, certificate.fromDigest) + assertEquals("authority-v2", certificate.toAlgorithm) + assertEquals(toDigest, certificate.toDigest) + assertEquals(admissionSetDigest, certificate.admissionSetDigest) + assertEquals(fromBaseSha, certificate.fromBaseSha) + assertEquals("test reason", certificate.reason) + assertEquals("giona", certificate.authorizedBy) + } + + @Test + fun `the admission-set digest recipe reproduces the committed 67-admission set`() { + // The recipe is part of the contract, not an implementation detail: whoever mints the P1M + // certificate must be able to reproduce this value exactly from the authorization set the + // certificate is meant to bound, and M42 later compares against it. + val admissions = MutationPopulationAdmissionLoader.load(repositoryRoot()).admissions + + assertEquals(67, admissions.size, "the committed P1 authorization set") + assertEquals( + admissionSetDigest, + MutationAuthorityDigestCertificates.admissionSetDigest(admissions.map { it.identity }), + "sorted identities joined with a newline and a trailing newline, SHA-256", + ) + } + + @Test + fun `the admission-set digest is order independent and set dependent`() { + val a = "a".repeat(64) + val b = "b".repeat(64) + + assertEquals( + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a, b)), + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(b, a)), + ) + assertTrue( + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a, b)) != + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(a)), + "bounding a different set must change the digest, otherwise the certificate is not bounded", + ) + } + + @Test + fun `a malformed from digest is a hard failure, not a weaker authority`() { + failingLoad(entry(mapOf("fromDigest" to "not-a-digest"))) + } + + @Test + fun `a short fromBaseSha is a hard failure`() { + val failure = failingLoad(entry(mapOf("fromBaseSha" to "ec8b4da5"))) + + assertTrue(failure.message!!.contains("fromBaseSha"), failure.message) + } + + @Test + fun `an unknown digest semantics is a hard failure`() { + val failure = failingLoad(entry(mapOf("toAlgorithm" to "authority-v3"))) + + assertTrue(failure.message!!.contains("known digest semantics"), failure.message) + } + + @Test + fun `a missing reason is a hard failure`() { + val failure = failingLoad(entry(mapOf("reason" to null))) + + assertTrue(failure.message!!.contains("reason"), failure.message) + } + + @Test + fun `two certificates claiming the same source digest contradict each other and fail`() { + val failure = failingLoad(entry() + entry(mapOf("toDigest" to "f".repeat(64)))) + + assertTrue(failure.message!!.contains("duplicate fromDigest"), failure.message) + } + + @Test + fun `a certificate that migrates a digest to itself fails, because it certifies nothing`() { + val failure = failingLoad(entry(mapOf("toAlgorithm" to "raw-v1"))) + + assertTrue(failure.message!!.contains("certifies nothing"), failure.message) + } + + @Test + fun `an absent schemaVersion is a hard failure`() { + val file = File(tempDir, MutationAuthorityDigestCertificateLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText("certificates:\n" + entry(), Charsets.UTF_8) + + val failure = assertFailsWith { MutationAuthorityDigestCertificateLoader.load(tempDir) } + + assertTrue(failure.message!!.contains("schemaVersion"), failure.message) + } + + @Test + fun `the enforced payload excludes audit metadata and includes every bound field`() { + val certificate = + MutationAuthorityDigestCertificate( + fromAlgorithm = "raw-v1", + fromDigest = fromDigest, + toAlgorithm = "authority-v2", + toDigest = toDigest, + admissionSetDigest = admissionSetDigest, + fromBaseSha = fromBaseSha, + reason = "test reason", + authorizedBy = "someone", + authorizedAt = "sometime", + ) + + assertEquals( + listOf("raw-v1", fromDigest, "authority-v2", toDigest, admissionSetDigest, fromBaseSha, "test reason"), + certificate.enforcedPayload(), + ) + assertEquals( + certificate.enforcedPayload(), + certificate.copy(authorizedBy = "another", authorizedAt = "elsewhere").enforcedPayload(), + "audit metadata alone can never manufacture or break authority", + ) + } + + /** Writes [entries] as the ledger, then asserts that loading it fails, returning the failure. */ + private fun failingLoad(entries: String): GradleException { + writeLedger(entries) + return assertFailsWith { MutationAuthorityDigestCertificateLoader.load(tempDir) } + } + + /** + * The repository root, found by walking up to the `gradlew` marker (the same idiom + * `CanonicalProbeFunctionalTest` uses) rather than assuming this test starts a fixed number of + * levels below it. This test is deliberately pinned against the real committed admission ledger, + * so a brittle path guess would fail for a reason that has nothing to do with the recipe. + */ + private fun repositoryRoot(): File { + var candidate = File(System.getProperty("user.dir")) + while (candidate.parentFile != null && !File(candidate, "gradlew").isFile) { + candidate = candidate.parentFile!! + } + check(File(candidate, "gradlew").isFile) { + "no repository root (directory containing gradlew) above ${System.getProperty("user.dir")}" + } + return candidate + } + + private fun auditedEntry( + authorizedBy: String, + authorizedAt: String, + ): String = + entry().replace( + " reason: \"test reason\"", + " authorizedBy: \"$authorizedBy\"\n authorizedAt: \"$authorizedAt\"\n reason: \"test reason\"", + ) + + /** + * The canonical certificate YAML, with selected fields replaced. + * + * Malformed-input cases need raw values that a typed certificate cannot represent, so overrides + * are raw strings (or null to omit a field) rather than certificate fields. + */ + private fun entry(overrides: Map = emptyMap()): String { + val fields = + linkedMapOf( + "fromAlgorithm" to "raw-v1", + "fromDigest" to fromDigest, + "toAlgorithm" to "authority-v2", + "toDigest" to toDigest, + "admissionSetDigest" to admissionSetDigest, + "fromBaseSha" to fromBaseSha, + "reason" to "test reason", + ) + fields.putAll(overrides) + val lines = + fields.entries + .filter { it.value != null } + .map { " ${it.key}: \"${it.value}\"" } + return " - " + lines.first().trimStart() + "\n" + lines.drop(1).joinToString("") { "$it\n" } + } + + private fun writeLedger(entries: String) { + val file = File(tempDir, MutationAuthorityDigestCertificateLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText("schemaVersion: \"1\"\ncertificates:\n$entries", Charsets.UTF_8) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationEvolutionLoaderTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationEvolutionLoaderTest.kt new file mode 100644 index 000000000..f90218447 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationEvolutionLoaderTest.kt @@ -0,0 +1,112 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class MutationEvolutionLoaderTest { + @Test + fun `missing file is empty`( + @TempDir root: File, + ) { + assertEquals(emptyList(), MutationEvolutionLoader.load(root).records) + } + + @Test + fun `empty records list is empty`( + @TempDir root: File, + ) { + write(root, "schemaVersion: \"1\"\nrecords: []") + assertEquals(emptyList(), MutationEvolutionLoader.load(root).records) + } + + @Test + fun `from base sha is loaded`( + @TempDir root: File, + ) { + write( + root, + """ + schemaVersion: "1" + records: + - id: id + fromBaseSha: base + reason: reason + """.trimIndent(), + ) + assertEquals( + "base", + MutationEvolutionLoader + .load(root) + .records + .single() + .fromBaseSha, + ) + } + + @Test + fun `missing schema version fails`( + @TempDir root: File, + ) { + write(root, "records: []") + assertFailsWith { MutationEvolutionLoader.load(root) } + } + + @Test + fun `malformed yaml fails`( + @TempDir root: File, + ) { + write(root, "schemaVersion: [") + assertFailsWith { MutationEvolutionLoader.load(root) } + } + + @Test + fun `blank id reason or base sha fails`( + @TempDir root: File, + ) { + write(root, "schemaVersion: \"1\"\nrecords:\n - id: \" \"\n reason: ok") + assertFailsWith { MutationEvolutionLoader.load(root) } + write(root, "schemaVersion: \"1\"\nrecords:\n - id: id\n fromBaseSha: \" \"\n reason: ok") + assertFailsWith { MutationEvolutionLoader.load(root) } + write(root, "schemaVersion: \"1\"\nrecords:\n - id: id\n reason: \" \"") + assertFailsWith { MutationEvolutionLoader.load(root) } + } + + @Test + fun `duplicate ids fail`( + @TempDir root: File, + ) { + write( + root, + """ + schemaVersion: "1" + records: + - id: same + reason: one + - id: same + reason: two + """.trimIndent(), + ) + assertFailsWith { MutationEvolutionLoader.load(root) } + } + + @Test + fun `property defaults and rejects unknown values`() { + assertEquals(MutationPopulationEvolution.FORBID, MutationPopulationEvolution.fromProperty(null)) + assertEquals(MutationPopulationEvolution.FORBID, MutationPopulationEvolution.fromProperty("")) + assertFailsWith { MutationPopulationEvolution.fromProperty("future") } + } + + private fun write( + root: File, + content: String, + ) { + File(root, "config/quality/mutation-evolution.yml").apply { + parentFile.mkdirs() + writeText(content) + } + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt new file mode 100644 index 000000000..f4af85f3e --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionCeremonyTest.kt @@ -0,0 +1,637 @@ +package dev.tramai.build.quality + +import dev.tramai.build.quality.MutationPopulationAdmissionCeremony.AdmissionVerdict +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * M30-M39: the population-admission ceremony (0.7.1g1G3). + * + * This suite exists to prove one narrow exception to M06 and nothing wider: + * + * - an appearing candidate-only NON_KILLED identity is admitted **only** when the *base* ledger + * authorizes its exact row, under the exact analyzer semantics, for the exact canonical + * fresh-population digest; + * - an unauthorized appearing survivor still fails M06, so `authorized X + unauthorized Y` fails + * **for Y** - the discriminator that separates "explicit authority for one adjudicated identity" + * from "turn M06 off for this transition"; + * - authority is temporal and base-derived: a transition can never mint the authority it consumes; + * - the SHA binds the mint, not the consumption, so a pending authorization survives intermediate + * merges and is still consumed many commits later; + * - the authorization is single-use, immutable while pending, and removable as cleanup once + * obsolete - but an obsolete authorization never forces an admission. + * + * Identities are real schema-v2 identities recomputed from the row's own fields + * ([MutationRatchetTestSupport]), and the digest used in every fixture is the one the verifier + * itself computes ([digestOf]), never an invented constant. + */ +class MutationPopulationAdmissionCeremonyTest : MutationRatchetTestSupport() { + private val anchor = row("anchor", status = "KILLED", outcome = "KILLED") + + private fun accepted(diagnostics: List): Boolean = + diagnostics.any { + it.severity == DiagnosticSeverity.ACCEPTED && + it.code == DiagnosticCode.MUTATION_RATCHET_ADMISSION_ACCEPTED + } + + // ── row 1: the admitted transition ── + + @Test + fun `exact base authorized appearing row is admitted and M06 does not fire`() { + val base = population(listOf(anchor)) + val candidate = population(listOf(anchor, row("target"))) + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(admission("target", populationDigest = digestOf(candidate))), + candidatePopulation = candidate, + candidateAdmissions = MutationPopulationAdmissions.NONE, + ) + passes(diagnostics) + assertTrue(accepted(diagnostics), "expected an M30 accepted note, got: $diagnostics") + assertFalse( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR), + "M06 must not fire for an authorized identity", + ) + } + + // ── row 3: M06 unchanged when nothing is authorized ── + + @Test + fun `appearing survivor without any authorization still fails M06`() { + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + candidatePopulation = population(listOf(anchor, row("target"))), + ) + assertFailsWith( + diagnostics, + DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, + "NEW NON_KILLED identity absent from the base authority", + ) + } + + // ── row 2: same-transition self-authorization ── + + @Test + fun `candidate that authorizes and admits in the same transition fails M31`() { + val candidate = population(listOf(anchor, row("target"))) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = MutationPopulationAdmissions.NONE, + candidatePopulation = candidate, + candidateAdmissions = admissions(admission("target", populationDigest = digestOf(candidate))), + ) + assertFailsWith( + diagnostics, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_UNAUTHORIZED, + "authorized only by this transition", + ) + assertFalse( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR), + "the more precise M31 must replace M06 for a self-authorized identity", + ) + } + + // ── rows 4-6: identity alone is not authority ── + + @Test + fun `authorized identity with a different raw status fails M32`() { + val candidate = population(listOf(anchor, row("target", status = "NO_COVERAGE"))) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = + admissions( + admission("target", status = "SURVIVED", populationDigest = digestOf(candidate)), + ), + candidatePopulation = candidate, + ) + assertFailsWith( + diagnostics, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + "does not match the row authorized in the base", + ) + } + + /** + * T9: re-homing an AUTHORIZED identity, with the two dimensions §7 fuses deliberately separated, + * because they are not the same attack. + * + * **family is NOT identity-bearing.** `MutationIdentity.stableKey()` hashes module, className, + * method, descriptor, mutator, description, block and index. So the re-homing must be applied to an + * existing row - `target.copy(family = ...)` - and never through `row(marker, family = ...)`, which + * also rewrites `className` and would silently mint a new identity, testing a different transition + * entirely (a className change) and reaching M06/M37 instead of M32. With the identity genuinely + * preserved and the candidate topology kept coherent, the only thing that can refuse the row is the + * persisted-row binding, and that is what §7 expects: M32. + * + * **module IS identity-bearing.** A module move therefore cannot reach M32 at all: the mutant that + * arrives is a new identity, refused as a new survivor (M06) while the authorization for the old + * identity is orphaned (M37). Pinned separately so neither case can be read as evidence for the other. + */ + @Test + fun `T9 a family-only re-homing keeps identity and fails M32, while a module move changes identity`() { + // --- family: not identity-bearing, so the identity survives -> M32 --- + val target = row("target") + val rehomedByFamily = target.copy(family = retryFamily) + assertEquals( + target.identity, + rehomedByFamily.identity, + "fixture precondition: a family-only move must preserve identity, so it cannot mint a new mutant", + ) + val familyCandidate = + population( + listOf(anchor, rehomedByFamily), + families = baseFamilies + (retryFamily to retryTarget), + ) + assertFailsWith( + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = admissions(admission("target", populationDigest = digestOf(familyCandidate))), + candidatePopulation = familyCandidate, + ), + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + "does not match the row authorized in the base", + ) + + // --- module: identity-bearing, so the mutant arrives as a new identity -> M06 + M37 --- + val moduleCandidate = population(listOf(anchor, row("target", module = ":other"))) + val moduleDiagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = admissions(admission("target", populationDigest = digestOf(moduleCandidate))), + candidatePopulation = moduleCandidate, + ) + assertTrue( + hasCode(moduleDiagnostics, DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR), + "a module move must arrive as a new identity: ${failures(moduleDiagnostics).map { it.message }}", + ) + assertTrue( + hasCode(moduleDiagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID) && + failures(moduleDiagnostics).any { it.message.contains("M37") }, + "the authorization orphaned by the move must fail M37: " + + "${failures(moduleDiagnostics).map { it.message }}", + ) + } + + @Test + fun `authorized row measured under different analyzer semantics fails M33`() { + val candidate = population(listOf(anchor, row("target"))) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = + admissions( + admission( + "target", + analyzer = semantics.copy(pluginVersion = "0.0.0-other"), + populationDigest = digestOf(candidate), + ), + ), + candidatePopulation = candidate, + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, "different analyzer semantics") + } + + @Test + fun `authorized row with a different population digest fails M34`() { + val base = population(listOf(anchor)) + val candidate = population(listOf(anchor, row("target"))) + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(admission("target", populationDigest = digestOf(base))), + candidatePopulation = candidate, + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, "authority projection") + } + + @Test + fun `admission fails closed when no trusted fresh measurement proof exists`() { + val candidate = population(listOf(anchor, row("target"))) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = admissions(admission("target", populationDigest = digestOf(candidate))), + candidatePopulation = candidate, + evidence = MutationEvolutionEvidence(), + ) + assertFailsWith( + diagnostics, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_MISMATCH, + "no proof of the canonical fresh measurement", + ) + } + + // ── row 8: THE discriminator ── + + @Test + fun `authorized X plus unauthorized Y fails for Y and never rejects X`() { + val base = population(listOf(anchor)) + val candidate = population(listOf(anchor, row("target"), row("other"))) + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(admission("target", populationDigest = digestOf(candidate))), + candidatePopulation = candidate, + ) + val failures = failures(diagnostics) + assertEquals(1, failures.size, "exactly one failure expected, got: ${failures.map { it.message }}") + assertEquals(identityOf("other", policyFamily, ":engine"), failures.single().findingId) + assertEquals(DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, failures.single().code) + assertFalse( + failures.any { it.findingId == identityOf("target", policyFamily, ":engine") }, + "X must not be rejected", + ) + } + + // ── row 9: improvements still pass ── + + @Test + fun `newly killed identity passes through M07`() { + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + candidatePopulation = + population(listOf(anchor, row("newkilled", status = "KILLED", outcome = "KILLED"))), + ) + passes(diagnostics) + } + + // ── the SHA table: mint-time binding, never consumption-time ── + + @Test + fun `new authorization bound to another base SHA fails at mint`() { + val candidate = population(listOf(anchor)) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + candidatePopulation = candidate, + candidateAdmissions = + admissions( + admission( + "future", + fromBaseSha = "0000000000000000000000000000000000000000", + populationDigest = digestOf(candidate), + ), + ), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `an authorization minted against an older base is consumable much later`() { + // The authorization records the authority base it was MINTED against; the current base is + // newer. That is the expected state, not an error - requiring the recorded SHA to track the + // immediate base would make delayed consumption impossible. So this transition consumes it: + // the exact authorized row appears, the ledger entry is removed, the digest matches. + val mintingSha = "1111111111111111111111111111111111111111" + val candidate = population(listOf(anchor, row("target"))) + val minted = admission("target", populationDigest = digestOf(candidate), fromBaseSha = mintingSha) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = admissions(minted), + candidatePopulation = candidate, + candidateAdmissions = MutationPopulationAdmissions.NONE, + ) + passes(diagnostics) + assertTrue(accepted(diagnostics), "expected the M30 acceptance for a delayed consumption") + } + + @Test + fun `rewriting only fromBaseSha while pending fails M36`() { + val base = population(listOf(anchor)) + val minted = + admission( + "target", + fromBaseSha = "1111111111111111111111111111111111111111", + populationDigest = digestOf(base), + ) + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(minted), + candidatePopulation = base, + candidateAdmissions = admissions(minted.copy(fromBaseSha = BASE_SHA)), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "immutable") + } + + // ── structural proof: complete-payload immutability, audit fields excluded ── + + @Test + fun `every bound field independently rejects a retained rewrite and audit fields do not`() { + val base = population(listOf(anchor)) + val minted = admission("target", populationDigest = digestOf(base)) + val rewrites = + listOf( + "status" to minted.copy(status = "TIMED_OUT"), + "outcome" to minted.copy(outcome = "KILLED"), + "family" to minted.copy(family = retryFamily), + "module" to minted.copy(module = ":other"), + "analyzer" to minted.copy(analyzer = minted.analyzer.copy(engineVersion = "0.0.0")), + "fromBaseSha" to minted.copy(fromBaseSha = "3333333333333333333333333333333333333333"), + "populationDigest" to minted.copy(populationDigest = "4".repeat(64)), + "reason" to minted.copy(reason = "re-authored after the decision"), + "issue" to minted.copy(issue = "ISSUE-2"), + "targetPhase" to minted.copy(targetPhase = "0.8.0"), + ) + for ((field, rewritten) in rewrites) { + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(minted), + candidatePopulation = base, + candidateAdmissions = admissions(rewritten), + ) + assertTrue( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID), + "rewriting $field must fail", + ) + } + val auditOnly = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(minted), + candidatePopulation = base, + candidateAdmissions = admissions(minted.copy(authorizedBy = "someone-else", authorizedAt = "later")), + ) + passes(auditOnly) + } + + // ── lifecycle: removal, single use, obsolescence ── + + @Test + fun `removing a pending authorization without consuming it fails M37`() { + val base = population(listOf(anchor)) + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(admission("target", populationDigest = digestOf(base))), + candidatePopulation = base, + candidateAdmissions = MutationPopulationAdmissions.NONE, + ) + assertFailsWith( + diagnostics, + DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, + "removed without being consumed", + ) + } + + @Test + fun `retaining a consumed authorization fails M38`() { + val candidate = population(listOf(anchor, row("target"))) + val minted = admission("target", populationDigest = digestOf(candidate)) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor)), + baseAdmissions = admissions(minted), + candidatePopulation = candidate, + candidateAdmissions = admissions(minted), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_RETAINED, "single-use") + assertTrue(accepted(diagnostics), "the row itself is still authorized; only the retention is wrong") + } + + @Test + fun `obsolete authorization whose target is now killed can be cleaned up`() { + val base = population(listOf(anchor, row("target"))) + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(admission("target", populationDigest = digestOf(base))), + candidatePopulation = population(listOf(anchor, row("target", status = "KILLED", outcome = "KILLED"))), + candidateAdmissions = MutationPopulationAdmissions.NONE, + ) + passes(diagnostics) + } + + @Test + fun `retained obsolete authorization warns and admits nothing`() { + val base = population(listOf(anchor, row("target"))) + val minted = admission("target", populationDigest = digestOf(base)) + val diagnostics = + verifyAdmission( + basePopulation = base, + baseAdmissions = admissions(minted), + candidatePopulation = population(listOf(anchor, row("target", status = "KILLED", outcome = "KILLED"))), + candidateAdmissions = admissions(minted), + ) + passes(diagnostics) + assertTrue( + diagnostics.any { it.severity == DiagnosticSeverity.WARNING && it.message.contains("M39") }, + "expected an M39 cleanup advisory, got: $diagnostics", + ) + assertFalse(accepted(diagnostics), "an obsolete authorization must never force an admission") + } + + // ── structural proof: next-base invariant ── + + @Test + fun `a passing mint and a passing consumption are each valid as the next base`() { + val p1Base = population(listOf(anchor)) + val target = row("target") + val p2Population = population(listOf(anchor, target)) + val minted = admission("target", populationDigest = digestOf(p2Population)) + + val mintDiagnostics = + verifyAdmission( + basePopulation = p1Base, + baseAdmissions = MutationPopulationAdmissions.NONE, + candidatePopulation = p1Base, + candidateAdmissions = admissions(minted), + ) + passes(mintDiagnostics) + + val consumeDiagnostics = + verifyAdmission( + basePopulation = p1Base, + baseAdmissions = admissions(minted), + candidatePopulation = p2Population, + candidateAdmissions = MutationPopulationAdmissions.NONE, + ) + passes(consumeDiagnostics) + + // The consumed state must itself be a legal base: same populations, ledger empty. + val quietDiagnostics = + verifyAdmission( + basePopulation = p2Population, + candidatePopulation = p2Population, + ) + passes(quietDiagnostics) + } + + // ── row 11: composition with the existing M21 removal authority ── + + @Test + fun `a valid M21 removal and a valid admission compose in one transition`() { + val doomed = row("doomed") + val candidate = population(listOf(anchor, row("target"))) + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(anchor, doomed)), + baseAdmissions = admissions(admission("target", populationDigest = digestOf(candidate))), + candidatePopulation = candidate, + candidateAdmissions = MutationPopulationAdmissions.NONE, + evidence = evidence(candidate, MutationEvolutionRecords("1", listOf(evolutionRecord("doomed")))), + ) + passes(diagnostics) + } + + // ── the shared verdict is public API of the ceremony: a null authorization is M06, verbatim ── + + @Test + fun `appearance verdict reproduces the M06 diagnostic verbatim`() { + val target = row("target") + val rejected = + MutationPopulationAdmissionCeremony.appearanceVerdict( + baseAdmission = null, + candidateAdmission = null, + mutant = target, + candidateAnalyzer = semantics, + authority = AdmissionAuthority("0".repeat(64)), + ) as? AdmissionVerdict.Rejected + assertNotNull(rejected) + assertEquals(DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, rejected.code) + // Full equality against the literal diagnostic: the ceremony narrows M06 for authorized + // identities and must not restate it. The 8-character identity slice, the row rendering and + // the sentence are all pinned here - a prefix check would not prove any of that. + val short = identityOf("target", policyFamily, ":engine").take(8) + val expected = + "M06: :engine dev.tramai.policy.Policy#apply_target()V " + + "[org.pitest.mutationtest.engine.gregor.mutators.MathMutator] family 'policy' ($short) is a " + + "NEW NON_KILLED identity absent from the base authority. New mutants must be killed; a PR " + + "cannot certify its own survivors." + assertEquals(expected, rejected.message) + } + + // ── T1 (ceremony level): the case-3 discriminator ── + // + // Four campaigns produced four distinct raw-status digests: three identities of 2544 oscillating + // only SURVIVED<->TIMED_OUT, all NON_KILLED in every campaign, while identity, outcome, family, + // module and analyzer semantics were identical. An authorization minted against one such + // measurement must remain consumable in another. Binding neighbouring raw status (the previous + // behaviour) rejected it, which is the defect this slice removes. + + @Test + fun `T1 a neighbour's raw status movement does not invalidate an authorized consumption`() { + val target = row("target") + val neighbourMinted = row("neighbour", status = "SURVIVED") + val neighbourConsumed = row("neighbour", status = "TIMED_OUT") + val mintedAgainst = population(listOf(target, neighbourMinted)) + val fresh = population(listOf(target, neighbourConsumed)) + + // Raw status moved between the two measurements; authority content did not. + assertEquals(digestOf(mintedAgainst), digestOf(fresh)) + // The raw-exact measurement proof still distinguishes them, so nothing was relaxed where raw + // status genuinely is authority (M21 exact comparison / M32 exact row). + assertNotEquals(rawDigestOf(mintedAgainst), rawDigestOf(fresh)) + + // The authorization was minted against the measurement where the neighbour was SURVIVED and + // is consumed in the measurement where it is TIMED_OUT. It must still be authorized: the + // target is candidate-only NON_KILLED, the row matches exactly, and the population context + // is unchanged in authority terms. + val diagnostics = + verifyAdmission( + basePopulation = population(listOf(neighbourMinted)), + baseAdmissions = admissions(admission("target", populationDigest = digestOf(mintedAgainst))), + candidatePopulation = fresh, + candidateAdmissions = MutationPopulationAdmissions.NONE, + evidence = evidence(fresh), + ) + passes(diagnostics) + } + + // ── M34 with certified migration (Step 3b) ── + // + // A raw-v1 authorization is consumable under authority-v2 only when a base certificate translates + // exactly its historical digest into this transition's fresh authority projection. Without that + // fact M34 must still fail, so the escape cannot be reached by asserting anything. + + private fun certificate( + rawDigest: String, + toDigest: String, + identity: String, + ) = MutationAuthorityDigestCertificate( + fromAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_RAW_V1, + fromDigest = rawDigest, + toAlgorithm = MutationAuthorityDigestCertificates.ALGORITHM_AUTHORITY_V2, + toDigest = toDigest, + admissionSetDigest = + MutationAuthorityDigestCertificates.admissionSetDigest(listOf(identity)), + fromBaseSha = "a".repeat(40), + reason = "test certificate", + ) + + @Test + fun `certifiedConsumptions produces the fact from a base certificate and refuses it otherwise`() { + val target = row("target") + val fresh = population(listOf(target)) + val rawDigest = "1".repeat(64) + val certificates = + MutationAuthorityDigestCertificates("1", listOf(certificate(rawDigest, digestOf(fresh), target.identity))) + val base = admissions(admission("target", populationDigest = rawDigest)) + + assertEquals(1, certifiedConsumptions(certificates, base, digestOf(fresh)).size) + // A projection this certificate does not certify yields no fact at all (M40). + assertTrue(certifiedConsumptions(certificates, base, "0".repeat(64)).isEmpty()) + // No certificate at all yields no fact: the fail-closed state. + assertTrue( + certifiedConsumptions(MutationAuthorityDigestCertificates.NONE, base, digestOf(fresh)).isEmpty(), + ) + } + + @Test + fun `M34 accepts a raw-v1 admission when a base certificate covers exactly its historical digest`() { + val target = row("target") + val fresh = population(listOf(target)) + val rawDigest = "1".repeat(64) + val certificate = certificate(rawDigest, digestOf(fresh), target.identity) + val fact = + verifyCertificateConsumption( + certificate = certificate, + baseCertificates = MutationAuthorityDigestCertificates("1", listOf(certificate)), + citedAdmissionPopulationDigest = rawDigest, + baseAdmissionIdentities = listOf(target.identity), + freshAuthorityProjectionHash = digestOf(fresh), + ) + assertTrue(fact is CertificateConsumption.Valid, "expected a valid consumption, got $fact") + + val verdict = + MutationPopulationAdmissionCeremony.appearanceVerdict( + baseAdmission = admission("target", populationDigest = rawDigest), + candidateAdmission = null, + mutant = target, + candidateAnalyzer = fresh.analyzer, + authority = AdmissionAuthority(digestOf(fresh), setOf(fact as CertificateConsumption.Valid)), + ) + + assertTrue(verdict is AdmissionVerdict.Authorized, "expected an authorized verdict, got $verdict") + } + + @Test + fun `M34 still fails a raw-v1 admission with no certified migration`() { + val target = row("target") + val fresh = population(listOf(target)) + val rawDigest = "1".repeat(64) + + val rejected = + MutationPopulationAdmissionCeremony.appearanceVerdict( + baseAdmission = admission("target", populationDigest = rawDigest), + candidateAdmission = null, + mutant = target, + candidateAnalyzer = fresh.analyzer, + authority = AdmissionAuthority(digestOf(fresh)), + ) as? AdmissionVerdict.Rejected + + assertNotNull(rejected) + assertTrue(rejected.message.startsWith("M34:"), rejected.message) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionIntegrationTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionIntegrationTest.kt new file mode 100644 index 000000000..2fafce025 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionIntegrationTest.kt @@ -0,0 +1,724 @@ +package dev.tramai.build.quality + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.UnexpectedBuildResultException +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The authority-transport proof for population admission (0.7.1g1G4z2). + * + * The ceremony was already proved at verifier level (M30-M39) and the ledger wiring was proved at + * task level, but neither proved the transport: that the REAL `verifyMutationRatchet` task, under + * RECORDED_EVOLUTION, measures the candidate population ITSELF, derives the trusted projection hash + * from that fresh measurement, consumes a previously minted base-side authorization with it, and + * rejects retention of an authorization it just consumed (M38). + * + * Everything here is real: real git history, the real wrapper, the real nested Gradle + PIT + * measurement through `runNestedGradle`, the real YAML ledgers. The digest the authorization binds + * is never fabricated - it is the projection hash of a population this fixture actually measured. + * + * Fixture git history. The authority base is an ANCESTOR of both transitions under test, exactly as a + * real PR consumes authority that its base already carries: + * + * - `B0` base source; + * - `B1` + base population - the base the authorization is minted against; + * - `S1` scratch candidate source on a side branch - measured, then abandoned, so it is never an + * ancestor of the authority base (the measurement is data, the ancestry would be fiction); + * - `C` authority base: base source + base population + the minted authorization; + * - `C-consumed` candidate source + candidate population, the authorization consumed (removed); + * - `C-retained` candidate source + candidate population, the authorization still in place. + * + * The two candidate states differ in exactly one thing - whether the authorization is still there - + * so the cases isolate single use from every other rule. The ancestry of each transition from the + * authority base is asserted below, so a later refactor cannot quietly weaken the proof. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Tag("integration") +@Tag("slow") +class MutationPopulationAdmissionIntegrationTest { + private lateinit var fixture: File + private lateinit var stashRoot: File + private lateinit var baseSha: String + + /** The fixture's main branch, whose tip is the authority base. */ + private lateinit var mainBranch: String + + /** The consuming transition: a child of the authority base, with the authorization removed. */ + private lateinit var consumedCandidateSha: String + + /** The retaining transition: the same candidate state as a child of the authority base. */ + private lateinit var retainedCandidateSha: String + private var appearingIdentities: List = emptyList() + + @BeforeAll + fun prepareAuthorityTransportFixture() { + val tempRoot = Files.createTempDirectory("tramai-population-admission-").toFile() + println("[admission-integration] fixture root: $tempRoot") + stashRoot = File(tempRoot, "stash").apply { mkdirs() } + fixture = File(tempRoot, "fixture").apply { mkdirs() } + + installWrapper(repositoryRoot) + // The measurement's provenance gate reads this repository, so the fixture IS a repository. + git("init") + git("config", "user.email", "admission-integration@tramai.dev") + git("config", "user.name", "PopulationAdmissionIntegration") + mainBranch = git("symbolic-ref", "--short", "HEAD").trim() + writeQualityConfig() + writeSubjectModule() + writeBuildScript(pluginClasspath()) + + // B0/B1: the measured base. B1 is the base the authorization is minted against. + writeSubject(BASE_SOURCE) + commit("base source") + + val basePopulation = File(stashRoot, "base-population.json") + measureInto(basePopulation, "base measurement") + commit("base population") + val mintingBaseSha = headSha() + + // S1: the future candidate measurement, taken on a side branch and then abandoned. It supplies + // the population the authorization binds, but it must not become an ancestor of the authority + // base below - that would model a transition no PR can have. + writeSubject(CANDIDATE_SOURCE) + commit("scratch candidate source") + val candidatePopulation = File(stashRoot, "candidate-population.json") + measureInto(candidatePopulation, "candidate measurement") + git("branch", "scratch-candidate-measurement") + git("checkout", "-f", "-B", mainBranch, mintingBaseSha) + + val base = readPopulation(basePopulation) + val candidate = readPopulation(candidatePopulation) + assertAuthorityTransportPreconditions(base, candidate) + appearingIdentities = appearingNonKilled(base, candidate) + + // C: the authority base. It carries the base state plus the authorization a FUTURE transition + // consumes: an authorization is merged into the base, never created by the transition that uses + // it (M31). `fromBaseSha` records the base of the minting transition. + writeAdmissionLedger(candidate, mintingBaseSha, authorityProjectionDigest(candidate)) + commit("authority base with minted population authorizations") + baseSha = headSha() + git("branch", "authority-base") + + // Both transitions under test are DESCENDANTS of the authority base, as a real PR is: one + // consumes the authorization, the other retains it. + consumedCandidateSha = + commitCandidateTransition( + branch = "consumed-candidate", + population = candidatePopulation, + retainAuthorization = false, + message = "candidate consuming the authorization", + ) + retainedCandidateSha = + commitCandidateTransition( + branch = "retained-candidate", + population = candidatePopulation, + retainAuthorization = true, + message = "candidate retaining the consumed authorization", + ) + + assertAuthorityTopology(git("rev-parse", "scratch-candidate-measurement").trim()) + } + + /** + * The topology this proof depends on. The authority base is an ancestor of both transitions under + * test, and the scratch candidate measurement is NOT an ancestor of the authority base: a base that + * already contained the candidate state would model a transition no PR can have. + */ + private fun assertAuthorityTopology(scratchCandidateSha: String) { + assertTrue( + isAncestor(baseSha, consumedCandidateSha), + "the authority base must be an ancestor of the consuming transition: $baseSha -> $consumedCandidateSha", + ) + assertTrue( + isAncestor(baseSha, retainedCandidateSha), + "the authority base must be an ancestor of the retaining transition: $baseSha -> $retainedCandidateSha", + ) + assertTrue( + !isAncestor(scratchCandidateSha, baseSha), + "the scratch candidate measurement must not be an ancestor of the authority base: " + + "a base cannot already contain the candidate state it judges ($scratchCandidateSha -> $baseSha)", + ) + } + + /** + * The identities this transition makes appear as NON_KILLED: exactly what the minted + * authorization has to cover, derived from two real measurements rather than assumed. + */ + private fun appearingNonKilled( + base: MutationPopulationBaseline, + candidate: MutationPopulationBaseline, + ): List = + candidate.mutants + .filter { it.outcome == MutationRatchetVerifier.NON_KILLED } + .map { it.identity } + .filter { id -> base.mutants.none { it.identity == id } } + .sorted() + + /** + * A transition that starts from the authority base: the candidate state, with the minted + * authorization either consumed (removed) or retained. Returns the new commit. + */ + private fun commitCandidateTransition( + branch: String, + population: File, + retainAuthorization: Boolean, + message: String, + ): String { + git("checkout", "-B", branch, baseSha) + writeSubject(CANDIDATE_SOURCE) + writePopulation(population) + if (!retainAuthorization) admissionLedgerFile().delete() + commit(message) + return headSha() + } + + /** + * The positive P2: the authorization the base minted is consumed by the exact row it authorized, + * under the exact analyzer semantics and the exact population digest. The task itself produces + * that digest from a fresh nested PIT measurement. + */ + @Test + fun `the real ratchet measures the candidate, consumes the base authorization and passes`() { + // C3: the candidate state as a well-formed P2 - the consumed authorization is gone. + checkout(consumedCandidateSha) + assertTrue( + !admissionLedgerFile().isFile, + "the consumption candidate must not carry the authorization ledger: ${admissionLedgerFile()}", + ) + + val (ok, output) = verifyRatchet() + println("[admission-integration] consumption transition:" + NL + decisiveLines(output)) + + assertTrue( + output.contains("canonicalMutationProbe"), + "verifyMutationRatchet must run the real nested PIT measurement:\n$output", + ) + assertTrue( + output.contains("Critical mutation measurement:"), + "the nested measurement must produce a population:\n$output", + ) + assertTrue(ok, "the consumed authorization must be admitted:\n$output") + assertTrue( + output.contains("baseline verification PASSED"), + "expected the ratchet to pass on this transition:\n$output", + ) + // Any of these would mean the appearing rows were NOT admitted by the base authorization. + listOf("M06", "M30", "M33", "M34", "M37", "M38").forEach { code -> + assertTrue(!output.contains(code), "unexpected $code in a consumed-authorization pass:\n$output") + } + } + + /** + * The negative: the authorization that admitted the row is still present. An authorization is + * single-use, so the same transition that passes above must fail M38. + */ + @Test + fun `the real ratchet rejects an authorization retained after its own consumption`() { + checkout(retainedCandidateSha) + assertTrue(admissionLedgerFile().isFile, "the retained-authorization candidate must carry the ledger") + + val (ok, output) = verifyRatchet() + println("[admission-integration] retained-authorization transition:" + NL + decisiveLines(output)) + + assertTrue(!ok, "retaining a consumed authorization must fail the task:\n$output") + assertTrue(output.contains("M38"), "expected the single-use failure M38:\n$output") + assertTrue( + output.contains(appearingIdentities.first().take(8)), + "M38 must name the retained authorization's identity:\n$output", + ) + } + + /** + * T7 at TASK level — the half a verifier-level test cannot reach, because it never runs the + * production transport (`verifyMutationRatchet` -> RECORDED_EVOLUTION -> nested + * `canonicalMutationProbe` -> fresh PIT population -> `exactComparison` -> proof -> verifier). + * + * The perturbation is deliberately AUTHORITY-EQUIVALENT: it moves one raw PIT status, and + * authority-v2 excludes raw status, so the candidate's own authority projection is untouched. + * A task that accepted a candidate-supplied authority digest would still pass, and the first + * assertion proves this perturbation really is authority-equivalent. + * + * It must fail instead: the fresh exact measurement has to establish the proof before any + * authority is recognised, which is what stops a candidate from putting its own authority + * context in front of M34/M44/M47. + */ + @Test + fun `T7 a candidate baseline that only moves raw PIT status cannot supply the authority context`() { + // The same transition that passes unperturbed (the first test in this class). + checkout(consumedCandidateSha) + val baseline = File(fixture, "config/quality/mutation-baseline.json") + val committed = readPopulation(baseline) + val target = + committed.mutants.first { row -> + // Deliberately NOT an identity this transition authorises: moving an authorised row's + // raw status is M32/M37 business (T3), and would make this test prove the wrong rule. + row.identity !in appearingIdentities && + row.status == "SURVIVED" && + row.family in committed.byFamily + } + val displaced = + committed.copy( + mutants = + committed.mutants.map { row -> + if (row.identity == target.identity) { + row.copy(status = "TIMED_OUT") + } else { + row + } + }, + // `byFamily` is derived truth. Moving raw status without moving the counters leaves a + // hand-edited ledger, and the task fails closed on THAT instead of on authority - + // which would make this test evidence for the wrong property. + byFamily = + committed.byFamily.mapValues { (family, entry) -> + if (family != target.family) { + entry + } else { + entry.copy( + survivedMutants = entry.survivedMutants - 1, + timedOutMutants = entry.timedOutMutants + 1, + ) + } + }, + ) + + assertTrue(committed != displaced, "the perturbation must actually change the population") + assertEquals( + authorityProjectionDigest(committed), + authorityProjectionDigest(displaced), + "the perturbation must leave the authority projection untouched, or this proves less", + ) + + ReportNormalizer.writeJson(displaced, baseline) + // The candidate state must be COMMITTED. The measurement's provenance gate requires a clean + // fixture worktree, so a dirty edit would fail the task for that reason - proving nothing + // about authority - and would also poison the sibling tests that checkout their own SHAs. + commit("T7: displace one raw PIT status in the committed candidate baseline") + + val (ok, output) = verifyRatchet() + println("[admission-integration] T7 raw-status-displaced candidate:" + NL + decisiveLines(output)) + + assertTrue(!ok, "an authority-equivalent but non-identical baseline must fail the task:\n$output") + assertTrue( + output.contains("no proof of the canonical fresh measurement"), + "the FRESH measurement must be what refuses the admission, not a candidate-side claim:\n" + + decisiveLines(output), + ) + // It must fail on the authority decision and not on a fixture artifact. `byFamily` would mean + // a hand-edited counter; `M32` would mean the perturbation touched an authorised row and this + // test is proving the status-inclusive row binding (T3) instead of T7. + // + // M37 is deliberately NOT in this list: it is the designed cascade of the same missing proof + // (no authority context -> the retained admissions cannot be recognised as consumed), not an + // artifact of how the perturbation was built. Forbidding it would demand a discriminator that + // isolates the fresh path from its own fail-closed consequences, which does not exist. + listOf("byFamily", "M32").forEach { artifact -> + assertTrue( + !output.contains(artifact), + "this discriminator must isolate T7, but the task reported the fixture artifact $artifact:\n" + + decisiveLines(output), + ) + } + } + + // ── fixture construction ── + + /** + * The build-logic plugin classpath as TestKit itself resolves it, rendered literally into the + * fixture's build script. `withPluginClasspath()` only ever reaches the OUTER invocation, so a + * fixture whose production task spawns `runNestedGradle` must bootstrap plugin resolution in + * the build script itself - otherwise the nested process cannot resolve the plugin at all. + */ + private fun pluginClasspath(): List = + GradleRunner + .create() + .withPluginClasspath() + .pluginClasspath + .map { it.absolutePath } + + private fun writeBuildScript(classpath: List) { + val quote = '"' + val entries = classpath.joinToString("," + System.lineSeparator()) { "$quote$it$quote" } + write( + "build.gradle.kts", + """ + // Identical plugin resolution in the outer TestKit build and in the nested `gradlew` + // process the mutation measurement spawns. + buildscript { + dependencies { + classpath( + files( + $entries + ), + ) + } + } + + println("[fixture] plugin classpath entries: " + buildscript.configurations.getByName("classpath").files.size) + + apply(plugin = "tramai.maintainability-baseline") + """.trimIndent() + System.lineSeparator(), + ) + } + + /** + * The repository under test. Fails with an explicit diagnostic when the property is missing, the + * same way the other build-logic integration tests do: without it the nested wrapper cannot be + * located at all, and a null dereference would hide that. + */ + private val repositoryRoot = + File( + requireNotNull(System.getProperty("tramai.repositoryRoot")) { + "tramai.repositoryRoot must be set (build-logic/build.gradle.kts test wiring); " + + "the fixture cannot install the real wrapper without it" + }, + ) + + private fun installWrapper(repositoryRoot: File) { + check(repositoryRoot.isDirectory) { "tramai.repositoryRoot is not a directory: $repositoryRoot" } + listOf("gradlew", "gradlew.bat").forEach { name -> + File(repositoryRoot, name).copyTo(File(fixture, name), overwrite = true) + } + File(fixture, "gradlew").setExecutable(true) + File(repositoryRoot, "gradle/wrapper").copyRecursively(File(fixture, "gradle/wrapper"), overwrite = true) + } + + private fun writeQualityConfig() { + write("settings.gradle.kts", "rootProject.name = \"population-admission\"" + NL + "include(\":subject\")" + NL) + write( + "config/quality/module-catalog.yml", + """ + schemaVersion: "3" + dependencyPolicies: + testing: + allowedLayers: [testing-support] + entryDefaults: + internal: &internal + layer: "testing-support" + maturity: "internal" + publishability: "internal" + apiStability: "excluded" + visibility: "internal" + owner: "testing" + dependencyPolicy: "testing" + releaseInclusion: "internal_only" + rationale: "Provides a TestKit fixture module." + modules: + - path: ":subject" + <<: *internal + """.trimIndent() + NL, + ) + write( + "config/quality/test-quality.yml", + """ + schemaVersion: "1" + criticalModules: [":subject"] + coverage: + regressionTolerancePercentagePoints: 1.0 + exclusions: [] + mutation: + regressionTolerancePercentagePoints: 1.0 + targetFamilies: + admission: + modules: [":subject"] + targetClasses: ["dev.tramai.pit.*"] + targetTests: ["dev.tramai.pit.*Test"] + """.trimIndent() + NL, + ) + write("config/quality/mutation-classifications.yml", "schemaVersion: \"1\"" + NL + "classifications: []" + NL) + write("config/quality/mutation-evolution.yml", "schemaVersion: \"1\"" + NL + "records: []" + NL) + write(".gitignore", "build/" + NL + ".gradle/" + NL) + } + + /** The `:subject` module the measurement actually mutates. */ + private fun writeSubjectModule() { + write( + "subject/build.gradle.kts", + """ + plugins { `java-library` } + + repositories { mavenCentral() } + + dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.12.2") + } + + tasks.test { useJUnitPlatform() } + """.trimIndent() + NL, + ) + write( + "subject/src/test/java/dev/tramai/pit/SubjectTest.java", + """ + package dev.tramai.pit; + + import static org.junit.jupiter.api.Assertions.assertEquals; + + import org.junit.jupiter.api.Test; + + class SubjectTest { + private final Subject subject = new Subject(); + + @Test + void adds() { + assertEquals(3, subject.add(1, 2)); + } + + @Test + void scalesAboveThreshold() { + assertEquals(24, subject.scale(12)); + } + } + """.trimIndent() + NL, + ) + } + + private fun writeSubject(source: String) { + write("subject/src/main/java/dev/tramai/pit/Subject.java", source) + } + + /** + * The authorization for every identity the candidate transition makes appear. Data-driven on + * purpose: which rows appear is a property of the measurement, not something this fixture may + * assume, and every appearing NON_KILLED identity needs its own exact authorization. + */ + private fun writeAdmissionLedger( + population: MutationPopulationBaseline, + mintBaseSha: String, + digest: String, + ) { + val analyzer = population.analyzer + val byIdentity = population.mutants.associateBy { it.identity } + val lines = mutableListOf("schemaVersion: \"1\"", "admissions:") + for (id in appearingIdentities) { + val row = byIdentity.getValue(id) + lines += " - identity: \"${row.identity}\"" + lines += " status: \"${row.status}\"" + lines += " outcome: \"${row.outcome}\"" + lines += " family: \"${row.family}\"" + lines += " module: \"${row.module}\"" + lines += " analyzer:" + lines += " pluginVersion: \"${analyzer.pluginVersion}\"" + lines += " engineVersion: \"${analyzer.engineVersion}\"" + lines += " mutators:" + analyzer.mutators.forEach { lines += " - \"$it\"" } + lines += " timeoutConst: ${analyzer.timeoutConst}" + lines += " timeoutFactor: ${analyzer.timeoutFactor}" + lines += " fromBaseSha: \"$mintBaseSha\"" + lines += " populationDigest: \"$digest\"" + lines += " reason: \"adjudicated survivor admitted by the transition that consumes it\"" + lines += " authorizedBy: \"fixture\"" + lines += " authorizedAt: \"1970-01-01T00:00:00Z\"" + } + write(MutationPopulationAdmissionLoader.FILE_NAME, lines.joinToString(NL) + NL) + } + + /** + * The verifier's own trusted **authority projection** digest computation, never a value this + * test invents: the same call the task makes to derive the proof M34 compares an authorization + * against. It must be the authority projection (identity, canonical outcome, family, module, + * topology, analyzer) and NOT the raw-exact measurement proof: production M34 compares + * `authorityProjectionHash`, so a fixture minting `projectionHash` would exercise the wrong + * digest semantics and pass while the real transport is broken. + */ + private fun authorityProjectionDigest(population: MutationPopulationBaseline): String = + MutationPopulationEvolutionProof + .exactComparison(population, population) + .proof + ?.authorityProjectionHash + ?: error("the fixture population is not self-comparable, so no digest can be minted") + + private fun assertAuthorityTransportPreconditions( + base: MutationPopulationBaseline, + candidate: MutationPopulationBaseline, + ) { + val baseIds = base.mutants.map { it.identity }.toSet() + val candidateIds = candidate.mutants.map { it.identity }.toSet() + val disappeared = baseIds - candidateIds + assertTrue( + disappeared.isEmpty(), + "the base population must survive the candidate measurement (M21 would reject the fixture " + + "before it could exercise admission): $disappeared", + ) + val appearingNonKilled = + candidate.mutants + .filter { it.outcome == MutationRatchetVerifier.NON_KILLED } + .map { it.identity } + .filter { it !in baseIds } + assertTrue( + appearingNonKilled.isNotEmpty(), + "the candidate source must produce at least one appearing NON_KILLED identity, otherwise " + + "the admission ceremony is never reached: base=$baseIds candidate=$candidateIds", + ) + assertTrue( + authorityProjectionDigest(base) != authorityProjectionDigest(candidate), + "the two populations must differ", + ) + } + + // ── task driving ── + + private fun measureInto( + destination: File, + label: String, + ) { + val (ok, output) = runTask("generateCriticalMutationBaseline") + assertTrue(ok, "$label failed:\n$output") + assertTrue( + output.contains("Critical mutation measurement:"), + "$label produced no population:\n$output", + ) + val population = File(fixture, "config/quality/mutation-baseline.json") + assertTrue(population.isFile, "$label wrote no ${population.name}") + population.copyTo(destination, overwrite = true) + } + + private fun verifyRatchet(): Pair = + runTask( + "verifyMutationRatchet", + "-PtramaiMutationBaseSha=$baseSha", + "-P${MutationPopulationEvolution.PROPERTY}=recorded-evolution", + ) + + /** + * The decisive lines of a ratchet run: the ceremony's own diagnostics and the measurement's + * population. Printed on success too, so a CI log shows what the transition actually decided + * rather than only that it did not fail. + */ + private fun decisiveLines(output: String): String = + output + .lines() + .filter { + it.contains("M3") || + it.contains("baseline verification") || + it.contains("Critical mutation measurement") || + it.contains("Family ") + }.joinToString(NL) + + private fun runTask(vararg arguments: String): Pair { + val runner = + GradleRunner + .create() + .withProjectDir(fixture) + .withArguments(arguments.toList() + listOf("--stacktrace", "--console=plain")) + return try { + true to runner.build().output + } catch (e: UnexpectedBuildResultException) { + false to (e.buildResult?.output ?: e.message.orEmpty()) + } + } + + // ── repository state ── + + private fun writePopulation(source: File) { + source.copyTo(File(fixture, "config/quality/mutation-baseline.json"), overwrite = true) + } + + private fun readPopulation(file: File): MutationPopulationBaseline = + ReportNormalizer.readJson(file, MutationPopulationBaseline::class.java) + + private fun admissionLedgerFile(): File = File(fixture, MutationPopulationAdmissionLoader.FILE_NAME) + + private fun checkout(sha: String) { + git("checkout", "--detach", sha) + assertTrue(cleanTree(), "the fixture worktree must be clean at $sha or the measurement's provenance gate fails") + } + + private fun cleanTree(): Boolean = git("status", "--porcelain").trim().isEmpty() + + private fun headSha(): String = git("rev-parse", "HEAD").trim() + + private fun commit(message: String) { + git("add", "-A") + git("commit", "-m", message) + } + + /** `git merge-base --is-ancestor`: the topology this proof depends on. */ + private fun isAncestor( + ancestor: String, + descendant: String, + ): Boolean = gitExit("merge-base", "--is-ancestor", ancestor, descendant) == 0 + + private fun gitExit(vararg args: String): Int = + ProcessBuilder(listOf("git") + args) + .directory(fixture) + .redirectErrorStream(true) + .start() + .let { process -> + process.inputStream.bufferedReader().use { it.readText() } + process.waitFor() + } + + private fun git(vararg args: String): String { + val process = + ProcessBuilder(listOf("git") + args) + .directory(fixture) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + check(process.waitFor() == 0) { "git ${args.joinToString(" ")} failed: $output" } + return output + } + + private fun write( + path: String, + content: String, + ) { + val file = File(fixture, path) + file.parentFile.mkdirs() + file.writeText(content) + } + + private companion object { + const val NL = "\n" + + val BASE_SOURCE = + """ + package dev.tramai.pit; + + public final class Subject { + public int add(int a, int b) { + return a + b; + } + + public int scale(int value) { + if (value > 10) { + return value * 2; + } + return value + 1; + } + } + """.trimIndent() + "\n" + + val CANDIDATE_SOURCE = + """ + package dev.tramai.pit; + + public final class Subject { + public int add(int a, int b) { + return a + b; + } + + public int scale(int value) { + if (value > 10) { + return value * 2; + } + return value + 1; + } + + // Adjudicated as an accepted survivor candidate: measured, yet not exercised by any + // test, so it appears in the candidate population as NON_KILLED. + public int adjudicated(int value) { + return value * 3; + } + } + """.trimIndent() + "\n" + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionLoaderTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionLoaderTest.kt new file mode 100644 index 000000000..06ae9a2ec --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionLoaderTest.kt @@ -0,0 +1,67 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The population-admission ledger's analyzer semantics are ORDER-SENSITIVE authority: the canonical + * analyzer records the mutators in the PIT renderer's declaration order, and M16-M19 compare those + * lists as lists. A ledger that re-orders them describes different semantics, so the loader must + * preserve what the ledger declares. This is the discriminator for the one-line production rule: + * sorting anywhere on the load path makes an authorized row unreachable at M33 before admission. + */ +class MutationPopulationAdmissionLoaderTest { + @TempDir + lateinit var tempDir: File + + @Test + fun `the loader preserves the ledger's mutator declaration order`() { + // The canonical PIT renderer order, which is NOT alphabetical: sorting anywhere on the load + // path re-orders it (CONDITIONALS_BOUNDARY, EMPTY_RETURNS, ...) and describes other semantics. + val declared = MutationProbeInitScript.PIT_MUTATORS + assertTrue(declared != declared.sorted(), "this discriminator needs an order that sorting would change") + writeLedger(declared) + + val loaded = MutationPopulationAdmissionLoader.load(tempDir) + + val admission = loaded.admissions.single() + assertEquals(declared, admission.analyzer.mutators, "declaration order is part of the authorized semantics") + assertTrue(admission.analyzer.mutators == declared, "no re-ordering on the load path") + } + + @Test + fun `an absent ledger is no authority rather than a failure`() { + val loaded = MutationPopulationAdmissionLoader.load(tempDir) + + assertEquals(MutationPopulationAdmissions.NONE.admissions, loaded.admissions) + } + + private fun writeLedger(mutators: List) { + val lines = + mutableListOf( + "schemaVersion: \"1\"", + "admissions:", + " - identity: \"" + "a".repeat(64) + "\"", + " status: \"NO_COVERAGE\"", + " outcome: \"NON_KILLED\"", + " family: \"admission\"", + " module: \":subject\"", + " analyzer:", + " pluginVersion: \"1.19.0\"", + " engineVersion: \"1.22.1\"", + " mutators:", + ) + mutators.forEach { lines += " - \"$it\"" } + lines += " timeoutConst: 4000" + lines += " timeoutFactor: 1.25" + lines += " fromBaseSha: \"" + "b".repeat(40) + "\"" + lines += " populationDigest: \"" + "c".repeat(64) + "\"" + lines += " reason: \"loader-order discriminator\"" + val file = File(tempDir, MutationPopulationAdmissionLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText(lines.joinToString("\n") + "\n") + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionWiringTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionWiringTest.kt new file mode 100644 index 000000000..816c47d87 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationAdmissionWiringTest.kt @@ -0,0 +1,365 @@ +package dev.tramai.build.quality + +import dev.tramai.build.quality.TestQualityConfiguration.MutationTargetFamily +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Task-level discriminators for the population-admission ledger wiring (0.7.1g1G4z). + * + * The M30-M39 ceremony was proved at verifier level; what these tests prove is the boundary the + * verifier cannot: that `verifyMutationRatchet` — the actual governance task — loads the + * repository's own `config/quality/mutation-population-admissions.yml` into the candidate it judges. + * + * Every case changes one real repository file and observes the real task: + * + * - the ledger absent: an appearing survivor still fails M06 (non-vacuous baseline); + * - the ledger present with an authorization minted against a different base: M35 fires, which is + * only possible if the task actually read the candidate ledger. If the candidate ledger were + * silently defaulted to "none", this case would fail with M06 alone and M35 could never fire; + * - the ledger present but malformed: the task fails closed on the loader error instead of + * degrading to "no authorizations". + */ +class MutationPopulationAdmissionWiringTest : MutationRatchetTestSupport() { + @TempDir + lateinit var tempDir: File + + private val sampleFamily = + MutationTargetFamily( + modules = listOf(":sample"), + targetClasses = listOf("dev.tramai.policy.*"), + targetTests = listOf("dev.tramai.policy.PolicyTest"), + ) + + private val sampleFamilies = mapOf(policyFamily to sampleFamily) + + private val appearingMarker = "appearing" + + private fun basePopulation() = + population( + listOf(row("anchor", module = ":sample", status = "KILLED", outcome = "KILLED")), + families = sampleFamilies, + measuredCommit = "base-head", + ) + + private fun candidatePopulation() = + population( + listOf( + row("anchor", module = ":sample", status = "KILLED", outcome = "KILLED"), + row(appearingMarker, module = ":sample"), + ), + families = sampleFamilies, + measuredCommit = "candidate-head", + ) + + private fun fixture(): File { + val dir = File(tempDir, "admission-wiring").apply { mkdirs() } + write( + dir, + "settings.gradle.kts", + """ + rootProject.name = "admission-wiring" + include(":sample") + """.trimIndent(), + ) + write( + dir, + "build.gradle.kts", + """ + plugins { id("tramai.maintainability-baseline") } + """.trimIndent(), + ) + write(dir, "sample/build.gradle.kts", "plugins { `java-library` }") + writeQualityConfig(dir) + return dir + } + + /** The authority surface the ratchet task reads; only the population and the ledger vary per case. */ + private fun writeQualityConfig(dir: File) { + write( + dir, + "config/quality/module-catalog.yml", + """ + schemaVersion: "3" + dependencyPolicies: + testing: + allowedLayers: [testing-support] + entryDefaults: + internal: &internal + layer: "testing-support" + maturity: "internal" + publishability: "internal" + apiStability: "excluded" + visibility: "internal" + owner: "testing" + dependencyPolicy: "testing" + releaseInclusion: "internal_only" + rationale: "Provides a TestKit fixture module." + modules: + - path: ":sample" + <<: *internal + """.trimIndent(), + ) + write( + dir, + "config/quality/test-quality.yml", + """ + schemaVersion: "1" + criticalModules: [":sample"] + coverage: + regressionTolerancePercentagePoints: 1.0 + exclusions: [] + mutation: + regressionTolerancePercentagePoints: 1.0 + targetFamilies: + $policyFamily: + modules: [":sample"] + targetClasses: ["dev.tramai.policy.*"] + targetTests: ["dev.tramai.policy.PolicyTest"] + """.trimIndent(), + ) + write(dir, "config/quality/mutation-classifications.yml", "schemaVersion: \"1\"\nclassifications: []\n") + write(dir, "config/quality/mutation-evolution.yml", "schemaVersion: \"1\"\nrecords: []\n") + } + + private fun write( + dir: File, + path: String, + content: String, + ) { + val file = File(dir, path) + file.parentFile.mkdirs() + file.writeText(content) + } + + private fun writePopulation( + dir: File, + population: MutationPopulationBaseline, + ) { + ReportNormalizer.writeJson(population, File(dir, "config/quality/mutation-baseline.json")) + } + + /** + * A ledger entry proposing the appearing identity, written with the canonical analyzer semantics + * the population carries: the mutator list is order-sensitive authority (M33), so the ledger + * records the canonical declaration order verbatim. + */ + private fun admissionLedger( + identity: String, + fromBaseSha: String, + reason: String = "wiring fixture", + ): String { + val mutatorLines = semantics.mutators.joinToString("\n") { " - \"$it\"" } + return """ + schemaVersion: "1" + admissions: + - identity: "$identity" + status: "SURVIVED" + outcome: "NON_KILLED" + family: "$policyFamily" + module: ":sample" + analyzer: + pluginVersion: "${semantics.pluginVersion}" + engineVersion: "${semantics.engineVersion}" + mutators: +$mutatorLines + timeoutConst: ${semantics.timeoutConst} + timeoutFactor: ${semantics.timeoutFactor} + fromBaseSha: "$fromBaseSha" + populationDigest: "$projectionDigest" + reason: "$reason" + """.trimIndent() + "\n" + } + + /** + * Commits the base state and leaves the candidate state uncommitted, as a PR working tree is. + * + * When [ledger] is given it belongs to the BASE commit — the only place an authorization can be + * read from, since the base side is resolved with git and never from the working tree. + */ + private fun commitBase( + dir: File, + ledger: String? = null, + ): String { + writePopulation(dir, basePopulation()) + if (ledger != null) { + write(dir, "config/quality/mutation-population-admissions.yml", ledger) + } + git(dir, "init") + git(dir, "add", "-A") + git(dir, "-c", "user.email=fixture@test", "-c", "user.name=fixture", "commit", "-m", "base") + val sha = git(dir, "rev-parse", "HEAD").trim() + assertTrue(sha.matches(Regex("[0-9a-f]{40}")), "fixture base SHA must be a real commit, got '$sha'") + return sha + } + + private fun git( + dir: File, + vararg args: String, + ): String { + val process = + ProcessBuilder(listOf("git") + args) + .directory(dir) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + assertEquals(0, process.waitFor(), "git ${args.joinToString(" ")} failed: $output") + return output + } + + private fun runner( + dir: File, + baseSha: String, + ) = GradleRunner + .create() + .withProjectDir(dir) + .withArguments("verifyMutationRatchet", "-PtramaiMutationBaseSha=$baseSha", "--stacktrace") + .withPluginClasspath() + + private val appearingIdentity = row(appearingMarker, module = ":sample").identity + + private val wrongBaseSha = "1".repeat(40) + + /** The base ledger's own fromBaseSha is mint-time provenance; M35 judges candidate-side mints only. */ + private val neutralBaseSha = "0".repeat(40) + + /** Structural only: the loader never validates that a digest corresponds to a real campaign. */ + private val projectionDigest = "a".repeat(64) + + @Test + fun `an appearing survivor with no candidate ledger still fails M06`() { + val dir = fixture() + val baseSha = commitBase(dir) + writePopulation(dir, candidatePopulation()) + + val result = runner(dir, baseSha).buildAndFail() + + val output = result.output + assertTrue(output.contains("M06"), "expected the M06 new-survivor failure, got:\n$output") + assertTrue( + !output.contains("M35"), + "M35 must not fire when the candidate ledger is absent:\n$output", + ) + } + + @Test + fun `the candidate ledger is read by the real task and a wrong fromBaseSha fails M35`() { + val dir = fixture() + val baseSha = commitBase(dir) + writePopulation(dir, candidatePopulation()) + write( + dir, + "config/quality/mutation-population-admissions.yml", + admissionLedger(appearingIdentity, wrongBaseSha), + ) + + val result = runner(dir, baseSha).buildAndFail() + + assertTrue( + result.output.contains("M35"), + "expected the M35 fromBaseSha binding failure, which is only reachable when the task loads " + + "the candidate ledger, got:\n${result.output}", + ) + } + + @Test + fun `a malformed candidate ledger fails the real task closed`() { + val dir = fixture() + val baseSha = commitBase(dir) + writePopulation(dir, candidatePopulation()) + write( + dir, + "config/quality/mutation-population-admissions.yml", + "schemaVersion: \"1\"\nadmissions:\n - identity: \"$appearingIdentity\"\n", + ) + + val result = runner(dir, baseSha).buildAndFail() + + assertTrue( + result.output.contains("mutation-population-admissions.yml"), + "expected the loader's own hard failure for the malformed ledger, got:\n${result.output}", + ) + } + + // ── lifecycle at the real task boundary: MINT → RETAIN → CONSUME ── + + @Test + fun `a pending authorization retained byte-identically across an unrelated transition passes`() { + val dir = fixture() + val ledger = admissionLedger(appearingIdentity, fromBaseSha = neutralBaseSha) + val baseSha = commitBase(dir, ledger) + writePopulation(dir, basePopulation()) + + // The authorization is still pending: its target has not appeared, so nothing consumes it. + // Before the candidate ledger was wired into the task this was a hard M37 failure - the task + // judged the candidate as holding no authorizations and read the pending row as a silent + // cancellation of base authority. + val result = runner(dir, baseSha).build() + + assertTrue( + !result.output.contains("M37"), + "a byte-identical pending authorization must not be read as a silent removal:\n${result.output}", + ) + } + + @Test + fun `a retained authorization rewritten in a bound field fails M36`() { + val dir = fixture() + val ledger = admissionLedger(appearingIdentity, fromBaseSha = neutralBaseSha) + val baseSha = commitBase(dir, ledger) + writePopulation(dir, basePopulation()) + write( + dir, + "config/quality/mutation-population-admissions.yml", + admissionLedger(appearingIdentity, fromBaseSha = neutralBaseSha, reason = "rewritten after mint"), + ) + + val result = runner(dir, baseSha).buildAndFail() + + assertTrue(result.output.contains("M36"), "expected the immutable-retention failure, got:\n${result.output}") + } + + @Test + fun `an authorized row appearing with no trusted fresh proof fails M34`() { + val dir = fixture() + val ledger = admissionLedger(appearingIdentity, fromBaseSha = neutralBaseSha) + val baseSha = commitBase(dir, ledger) + writePopulation(dir, candidatePopulation()) + + val result = runner(dir, baseSha).buildAndFail() + + // The ledger records the canonical analyzer order, so the base authorization matches the + // appearing row's semantics and the transition reaches the digest check. This invocation is + // not RECORDED_EVOLUTION, so no trusted fresh measurement exists and admission must fail + // closed rather than admit on the candidate's word. M33 here would mean the load path + // re-ordered the authorization, and the transition never got this far. + assertTrue( + result.output.contains("M34"), + "expected the fail-closed digest check before admission, got:\n${result.output}", + ) + assertTrue( + !result.output.contains("M33"), + "the ledger records the canonical order, so the analyzer semantics must match:\n${result.output}", + ) + } + + @Test + fun `removing a pending authorization while the authorized row appears fails M37`() { + val dir = fixture() + val ledger = admissionLedger(appearingIdentity, fromBaseSha = neutralBaseSha) + val baseSha = commitBase(dir, ledger) + writePopulation(dir, candidatePopulation()) + File(dir, "config/quality/mutation-population-admissions.yml").delete() + + val result = runner(dir, baseSha).buildAndFail() + + assertTrue( + result.output.contains("M37"), + "an authorization may not be cancelled silently, got:\n${result.output}", + ) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationEvolutionProofTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationEvolutionProofTest.kt new file mode 100644 index 000000000..d955ddc4e --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationEvolutionProofTest.kt @@ -0,0 +1,120 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class MutationPopulationEvolutionProofTest : MutationRatchetTestSupport() { + @Test + fun `exact comparison detects identity status family and module drift`() { + val candidate = population(listOf(row("v1"))) + assertTrue( + MutationPopulationEvolutionProof + .exactComparison(population(listOf(row("v2"))), candidate) + .diagnostics + .any { it.message.contains("absent") }, + ) + assertTrue( + MutationPopulationEvolutionProof + .exactComparison( + population(listOf(row("v1", status = "KILLED", outcome = "KILLED"))), + candidate, + ).diagnostics + .any { it.message.contains("raw status") }, + ) + assertTrue( + MutationPopulationEvolutionProof + .exactComparison( + population(listOf(row("v1").copy(family = retryFamily))), + candidate, + ).diagnostics + .any { it.message.contains("family") }, + ) + assertTrue( + MutationPopulationEvolutionProof + .exactComparison( + population(listOf(row("v1").copy(module = ":other"))), + candidate, + ).diagnostics + .any { it.message.contains("module") }, + ) + } + + /** + * T7: the authority context M34/M44/M47 are judged on is the one this verifier computed from its + * own fresh measurement. A candidate cannot supply it - a proof exists only for a committed + * population that the fresh measurement matches exactly, and the authority projection on that + * proof is taken from the fresh side. There is deliberately no partial trust: a candidate that + * reports a different population, even in a field the authority projection ignores, obtains no + * proof at all rather than a proof it helped construct. + */ + @Test + fun `T7 the authority context comes from the fresh measurement never from the candidate`() { + val fresh = population(listOf(row("v1"))) + + val matched = MutationPopulationEvolutionProof.exactComparison(fresh, population(listOf(row("v1")))) + assertTrue(matched.diagnostics.isEmpty(), matched.diagnostics.toString()) + assertTrue(matched.proof != null, "an exactly matching candidate must obtain a proof") + + val candidates = + listOf( + // A field the authority projection deliberately ignores. + population(listOf(row("v1", status = "TIMED_OUT"))), + // A different population entirely. + population(listOf(row("v1"), row("v2"))), + ) + for (candidate in candidates) { + assertEquals( + null, + MutationPopulationEvolutionProof.exactComparison(fresh, candidate).proof, + "a candidate-side difference must yield no authority context", + ) + } + } + + @Test + fun `identical populations produce an evolution proof`() { + val population = population(listOf(row("v1"))) + val comparison = MutationPopulationEvolutionProof.exactComparison(population, population) + assertTrue(comparison.diagnostics.isEmpty()) + assertTrue(comparison.proof != null) + } + + // ── T1 / T2 / T8: the authority projection is authority content only ── + // + // The empirical defect: four complete unrestricted campaigns at identical effective PIT inputs + // produced four distinct raw-status digests — three identities of 2544 oscillating only between + // SURVIVED and TIMED_OUT, all NON_KILLED in every campaign — while the authority content was + // byte-identical. M34 bound the raw-status digest, so a scheduler race invalidated adjudicated + // authority. Raw status stays authoritative for the measurement proof (M21) and for each + // individual authorization's exact row (M32); it must not be population authority (C7). + + @Test + fun `T1 a neighbour's raw status movement leaves the authority projection unchanged`() { + val before = population(listOf(row("target"), row("neighbour", status = "SURVIVED"))) + val after = population(listOf(row("target"), row("neighbour", status = "TIMED_OUT"))) + assertEquals(digestOf(before), digestOf(after)) + // ...while the raw-exact measurement proof still distinguishes them, so nothing was relaxed + // where raw status genuinely is authority. + assertNotEquals(rawDigestOf(before), rawDigestOf(after)) + } + + @Test + fun `T2 an outcome change changes the authority projection`() { + val survivor = population(listOf(row("target"))) + val killer = population(listOf(row("target", status = "KILLED", outcome = "KILLED"))) + assertNotEquals(digestOf(survivor), digestOf(killer)) + } + + @Test + fun `T8 analyzer semantics change the authority projection`() { + val base = population(listOf(row("target"))) + val drifted = + population( + listOf(row("target")), + analyzer = semantics.copy(timeoutConst = semantics.timeoutConst + 1), + ) + assertNotEquals(digestOf(base), digestOf(drifted)) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationPromotionTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationPromotionTest.kt new file mode 100644 index 000000000..c12772ad4 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationPromotionTest.kt @@ -0,0 +1,272 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.File +import java.nio.file.Files + +/** + * TASK-0.7.1i: a promotion declaration carries an ALREADY-CERTIFIED evidence population across a + * release promotion boundary. + * + * M35 and M45 bind a newly introduced admission/certificate to the authority base it was minted + * against. A promotion cannot satisfy that binding legitimately - re-minting admissions, rewriting + * `fromBaseSha` and manufacturing replacement certificates are all forbidden - so it needs exactly + * one statement: this evidence was not minted here, it is the same certified population, carried + * across unchanged. + * + * These tests are the negative proofs. Each one changes a single field of an otherwise exact + * declaration, or removes it, and every one of them must still fail. + */ +class MutationPopulationPromotionTest : MutationRatchetTestSupport() { + private companion object { + const val CARRIED_DIGEST = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const val OTHER_DIGEST = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const val MINTING_SHA = "1111111111111111111111111111111111111111" + const val CERTIFICATE_MINTING_SHA = "2222222222222222222222222222222222222222" + const val OTHER_BASE_SHA = "ffffffffffffffffffffffffffffffffffffffff" + } + + /** The exact declaration for the fixture below: one carried admission, no certificates. */ + private fun declaration( + promotionBase: String = BASE_SHA, + populationDigest: String = CARRIED_DIGEST, + admissions: Int = 1, + certificates: Int = 0, + ) = MutationPopulationPromotion( + promotionBase = promotionBase, + populationDigest = populationDigest, + admissions = admissions, + certificates = certificates, + ) + + /** An admission minted on an earlier line: it does not bind the base this transition sees. */ + private fun carriedAdmission(digest: String = CARRIED_DIGEST) = + admission("appearing", populationDigest = digest, fromBaseSha = MINTING_SHA) + + private fun certificate(fromDigest: String = CARRIED_DIGEST) = + MutationAuthorityDigestCertificate( + fromAlgorithm = "raw-v1", + fromDigest = fromDigest, + toAlgorithm = "authority-v2", + toDigest = OTHER_DIGEST, + admissionSetDigest = OTHER_DIGEST, + fromBaseSha = CERTIFICATE_MINTING_SHA, + reason = "fixture", + ) + + private fun certificates(vararg records: MutationAuthorityDigestCertificate) = + MutationAuthorityDigestCertificates(schemaVersion = "1", certificates = records.toList()) + + /** + * The transition under test. Populations are identical across base and candidate so M06 is not + * in play: the only question is whether the ledger's mint binding still fires. + */ + private fun verify( + candidateAdmissions: MutationPopulationAdmissions = noAdmissions, + candidateCertificates: MutationAuthorityDigestCertificates = MutationAuthorityDigestCertificates.NONE, + promotion: MutationPopulationPromotion? = null, + ): List { + val shared = population(listOf(row("anchor"))) + return MutationRatchetVerifier().verify( + MutationRatchetAuthority( + BASE_SHA, + shared, + classifications(), + baseFamilies, + MutationClassificationEnrollments.NONE, + noAdmissions, + ), + MutationRatchetCandidate( + shared, + classifications(), + baseFamilies, + MutationClassificationEnrollments.NONE, + candidateAdmissions, + candidateCertificates, + promotion, + ), + MutationPopulationAggregator.canonicalSemantics(), + MutationPopulationEvolution.RECORDED_EVOLUTION, + evidence(shared), + ) + } + + // ── the exact declaration is the only thing that carries ── + + @Test + fun `an exact declaration carries an admission minted on the promotion line`() { + passes(verify(candidateAdmissions = admissions(carriedAdmission()), promotion = declaration())) + } + + @Test + fun `an exact declaration carries the digest-migration certificate`() { + passes( + verify( + candidateCertificates = certificates(certificate()), + promotion = declaration(admissions = 0, certificates = 1), + ), + ) + } + + // ── every other declaration carries nothing ── + + @Test + fun `no declaration leaves the mint binding in force`() { + val diagnostics = verify(candidateAdmissions = admissions(carriedAdmission())) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration for another promotion base carries nothing`() { + val diagnostics = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(promotionBase = OTHER_BASE_SHA), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration naming another population digest carries nothing`() { + val diagnostics = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(populationDigest = OTHER_DIGEST), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration whose admission count does not describe the carried set carries nothing`() { + // The real promotion names 67; 68 is the off-by-one this rule exists to reject, and the same + // mismatch one row up or down is rejected here. + val tooMany = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(admissions = 2), + ) + assertFailsWith(tooMany, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + val tooFew = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(admissions = 0), + ) + assertFailsWith(tooFew, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a declaration whose certificate count does not describe the carried set carries nothing`() { + // One certificate present, declaration says zero: not carried, so M45 still fires. + val zero = + verify( + candidateCertificates = certificates(certificate()), + promotion = declaration(admissions = 0, certificates = 0), + ) + assertFailsWith(zero, DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, "M45") + // No certificate present, declaration says two: the whole declaration is void, so the + // admission it names is not carried either. + val two = + verify( + candidateAdmissions = admissions(carriedAdmission()), + promotion = declaration(admissions = 1, certificates = 2), + ) + assertFailsWith(two, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a newly minted admission carrying another digest is not carried`() { + // The declaration names the certified population; a row whose digest is not that population + // was minted here and is bound to this base like any other new authorization. + val diagnostics = + verify( + candidateAdmissions = admissions(carriedAdmission(digest = OTHER_DIGEST)), + promotion = declaration(), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_ADMISSION_INVALID, "fromBaseSha") + } + + @Test + fun `a newly introduced certificate carrying another digest is not carried`() { + val diagnostics = + verify( + candidateCertificates = certificates(certificate(fromDigest = OTHER_DIGEST)), + promotion = declaration(admissions = 0, certificates = 1), + ) + assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, "M45") + } + + // ── the declaration itself: fail-closed parsing ── + + @Test + fun `an absent declaration file is no promotion`() { + val root = Files.createTempDirectory("promotion-absent").toFile() + assertEquals(null, MutationPopulationPromotionLoader.load(root)) + } + + @Test + fun `a declaration of the wrong shape is a hard failure, not an absent promotion`() { + // A malformed ledger must not silently degrade into "no promotion was needed": the counts are + // part of the declaration's meaning, so a missing one is invalid rather than zero. Structural + // faults surface as GradleException and canonicality faults as IllegalArgumentException, the + // same split the population-admissions loader uses - both are hard build failures. + val missingCount = + ledger( + """ + schemaVersion: "1" + promotions: + - promotionBase: "$MINTING_SHA" + populationDigest: "$CARRIED_DIGEST" + admissions: 67 + """.trimIndent(), + ) + assertThrows { MutationPopulationPromotionLoader.load(missingCount) } + + val badDigest = + ledger( + """ + schemaVersion: "1" + promotions: + - promotionBase: "$MINTING_SHA" + populationDigest: "not-a-digest" + admissions: 67 + certificates: 1 + """.trimIndent(), + ) + assertThrows { MutationPopulationPromotionLoader.load(badDigest) } + } + + @Test + fun `a well-formed declaration loads exactly what it states`() { + val root = + ledger( + """ + schemaVersion: "1" + promotions: + - promotionBase: "$MINTING_SHA" + populationDigest: "$CARRIED_DIGEST" + admissions: 67 + certificates: 1 + """.trimIndent(), + ) + assertEquals( + MutationPopulationPromotion( + promotionBase = MINTING_SHA, + populationDigest = CARRIED_DIGEST, + admissions = 67, + certificates = 1, + ), + MutationPopulationPromotionLoader.load(root), + ) + } + + private fun ledger(contents: String): File { + val root = Files.createTempDirectory("promotion-ledger").toFile() + val file = File(root, MutationPopulationPromotionLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText(contents) + return root + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationTest.kt index 39e87aa71..33e9b3731 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationPopulationTest.kt @@ -5,6 +5,7 @@ import org.gradle.api.GradleException import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class MutationPopulationTest { private val families = @@ -30,8 +31,9 @@ class MutationPopulationTest { status: String = "KILLED", index: Int = 0, family: String = "routing", + module: String = ":engine", ) = MutationRecord( - module = ":engine", + module = module, family = family, status = status, sourceFile = "Router.kt", @@ -43,7 +45,7 @@ class MutationPopulationTest { description = "d", identity = MutationIdentity( - ":engine", + module, "dev.tramai.Router", "route", "()V", @@ -56,6 +58,73 @@ class MutationPopulationTest { index = index, ) + @Test + fun `a configured module that reports nothing fails even when its family stays non-vacuous`() { + // The under-count scenario: the family total is non-zero, one configured module is missing + // entirely. Family-level non-emptiness alone accepts this and persists a shrunken population. + val twoModuleFamily = + mapOf( + "evidence" to + MutationTargetFamily( + modules = listOf(":engine", ":security"), + targetClasses = listOf("dev.tramai.Router"), + targetTests = listOf("dev.tramai.*"), + ), + ) + val failure = + assertFailsWith { + MutationPopulationAggregator.aggregate( + listOf( + ParsedMutationReport( + ":engine", + "evidence", + listOf(record(family = "evidence", status = "KILLED")), + ), + ), + twoModuleFamily, + "abc", + semantics, + ) + } + assertTrue( + failure.message.orEmpty().contains(":security"), + "the unmeasured module must be named: ${failure.message}", + ) + } + + @Test + fun `every configured module reporting yields the configured module topology`() { + val twoModuleFamily = + mapOf( + "evidence" to + MutationTargetFamily( + modules = listOf(":engine", ":security"), + targetClasses = listOf("dev.tramai.Router"), + targetTests = listOf("dev.tramai.*"), + ), + ) + val baseline = + MutationPopulationAggregator.aggregate( + listOf( + ParsedMutationReport( + ":engine", + "evidence", + listOf(record(family = "evidence", status = "KILLED", index = 1)), + ), + ParsedMutationReport( + ":security", + "evidence", + listOf(record(family = "evidence", status = "KILLED", index = 2, module = ":security")), + ), + ), + twoModuleFamily, + "abc", + semantics, + ) + assertEquals(listOf(":engine", ":security"), baseline.byFamily.getValue("evidence").modules) + assertEquals(2, baseline.byFamily.getValue("evidence").totalMutants) + } + @Test fun `killed mutants are persisted not discarded`() { val baseline = diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt index 5fdae4ebb..f0f4c1e67 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetAuthorityTest.kt @@ -85,6 +85,18 @@ class MutationRatchetAuthorityTest { population = candidatePopulation, classifications = candidateClassifications, targetFamilies = candidateConfiguration.mutation.targetFamilies, + // An identity transition must RETAIN the base's pending authorizations: M37 + // forbids an authorization disappearing without a valid consumption, so + // passing NONE here asserted a transition this authority cannot validly make. + // Retained authorizations whose target is absent from the committed + // population are M39 warnings, not failures. This test failed at the + // pristine base ec8b4da5 for exactly that reason. + admissions = authority.admissions, + // An identity transition must RETAIN the base's certificate too. Passing NONE + // here would assert a transition that removes the certificate without a proven + // consumption, which M47 correctly refuses - M47's consuming half requires the + // fact that a consumption happened, not the mere absence of the certificate. + certificates = authority.certificates, ), executable = MutationPopulationAggregator.canonicalSemantics(), ).filter { it.severity == DiagnosticSeverity.FAILURE } @@ -140,6 +152,7 @@ class MutationRatchetAuthorityTest { candidatePopulation, authority.classifications, configuration.mutation.targetFamilies, + admissions = MutationPopulationAdmissions.NONE, ), MutationPopulationAggregator.canonicalSemantics(), ).filter { it.severity == DiagnosticSeverity.FAILURE } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetClassificationDiscriminatorTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetClassificationDiscriminatorTest.kt index e6d71188e..0847ee261 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetClassificationDiscriminatorTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetClassificationDiscriminatorTest.kt @@ -178,7 +178,11 @@ class MutationRatchetClassificationDiscriminatorTest : MutationRatchetTestSuppor } @Test - fun `M10 disappeared mutant with classification removed passes`() { + fun `M10 removed classification does not excuse a disappeared mutant`() { + // Contract change (0.7.1g hardening): removing the classification *because* its mutant + // vanished no longer makes the disappearance legitimate. Absence is a measurement claim that + // only the explicit baseline evolution ceremony may make, so M21 fails it here — while M11, + // which guards a classification removed while its survivor is still measured, stays silent. val base = population( listOf( @@ -187,13 +191,22 @@ class MutationRatchetClassificationDiscriminatorTest : MutationRatchetTestSuppor ), ) val candidate = population(listOf(row("k1", status = "KILLED", outcome = "KILLED"))) - passes( + val diagnostics = verify( basePopulation = base, baseClassifications = approvedClassifications("s1"), candidatePopulation = candidate, candidateClassifications = classifications(), - ), + ) + assertFailsWith( + diagnostics, + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "absent from the candidate population", + ) + assertTrue( + !hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_REMOVED), + "a removed classification must not be reported as M11 when its mutant is gone: " + + failures(diagnostics).map { it.message }, ) } diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetDiscriminatorTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetDiscriminatorTest.kt index 3c92590ff..e784b9f3e 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetDiscriminatorTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetDiscriminatorTest.kt @@ -1,6 +1,8 @@ package dev.tramai.build.quality import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue /** @@ -96,6 +98,242 @@ class MutationRatchetDiscriminatorTest : MutationRatchetTestSupport() { assertFailsWith(diagnostics, DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID, "self-inconsistent") } + // ── M21: measured authority may not shrink silently ── + + @Test + fun `M21 normal PR loses base identity fails`() { + // A module that stopped reporting, a narrowed target class set or a truncated report all look + // exactly like this. Absence is not evidence of improvement: without M21 the ratchet would + // accept a silently shrunken authority as the reference for the next PR. + val base = + population( + listOf( + row("k1", status = "KILLED", outcome = "KILLED"), + row("v1", status = "SURVIVED"), + ), + ) + val candidate = population(listOf(row("k1", status = "KILLED", outcome = "KILLED"))) + val diagnostics = verify(basePopulation = base, candidatePopulation = candidate) + assertFailsWith( + diagnostics, + DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT, + "absent from the candidate population", + ) + assertTrue( + failures(diagnostics).any { it.message.contains("apply_v1") }, + "the disappeared identity must be named: ${failures(diagnostics).map { it.message }}", + ) + } + + @Test + fun `M21 normal PR removes classification and identity fails`() { + val base = population(listOf(row("v1"))) + val candidate = population(emptyList()) + val diagnostics = + verify( + basePopulation = base, + baseClassifications = approvedClassifications("v1"), + candidatePopulation = candidate, + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT), "M21 must fail") + } + + @Test + fun `M21 manually shrunk baseline fails without evolution authority`() { + val base = population(listOf(row("v1"))) + val candidate = population(emptyList()) + val diagnostics = verify(basePopulation = base, candidatePopulation = candidate) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT), "FORBID must fail") + } + + @Test + fun `M21 baseline migration alone remains forbidden`() { + val base = population(listOf(row("v1")), measuredCommit = "base") + val candidate = population(emptyList(), measuredCommit = "candidate") + val diagnostics = + verify( + basePopulation = base, + candidatePopulation = candidate, + evolution = MutationPopulationEvolution.FORBID, + evolutionRecords = MutationEvolutionRecords("1", listOf(evolutionRecord("v1"))), + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT), "FORBID must fail") + } + + @Test + fun `M21 authorized canonical evolution warns only`() { + val base = population(listOf(row("v1")), measuredCommit = "base") + val candidate = + population( + listOf(row("k1", status = "KILLED", outcome = "KILLED")), + measuredCommit = "candidate", + ) + val diagnostics = + verify( + basePopulation = base, + candidatePopulation = candidate, + evolution = MutationPopulationEvolution.RECORDED_EVOLUTION, + evolutionRecords = MutationEvolutionRecords("1", listOf(evolutionRecord("v1"))), + ) + passes(diagnostics) + assertTrue(diagnostics.any { it.severity == DiagnosticSeverity.WARNING && it.message.contains("v1") }) + } + + @Test + fun `M21 recorded evolution without measurement proof fails`() { + val base = population(listOf(row("v1"))) + val candidate = population(listOf(row("k1", status = "KILLED", outcome = "KILLED"))) + val diagnostics = + verify( + basePopulation = base, + candidatePopulation = candidate, + evolution = MutationPopulationEvolution.RECORDED_EVOLUTION, + evolutionRecords = MutationEvolutionRecords("1", listOf(evolutionRecord("v1"))), + evolutionProof = null, + ) + assertTrue(diagnostics.any { it.message.contains("exact fresh canonical measurement") }) + } + + @Test + fun `M21 proof binds the full projection not only the identity set`() { + val measured = + population(listOf(row("k1", status = "KILLED", outcome = "KILLED")), measuredCommit = "m") + val sameIdentitiesDifferentStatus = + population(listOf(row("k1", status = "SURVIVED", outcome = "NON_KILLED")), measuredCommit = "m") + val sameIdentitiesDifferentAnalyzer = + population( + listOf(row("k1", status = "KILLED", outcome = "KILLED")), + measuredCommit = "m", + analyzer = MutationAnalyzerSemantics(pluginVersion = "another-renderer"), + ) + val proof = assertNotNull(MutationPopulationEvolutionProof.exactComparison(measured, measured).proof) + assertTrue(proof.matches(measured)) + assertFalse( + proof.matches(sameIdentitiesDifferentStatus), + "a proof must not be reusable for the same identities with different outcomes", + ) + assertFalse( + proof.matches(sameIdentitiesDifferentAnalyzer), + "a proof must not be reusable across analyzer semantics", + ) + } + + @Test + fun `M21 stale record base fails`() { + val base = population(listOf(row("v1"))) + val candidate = population(listOf(row("k1", status = "KILLED", outcome = "KILLED"))) + val diagnostics = + verify( + basePopulation = base, + candidatePopulation = candidate, + evolution = MutationPopulationEvolution.RECORDED_EVOLUTION, + evolutionRecords = + MutationEvolutionRecords( + "1", + listOf(evolutionRecord("v1").copy(fromBaseSha = "old-base")), + ), + ) + assertTrue(diagnostics.any { it.message.contains("not this transition base") }) + } + + @Test + fun `M21 extra record fails`() { + val base = population(listOf(row("v1"))) + val candidate = population(listOf(row("k1", status = "KILLED", outcome = "KILLED"))) + val diagnostics = + verify( + basePopulation = base, + candidatePopulation = candidate, + evolution = MutationPopulationEvolution.RECORDED_EVOLUTION, + evolutionRecords = + MutationEvolutionRecords( + "1", + listOf(evolutionRecord("v1"), evolutionRecord("extra")), + ), + ) + assertTrue(diagnostics.any { it.message.contains("does not name a base identity") }) + } + + @Test + fun `M21 missing record fails`() { + val base = population(listOf(row("v1"))) + val candidate = population(listOf(row("k1", status = "KILLED", outcome = "KILLED"))) + val diagnostics = + verify( + basePopulation = base, + candidatePopulation = candidate, + evolution = MutationPopulationEvolution.RECORDED_EVOLUTION, + evolutionRecords = MutationEvolutionRecords("1", emptyList()), + ) + assertTrue(diagnostics.any { it.message.contains("Authorized evolution does not name") }) + } + + @Test + fun `M21 authorized evolution with family missing still fails`() { + val families = mapOf(policyFamily to policyTarget, retryFamily to retryTarget) + val base = + population( + listOf(row("v1"), row("r1", family = retryFamily)), + families = families, + measuredCommit = "base", + ) + val candidate = + population( + listOf(row("k1", status = "KILLED", outcome = "KILLED")), + families = families, + measuredCommit = "candidate", + ) + val diagnostics = + MutationRatchetVerifier().verify( + MutationRatchetAuthority(BASE_SHA, base, classifications(), families, admissions = noAdmissions), + MutationRatchetCandidate(candidate, classifications(), families, admissions = noAdmissions), + semantics, + MutationPopulationEvolution.RECORDED_EVOLUTION, + MutationEvolutionEvidence( + MutationEvolutionRecords("1", listOf(evolutionRecord("v1"), evolutionRecord("r1"))), + MutationPopulationEvolutionProof.exactComparison(candidate, candidate).proof, + ), + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_AUTHORITY_INVALID), "missing family must fail") + } + + @Test + fun `M21 authorized evolution with narrowed target classes still fails`() { + val wide = policyTarget.copy(targetClasses = listOf("dev.tramai.policy.*", "dev.tramai.policy.deep.*")) + val base = population(listOf(row("v1")), families = mapOf(policyFamily to wide), measuredCommit = "base") + val candidate = + population( + listOf(row("k1", status = "KILLED", outcome = "KILLED")), + measuredCommit = "candidate", + ) + val diagnostics = + MutationRatchetVerifier().verify( + MutationRatchetAuthority( + BASE_SHA, + base, + classifications(), + mapOf(policyFamily to wide), + admissions = noAdmissions, + ), + MutationRatchetCandidate(candidate, classifications(), baseFamilies, admissions = noAdmissions), + semantics, + MutationPopulationEvolution.RECORDED_EVOLUTION, + MutationEvolutionEvidence( + MutationEvolutionRecords("1", listOf(evolutionRecord("v1"))), + MutationPopulationEvolutionProof.exactComparison(candidate, candidate).proof, + ), + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT), "M15 must fail") + } + + @Test + fun `M21 an identity that only changed status is not a disappearance`() { + // Boundary: "gone" and "still measured, now killed" must stay distinguishable. + val base = population(listOf(row("v1", status = "SURVIVED"))) + val candidate = population(listOf(row("v1", status = "KILLED", outcome = "KILLED"))) + passes(verify(basePopulation = base, candidatePopulation = candidate)) + } + // ── M12 / M13: structural integrity, fail closed ── @Test diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentCeremonyTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentCeremonyTest.kt new file mode 100644 index 000000000..8f2f3ac97 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentCeremonyTest.kt @@ -0,0 +1,363 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * 0.7.1g1F: the base classification enrollment ceremony (M22-M26) and its M08/M09 backstop. + * + * The ratchet rejects candidate-side classification additions (M08/M09) — a PR may not decide that its + * own new survivor is acceptable. Its documented complement, "adjudicated on master during an enrollment + * ceremony", had no implementation until this ledger. These discriminators pin both halves of the + * property: an authorization that exists in the BASE may be consumed by a later transition, and a + * candidate that fabricates its own authority in the same transition still fails exactly as before. + * + * Retention, terminal states and the next-base invariant live in + * [MutationRatchetEnrollmentLifecycleTest]; ledger parsing lives in + * [MutationRatchetEnrollmentLedgerTest]. + */ +class MutationRatchetEnrollmentCeremonyTest : MutationRatchetEnrollmentTestSupport() { + // ---------------------------------------------------------------- M22/M26: the ceremony works + + @Test + fun `M22 exact base-authorized classification enrolls and consumes its authorization`() { + val x = row("x") + val authorized = toolLimitation("x") + val diagnostics = + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = consume(authorized), + ) + passes(diagnostics) + } + + @Test + fun `M26 authorization may be consumed in the same transition that enrolls it`() { + val x = row("x") + val authorized = classificationOf("x", classification = "missing-test", reason = "adjudicated") + // BASE: authorization present, no classification. CANDIDATE: authorization removed, exact + // classification added — the consumption shape. + passes( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = consume(authorized), + ), + ) + } + + @Test + fun `M22 enrollment is generic across the allowed classification vocabulary`() { + val kinds = + listOf("missing-test", "equivalent-mutant", "low-risk-implementation-detail", "known-design-ambiguity") + kinds.forEach { kind -> + val x = row("x") + val authorized = classificationOf("x", classification = kind, reason = "adjudicated as $kind") + passes( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = consume(authorized), + ), + ) + } + } + + // ---------------------------------------------------------------- M23/M08: self-authorization + + @Test + fun `M23 a transition that authorizes and enrolls in the same commit fails`() { + val x = row("x") + val selfAuthorized = toolLimitation("x", reason = "self-approved") + val diagnostics = + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = + Ledger( + candidateClassifications = classifications(selfAuthorized), + candidateEnrollments = enrollments(selfAuthorized), + ), + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_UNAUTHORIZED), "M23 must fail") + assertTrue( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID), + "self-authorization must not silently authorize the classification", + ) + } + + @Test + fun `M08 stays intact - an unrelated candidate classification still fails once the ceremony exists`() { + val x = row("x") + val y = row("y") + val authorized = toolLimitation("x") + val unrelated = toolLimitation("y", reason = "candidate invented") + val diagnostics = + verify( + base = population(listOf(x, y)), + candidate = population(listOf(x, y)), + ledger = + Ledger( + baseEnrollments = enrollments(authorized), + candidateClassifications = classifications(authorized, unrelated), + ), + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID), "Y must fail") + assertEquals( + listOf(identityOf("y", policyFamily, ":engine")), + failures(diagnostics) + .filter { it.code == DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID } + .mapNotNull { it.findingId }, + ) + } + + // ---------------------------------------------------------------- M24: exact payload + + @Test + fun `M24 classification type differs from the authorization`() { + val x = row("x") + val changed = classificationOf("x", classification = "equivalent-mutant", reason = "adjudicated") + assertTrue( + hasCode( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = + Ledger( + baseEnrollments = enrollments(toolLimitation("x")), + candidateClassifications = classifications(changed), + ), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_MISMATCH, + ), + "M24 must reject a different classification type", + ) + } + + @Test + fun `M24 reason issue and targetPhase must match exactly`() { + val x = row("x") + + fun payload( + reason: String, + issue: String? = "I-1", + targetPhase: String? = "0.7.1", + ): MutationClassification = + classificationOf( + marker = "x", + classification = "tool-limitation", + reason = reason, + issue = issue, + targetPhase = targetPhase, + ) + + val variants = + listOf( + payload(reason = "B"), + payload(reason = "A", issue = "I-2"), + payload(reason = "A", targetPhase = "0.7.2"), + payload(reason = "A", issue = null), + payload(reason = "A", targetPhase = null), + ) + variants.forEach { variant -> + assertTrue( + hasCode( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = + Ledger( + baseEnrollments = enrollments(payload(reason = "A")), + candidateClassifications = classifications(variant), + ), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_MISMATCH, + ), + "M24 must reject a rewritten payload: $variant", + ) + } + } + + // ---------------------------------------------------------------- M25: invalid targets + + @Test + fun `M25 authorization cannot target an identity absent from the base population`() { + val authorized = toolLimitation("fresh") + assertTrue( + hasCode( + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(row("x"))), + ledger = consume(authorized), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID, + ), + "M25 must fail", + ) + } + + @Test + fun `M25 authorization cannot preauthorize a newly measured survivor - M06 still holds`() { + val fresh = row("fresh") + val authorized = toolLimitation("fresh") + // Even with an authorization in the base, a NEW NON_KILLED identity cannot enter the authority. + val diagnostics = + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(row("x"), fresh)), + ledger = consume(authorized), + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID), "M25 must fail") + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR), "M06 must still fail") + } + + @Test + fun `M25 authorization cannot target a KILLED identity`() { + val killed = row("k", status = "KILLED", outcome = "KILLED") + val authorized = toolLimitation("k") + assertTrue( + hasCode( + verify( + base = population(listOf(killed)), + candidate = population(listOf(killed)), + ledger = consume(authorized), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID, + ), + "M25 must reject a KILLED target", + ) + } + + @Test + fun `M25 authorization cannot target an already classified identity`() { + val x = row("x") + val existing = classificationOf("x", classification = "equivalent-mutant", reason = "already approved") + assertTrue( + hasCode( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = + Ledger( + baseClassifications = classifications(existing), + baseEnrollments = enrollments(toolLimitation("x", reason = "second approval")), + candidateClassifications = classifications(existing), + ), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID, + ), + "M25 must reject an already-classified target", + ) + } + + @Test + fun `M25 duplicate authorization ids fail closed`() { + val x = row("x") + val diagnostics = + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = + Ledger( + baseEnrollments = + enrollments( + toolLimitation("x", reason = "adjudicated"), + classificationOf("x", classification = "missing-test", reason = "contradictory"), + ), + ), + ) + assertTrue(hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID), "duplicates must fail") + } + + // ---------------------------------------------------------------- M03 retained authority + + @Test + fun `M03 an enrolled classification is retained byte-identically by later candidates`() { + val x = row("x") + val enrolled = toolLimitation("x") + passes( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = retained(enrolled), + ), + ) + } + + @Test + fun `M03 an enrolled classification may not be rewritten later`() { + val x = row("x") + assertTrue( + hasCode( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = + Ledger( + baseClassifications = classifications(toolLimitation("x")), + candidateClassifications = classifications(toolLimitation("x", reason = "reworded later")), + ), + ), + DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID, + ), + "M03 must reject a rewritten enrolled classification", + ) + } + + // ---------------------------------------------------------------- the full transition proof + + @Test + fun `ceremony transition BASE0 to BASE1 to BASE2 to CANDIDATE3`() { + val x = row("x") + val y = row("y") + val authorized = toolLimitation("x", reason = "g1E adjudicated") + val selfApprovedY = toolLimitation("y", reason = "candidate invented") + val xId = identityOf("x", policyFamily, ":engine") + + // BASE0 -> BASE1 (authorize): candidate adds an authorization for an existing base survivor + // and enrolls nothing. + passes( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = Ledger(candidateEnrollments = enrollments(authorized)), + ), + ) + + // BASE1 -> BASE2 (consume): the base authorization exists, so the exact classification may be + // enrolled and the authorization consumed. + passes( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = consume(authorized), + ), + ) + + // BASE2 -> CANDIDATE3: X is retained byte-identically while an unrelated new classification for + // Y is still rejected (M08). + val diagnostics = + verify( + base = population(listOf(x)), + candidate = population(listOf(x, y)), + ledger = + Ledger( + baseClassifications = classifications(authorized), + candidateClassifications = classifications(authorized, selfApprovedY), + ), + ) + assertTrue( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID), + "unrelated candidate self-classification must still fail", + ) + assertEquals( + listOf(identityOf("y", policyFamily, ":engine")), + failures(diagnostics) + .filter { it.code == DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID } + .mapNotNull { it.findingId }, + ) + assertTrue(failures(diagnostics).none { it.findingId == xId }, "the enrolled identity must be unaffected") + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLedgerTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLedgerTest.kt new file mode 100644 index 000000000..eadf34a82 --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLedgerTest.kt @@ -0,0 +1,80 @@ +package dev.tramai.build.quality + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * 0.7.1g1F: the authorization ledger file itself. An absent ledger means no authorizations (the most + * restrictive state), a malformed one fails closed, and the classification vocabulary is the shared one — + * not a second list that could drift from `mutation-classifications.yml`. + */ +class MutationRatchetEnrollmentLedgerTest : MutationRatchetEnrollmentTestSupport() { + // ---------------------------------------------------------------- the ledger file itself + + @Test + fun `absent ledger means no authorizations`() { + assertEquals(0, MutationClassificationEnrollmentLoader.load(tempDir).enrollments.size) + } + + @Test + fun `ledger rejects an unknown classification name using the shared vocabulary`() { + writeLedger( + """ + schemaVersion: "1" + enrollments: + - id: "${identityOf("x", policyFamily, ":engine")}" + classification: "sounds-fine-honestly" + reason: "not in the allowed vocabulary" + """.trimIndent(), + ) + val failure = runCatching { MutationClassificationEnrollmentLoader.load(tempDir) }.exceptionOrNull() + assertTrue(failure is GradleException, "expected GradleException, got $failure") + assertTrue(failure.message!!.contains("not allowed"), "unexpected message: ${failure.message}") + } + + @Test + fun `ledger rejects duplicate authorization ids`() { + val id = identityOf("x", policyFamily, ":engine") + writeLedger( + """ + schemaVersion: "1" + enrollments: + - id: "$id" + classification: "tool-limitation" + reason: "first" + - id: "$id" + classification: "missing-test" + reason: "contradictory duplicate" + """.trimIndent(), + ) + val failure = runCatching { MutationClassificationEnrollmentLoader.load(tempDir) }.exceptionOrNull() + assertTrue(failure is GradleException, "expected GradleException, got $failure") + assertTrue(failure.message!!.contains("duplicate"), "unexpected message: ${failure.message}") + } + + @Test + fun `ledger parses an exact authorization payload`() { + val id = identityOf("x", policyFamily, ":engine") + writeLedger( + """ + schemaVersion: "1" + enrollments: + - id: "$id" + classification: "tool-limitation" + reason: >- + Deterministic PIT timeout on a compiler-generated suspension check. + issue: "g1G1" + targetPhase: "0.7.1" + """.trimIndent(), + ) + val loaded = MutationClassificationEnrollmentLoader.load(tempDir) + assertEquals(1, loaded.enrollments.size) + val authorized = loaded.byIdentity().getValue(id) + assertEquals("tool-limitation", authorized.classification) + assertEquals("g1G1", authorized.issue) + assertEquals("0.7.1", authorized.targetPhase) + assertTrue(authorized.reason.startsWith("Deterministic PIT timeout"), "reason must survive folding") + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLifecycleTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLifecycleTest.kt new file mode 100644 index 000000000..721b2c77b --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLifecycleTest.kt @@ -0,0 +1,264 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +/** + * 0.7.1g1F: the authorization lifecycle (M27/M28/M29) — what may happen to a base authorization that is + * not consumed immediately. + * + * Review of the first revision found two holes here: an unconsumed authorization could be silently + * re-authored before a later PR consumed it (M28), and target validity was only ever checked against the + * base population, so a transition could enroll a classification for a mutant it had just killed or + * removed — producing a candidate authority its own next verification rejects — while an authorization + * whose target died was undeletable (M29 plus terminal semantics). + */ +class MutationRatchetEnrollmentLifecycleTest : MutationRatchetEnrollmentTestSupport() { + // ---------------------------------------------------------------- M27: consumption discipline + + @Test + fun `M27 authorization removed without enrollment fails closed`() { + val x = row("x") + assertTrue( + hasCode( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = Ledger(baseEnrollments = enrollments(toolLimitation("x"))), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED, + ), + "M27 must fail", + ) + } + + @Test + fun `M27 authorization retained after its classification was enrolled fails closed`() { + val x = row("x") + val authorized = toolLimitation("x") + assertTrue( + hasCode( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = + Ledger( + baseEnrollments = enrollments(authorized), + candidateClassifications = classifications(authorized), + candidateEnrollments = enrollments(authorized), + ), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED, + ), + "single-use must hold", + ) + } + + // ---------------------------------------------------------------- M28: retention is immutable + + @Test + fun `M28 a retained authorization may not be rewritten before it is consumed`() { + val x = row("x") + + fun payload( + classification: String = "tool-limitation", + reason: String = "A", + issue: String? = "I-1", + targetPhase: String? = "0.7.1", + ): MutationClassification = + classificationOf( + marker = "x", + classification = classification, + reason = reason, + issue = issue, + targetPhase = targetPhase, + ) + + val base = payload() + val rewrites = + listOf( + payload(classification = "equivalent-mutant"), + payload(reason = "B"), + payload(issue = "I-2"), + payload(issue = null), + payload(targetPhase = "0.7.2"), + payload(targetPhase = null), + ) + rewrites.forEach { rewritten -> + assertTrue( + hasCode( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = pending(base, asCarried = rewritten), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_MISMATCH, + ), + "M28 must reject a retained authorization rewritten to $rewritten", + ) + } + } + + @Test + fun `M28 an unchanged retained authorization is still pending and passes`() { + val x = row("x") + val authorized = toolLimitation("x") + passes( + verify( + base = population(listOf(x)), + candidate = population(listOf(x)), + ledger = pending(authorized), + ), + ) + } + + // ---------------------------------------------------------------- M27/M29: terminal states + + @Test + fun `M27 a retained authorization whose target became KILLED is stale`() { + val killed = row("x", status = "KILLED", outcome = "KILLED") + val authorized = toolLimitation("x") + assertTrue( + hasCode( + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(killed)), + ledger = pending(authorized), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED, + ), + "a dead target must not keep its authorization", + ) + } + + @Test + fun `M27 a retained authorization whose target disappeared is stale`() { + val authorized = toolLimitation("x") + assertTrue( + hasCode( + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(row("y"))), + ledger = pending(authorized), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED, + ), + "an unmeasured target must not keep its authorization", + ) + } + + @Test + fun `M27 an authorization may be cleaned up once its target is KILLED`() { + val killed = row("x", status = "KILLED", outcome = "KILLED") + passes( + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(killed)), + ledger = Ledger(baseEnrollments = enrollments(toolLimitation("x"))), + ), + ) + } + + @Test + fun `M27 cleanup after a disappearance is permitted and M21 governs the disappearance`() { + val diagnostics = + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(row("y"))), + ledger = Ledger(baseEnrollments = enrollments(toolLimitation("x"))), + ) + assertTrue( + failures(diagnostics).none { it.code == DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_ORPHANED }, + "the ceremony must allow cleanup: ${failures(diagnostics).map { it.code }}", + ) + assertTrue( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_TARGET_DRIFT), + "M21 must independently decide whether the disappearance is legitimate", + ) + } + + @Test + fun `M29 enrollment for a mutant this transition killed fails`() { + val killed = row("x", status = "KILLED", outcome = "KILLED") + val diagnostics = + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(killed)), + ledger = + Ledger( + baseEnrollments = enrollments(toolLimitation("x")), + candidateClassifications = classifications(toolLimitation("x")), + ), + ) + assertTrue( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID), + "a classification may not be enrolled onto a dead mutant: ${failures(diagnostics).map { it.code }}", + ) + } + + @Test + fun `M29 enrollment for a mutant this transition removed fails`() { + val diagnostics = + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(row("y"))), + ledger = + Ledger( + baseEnrollments = enrollments(toolLimitation("x")), + candidateClassifications = classifications(toolLimitation("x")), + ), + ) + assertTrue( + hasCode(diagnostics, DiagnosticCode.MUTATION_RATCHET_CLASSIFICATION_INVALID), + "a classification may not be enrolled onto an absent mutant: ${failures(diagnostics).map { it.code }}", + ) + } + + @Test + fun `M25 a proposed authorization for a mutant already KILLED in the candidate fails`() { + assertTrue( + hasCode( + verify( + base = population(listOf(row("x"))), + candidate = population(listOf(row("x", status = "KILLED", outcome = "KILLED"))), + ledger = Ledger(candidateEnrollments = enrollments(toolLimitation("x"))), + ), + DiagnosticCode.MUTATION_RATCHET_ENROLLMENT_INVALID, + ), + "a proposal must target a live survivor in the candidate population too", + ) + } + + // ---------------------------------------------------------------- the invariant behind all of it + + @Test + fun `every passing ceremony transition yields a candidate that is valid as the next base`() { + val authorized = toolLimitation("x", reason = "g1E adjudicated") + val killed = row("x", status = "KILLED", outcome = "KILLED") + + // authorize (BASE0 -> BASE1): an authorization is added, nothing is enrolled + assertValidAsNextBase( + population = population(listOf(row("x"))), + classifications = classifications(), + enrollments = enrollments(authorized), + ) + // pending retention: the authorization is carried unchanged + assertValidAsNextBase( + population = population(listOf(row("x"))), + classifications = classifications(), + enrollments = enrollments(authorized), + ) + // consume (BASE1 -> BASE2): the classification is enrolled and the authorization is gone + assertValidAsNextBase( + population = population(listOf(row("x"))), + classifications = classifications(authorized), + enrollments = MutationClassificationEnrollments.NONE, + ) + // cleanup: the mutant died and the authorization was removed with it + assertValidAsNextBase( + population = population(listOf(killed)), + classifications = classifications(), + enrollments = MutationClassificationEnrollments.NONE, + ) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentTestSupport.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentTestSupport.kt new file mode 100644 index 000000000..fc9d1171a --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentTestSupport.kt @@ -0,0 +1,108 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Shared fixtures for the classification enrollment ceremony suites (0.7.1g1F). No test methods live + * here: [MutationRatchetEnrollmentCeremonyTest] covers the rules that let a base authorization enroll a + * classification, and [MutationRatchetEnrollmentLifecycleTest] covers retention, terminal states and the + * "a passing transition must be a valid next base" invariant. + */ +abstract class MutationRatchetEnrollmentTestSupport : MutationRatchetTestSupport() { + @TempDir + lateinit var tempDir: File + + /** The optional half of a transition; everything omitted keeps its pre-ceremony default. */ + protected data class Ledger( + val baseClassifications: MutationClassifications? = null, + val baseEnrollments: MutationClassificationEnrollments = MutationClassificationEnrollments.NONE, + val candidateClassifications: MutationClassifications? = null, + val candidateEnrollments: MutationClassificationEnrollments = MutationClassificationEnrollments.NONE, + ) + + protected fun enrollments(vararg records: MutationClassification): MutationClassificationEnrollments = + MutationClassificationEnrollments( + schemaVersion = "1", + enrollments = records.map { MutationClassificationEnrollment(it) }, + ) + + protected fun verify( + base: MutationPopulationBaseline, + candidate: MutationPopulationBaseline, + ledger: Ledger = Ledger(), + ): List = + MutationRatchetVerifier().verify( + MutationRatchetAuthority( + baseSha = BASE_SHA, + population = base, + classifications = ledger.baseClassifications ?: classifications(), + targetFamilies = baseFamilies, + enrollments = ledger.baseEnrollments, + admissions = noAdmissions, + ), + MutationRatchetCandidate( + population = candidate, + classifications = ledger.candidateClassifications ?: classifications(), + targetFamilies = baseFamilies, + enrollments = ledger.candidateEnrollments, + admissions = noAdmissions, + ), + MutationPopulationAggregator.canonicalSemantics(), + ) + + protected fun consume(record: MutationClassification): Ledger = + Ledger(baseEnrollments = enrollments(record), candidateClassifications = classifications(record)) + + /** + * The pending shape: the base authorization is carried into the candidate ledger unchanged (or, with + * [asCarried], rewritten — which M28 must reject). + */ + protected fun pending( + record: MutationClassification, + asCarried: MutationClassification = record, + ): Ledger = Ledger(baseEnrollments = enrollments(record), candidateEnrollments = enrollments(asCarried)) + + /** The retained shape: the record is already base authority on both sides of the transition. */ + protected fun retained(record: MutationClassification): Ledger = + Ledger(baseClassifications = classifications(record), candidateClassifications = classifications(record)) + + protected fun toolLimitation( + marker: String, + reason: String = "adjudicated", + ): MutationClassification = classificationOf(marker, classification = "tool-limitation", reason = reason) + + /** + * The property the two reviewed blockers violated: a transition that passes must produce an + * authority that its own next verification accepts. The identity transition here is exactly the shape + * CI runs, so this fails if a passing transition can poison the next base. + */ + protected fun assertValidAsNextBase( + population: MutationPopulationBaseline, + classifications: MutationClassifications, + enrollments: MutationClassificationEnrollments, + ) { + val next = + MutationRatchetAuthority( + baseSha = BASE_SHA, + population = population, + classifications = classifications, + targetFamilies = baseFamilies, + enrollments = enrollments, + admissions = noAdmissions, + ) + passes( + MutationRatchetVerifier().verify( + next, + MutationRatchetCandidate(population, classifications, baseFamilies, enrollments, noAdmissions), + MutationPopulationAggregator.canonicalSemantics(), + ), + ) + } + + protected fun writeLedger(content: String) { + val file = File(tempDir, MutationClassificationEnrollmentLoader.FILE_NAME) + file.parentFile.mkdirs() + file.writeText(content, Charsets.UTF_8) + } +} diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt index b748dd76b..462797e46 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetTestSupport.kt @@ -62,6 +62,12 @@ abstract class MutationRatchetTestSupport { protected val semantics: MutationAnalyzerSemantics = MutationPopulationAggregator.canonicalSemantics() + /** + * Fixtures propose no population admissions, and the argument is explicit on purpose: the + * production call sites must state their ledger rather than inherit a default. + */ + protected val noAdmissions = MutationPopulationAdmissions.NONE + protected fun row( marker: String, family: String = policyFamily, @@ -145,11 +151,48 @@ abstract class MutationRatchetTestSupport { executable: MutationAnalyzerSemantics = MutationPopulationAggregator.canonicalSemantics(), ): List = MutationRatchetVerifier().verify( - MutationRatchetAuthority(BASE_SHA, basePopulation, baseClassifications, baseFamilies), - MutationRatchetCandidate(candidatePopulation, candidateClassifications, baseFamilies), + MutationRatchetAuthority( + BASE_SHA, + basePopulation, + baseClassifications, + baseFamilies, + admissions = noAdmissions, + ), + MutationRatchetCandidate( + candidatePopulation, + candidateClassifications, + baseFamilies, + admissions = noAdmissions, + ), executable, ) + protected fun verify( + basePopulation: MutationPopulationBaseline, + candidatePopulation: MutationPopulationBaseline, + evolution: MutationPopulationEvolution, + evolutionRecords: MutationEvolutionRecords, + evolutionProof: MutationPopulationEvolutionProof? = + if (evolution == MutationPopulationEvolution.RECORDED_EVOLUTION) { + MutationPopulationEvolutionProof.exactComparison(candidatePopulation, candidatePopulation).proof + } else { + null + }, + ): List = + MutationRatchetVerifier().verify( + MutationRatchetAuthority( + BASE_SHA, + basePopulation, + classifications(), + baseFamilies, + admissions = noAdmissions, + ), + MutationRatchetCandidate(candidatePopulation, classifications(), baseFamilies, admissions = noAdmissions), + MutationPopulationAggregator.canonicalSemantics(), + evolution, + MutationEvolutionEvidence(evolutionRecords, evolutionProof), + ) + protected fun verify( basePopulation: MutationPopulationBaseline, baseFamilies: Map, @@ -157,14 +200,135 @@ abstract class MutationRatchetTestSupport { candidateFamilies: Map, ): List = MutationRatchetVerifier().verify( - MutationRatchetAuthority(BASE_SHA, basePopulation, classifications(), baseFamilies), - MutationRatchetCandidate(candidatePopulation, classifications(), candidateFamilies), + MutationRatchetAuthority( + BASE_SHA, + basePopulation, + classifications(), + baseFamilies, + admissions = noAdmissions, + ), + MutationRatchetCandidate( + candidatePopulation, + classifications(), + candidateFamilies, + admissions = noAdmissions, + ), MutationPopulationAggregator.canonicalSemantics(), ) protected fun failures(diagnostics: List): List = diagnostics.filter { it.severity == DiagnosticSeverity.FAILURE } + /** + * The canonical projection digest the verifier itself computes for a population. + * + * This is the **authority projection** digest (identity, canonical outcome, family, module, + * topology and analyzer semantics; raw PIT status deliberately excluded), i.e. the M34 + * population context. Tests must never invent a digest: the admission ceremony compares against + * this value, so using it is what proves the trust direction. For the raw-exact measurement + * proof (M21) use `rawDigestOf`. + */ + protected fun digestOf(population: MutationPopulationBaseline): String = + MutationPopulationEvolutionProof + .exactComparison(population, population) + .proof!! + .authorityProjectionHash + + /** The raw-exact measurement projection digest (M21 exact comparison), raw status included. */ + protected fun rawDigestOf(population: MutationPopulationBaseline): String = + MutationPopulationEvolutionProof.exactComparison(population, population).proof!!.projectionHash + + /** The verifier's own trusted measurement evidence for a population (the M21 exact comparison). */ + protected fun evidence(population: MutationPopulationBaseline): MutationEvolutionEvidence = + evidence(population, MutationEvolutionRecords("1", emptyList())) + + protected fun evidence( + population: MutationPopulationBaseline, + records: MutationEvolutionRecords, + ): MutationEvolutionEvidence = + MutationEvolutionEvidence( + records, + MutationPopulationEvolutionProof.exactComparison(population, population).proof, + ) + + /** + * A population admission for [marker]'s canonical identity. The row fields use the same identity + * arithmetic as [population], so an authorization and the row it admits cannot silently disagree; + * tests vary one bound field at a time with `copy(...)`. + */ + protected fun admission( + marker: String, + populationDigest: String, + fromBaseSha: String = BASE_SHA, + status: String = "SURVIVED", + analyzer: MutationAnalyzerSemantics = semantics, + ): MutationPopulationAdmission = + MutationPopulationAdmission( + identity = identityOf(marker, policyFamily, ":engine"), + status = status, + outcome = "NON_KILLED", + family = policyFamily, + module = ":engine", + analyzer = analyzer, + fromBaseSha = fromBaseSha, + populationDigest = populationDigest, + reason = "adjudicated: admitted as part of this measured population transition", + authorizedBy = "fixture", + authorizedAt = "1970-01-01T00:00:00Z", + ) + + protected fun admissions(vararg records: MutationPopulationAdmission): MutationPopulationAdmissions = + MutationPopulationAdmissions(schemaVersion = "1", admissions = records.toList()) + + /** + * Verify a population-admission transition. [freshPopulation] is the population whose canonical + * projection the verifier trusts as its own fresh measurement - by default the candidate, which + * is what a legal P2 commits. `null` means no trusted measurement proof exists, and admission + * must then fail closed. + */ + protected fun verifyAdmission( + basePopulation: MutationPopulationBaseline, + candidatePopulation: MutationPopulationBaseline, + baseAdmissions: MutationPopulationAdmissions = MutationPopulationAdmissions.NONE, + candidateAdmissions: MutationPopulationAdmissions = MutationPopulationAdmissions.NONE, + evidence: MutationEvolutionEvidence = evidence(candidatePopulation), + ): List = + MutationRatchetVerifier().verify( + MutationRatchetAuthority( + BASE_SHA, + basePopulation, + classifications(), + baseFamilies, + MutationClassificationEnrollments.NONE, + baseAdmissions, + ), + MutationRatchetCandidate( + candidatePopulation, + classifications(), + baseFamilies, + MutationClassificationEnrollments.NONE, + candidateAdmissions, + ), + MutationPopulationAggregator.canonicalSemantics(), + // A transition carrying a trusted measurement proof is an evolution transition; without + // one it may not change the population at all (M21 FORBID), and admission fails closed. + if (evidence.proof == null) { + MutationPopulationEvolution.FORBID + } else { + MutationPopulationEvolution.RECORDED_EVOLUTION + }, + evidence, + ) + + protected fun evolutionRecord(marker: String): MutationEvolutionRecord = + MutationEvolutionRecord( + id = identityOf(marker, policyFamily, ":engine"), + fromBaseSha = BASE_SHA, + reason = "source mutation removed", + issue = "ISSUE-1", + targetPhase = "0.7.1", + ) + protected fun hasCode( diagnostics: List, code: DiagnosticCode, diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationReportParserTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationReportParserTest.kt index b590d7afa..95b3be7d9 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationReportParserTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/MutationReportParserTest.kt @@ -7,6 +7,7 @@ import java.nio.file.Path import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class MutationReportParserTest { @TempDir @@ -117,6 +118,46 @@ class MutationReportParserTest { ) } + @Test + fun `a non-PITest root element fails closed`() { + val failure = + assertFailsWith { + MutationReportParser().parse(":engine", "routing", xml("")) + } + assertTrue( + failure.message.orEmpty().contains("expected root"), + "expected a root-element diagnostic, got: ${failure.message}", + ) + } + + @Test + fun `a truncated report fails closed instead of parsing as an empty measurement`() { + // The exact artifact an aborted campaign leaves behind: the root is still open when the + // writer stops. Reading it as "no mutants" would silently shrink a measurement. + val truncated = + """ + + + dev.tramai.Router + """.trimIndent() + assertFailsWith { + MutationReportParser().parse(":engine", "routing", xml(truncated)) + } + } + + @Test + fun `a valid PITest root carrying zero mutations is a measurement concern not a syntax failure`() { + // Structural validity is all this parser owns. Emptiness must reach the aggregator's + // non-vacuity check rather than being reported as malformedness. + val report = + MutationReportParser().parse( + ":engine", + "routing", + xml(""""""), + ) + assertEquals(0, report.mutants.size) + } + private fun mutation( line: Int, status: String = "SURVIVED", diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt index e7da8f78c..0dc62f5f4 100644 --- a/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt @@ -239,6 +239,7 @@ class ResidualQualityVerifierTasksTest { dir, srcRel, "tramai-core/src/main/kotlin/dev/tramai/core/annotations", + // 0.7.1b workload identity vocabulary: the consumer smoke imports this package. "tramai-core/src/main/kotlin/dev/tramai/core/identity", "tramai-core/src/main/kotlin/dev/tramai/core/model/Tool.kt", "tramai-core/src/main/kotlin/dev/tramai/core/model/ToolResult.kt", @@ -247,6 +248,7 @@ class ResidualQualityVerifierTasksTest { "tramai-core/src/main/kotlin/dev/tramai/core/model/ToolFailureCode.kt", "tramai-core/src/main/kotlin/dev/tramai/core/policy", ) + dropCoroutineBoundIdentitySource(dir) writeFile( dir, "settings.gradle.kts", @@ -278,6 +280,16 @@ class ResidualQualityVerifierTasksTest { return dir } + /** + * The fixture's tramai-core declares no coroutine dependency, and the run-scope helper is the + * only identity source that needs one. Checked: a blind delete would let the fixture silently + * keep a source it cannot compile. + */ + private fun dropCoroutineBoundIdentitySource(dir: File) { + val scope = File(dir, "tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunScope.kt") + check(scope.delete()) { "fixture must drop GovernedRunScope.kt: the copied package changed shape" } + } + @Test fun `java consumer smoke is fail-soft - broken source records failure in marker`() { val dir = consumerFixture("java") diff --git a/build-logic/src/test/kotlin/dev/tramai/build/quality/TestsWatchdogWiringTest.kt b/build-logic/src/test/kotlin/dev/tramai/build/quality/TestsWatchdogWiringTest.kt new file mode 100644 index 000000000..043d6737f --- /dev/null +++ b/build-logic/src/test/kotlin/dev/tramai/build/quality/TestsWatchdogWiringTest.kt @@ -0,0 +1,150 @@ +package dev.tramai.build.quality + +import org.junit.jupiter.api.Test +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.assertTrue + +/** + * CI wiring contract for the `tests` job watchdog. + * + * The watchdog exists to abort *stalled* runs, not slow ones. Its predecessor had only an absolute + * deadline, calibrated when the suite ran in ~12.8 minutes; once the suite reached ~14 minutes the + * guard started aborting healthy runs that were still passing tests. That is a defect in what the + * guard measures, not in the suite, so the guard now carries two independent conditions: inactivity + * (a real stall) and an absolute ceiling that exists as growth margin. + * + * The wiring cases keep both guards and the failure artifact in place; the behavioural cases drive + * the real script against synthetic commands, so the guards are proven to fire rather than merely + * present in a file. + */ +class TestsWatchdogWiringTest { + private val repoRoot = File(System.getProperty("tramai.repositoryRoot") ?: "..").canonicalFile + private val script = File(repoRoot, ".github/scripts/test-watchdog.sh") + private val workflow = File(repoRoot, ".github/workflows/ci.yml") + + // ── Wiring ─────────────────────────────────────────────────────────────── + + @Test + fun `the tests job runs the watchdog script`() { + assertTrue(script.isFile, "missing ${script.path}") + assertTrue( + workflow.readText().contains("bash ./.github/scripts/test-watchdog.sh"), + "the tests job must run the shared watchdog script, not an inline loop", + ) + } + + @Test + fun `the tests step declares both guards with growth headroom`() { + val text = workflow.readText() + val ceiling = + Regex("""WATCHDOG_CEILING_SECONDS:\s*"(\d+)"""") + .find(text) + ?.groupValues + ?.get(1) + ?.toInt() + val inactivity = + Regex("""WATCHDOG_INACTIVITY_SECONDS:\s*"(\d+)"""") + .find(text) + ?.groupValues + ?.get(1) + ?.toInt() + val stepTimeout = + Regex("""- name: Run tests\s*\n\s*timeout-minutes:\s*(\d+)""") + .find(text) + ?.groupValues + ?.get(1) + ?.toInt() + + assertTrue( + ceiling != null && ceiling >= 1200, + "the wall-clock ceiling must stay at or above 20 minutes as growth margin, got $ceiling", + ) + assertTrue( + inactivity != null && inactivity in 240..300, + "the inactivity guard must stay in the 4-5 minute band, got $inactivity", + ) + assertTrue( + stepTimeout != null && ceiling != null && stepTimeout * 60 > ceiling, + "the GitHub step timeout (${stepTimeout}min) must exceed the watchdog ceiling (${ceiling}s)", + ) + } + + @Test + fun `the thread-dump artifact upload is preserved`() { + val text = workflow.readText() + assertTrue(text.contains("name: test-hang-threaddumps"), "the dump artifact must stay named") + assertTrue(text.contains("path: /tmp/threaddumps/"), "the dump artifact must keep its path") + assertTrue( + Regex("""- name: Upload test hang thread dumps\s*\n\s*if: always\(\)""").containsMatchIn(text), + "the dump artifact must upload with if: always(), or failures lose their evidence", + ) + } + + // ── Behaviour ──────────────────────────────────────────────────────────── + + @Test + fun `a stalled run is aborted by the inactivity guard`() { + val (exit, output) = runWatchdog(command = "bash ${helper("quiet")}", ceiling = 120, inactivity = 3) + assertTrue(exit == 1, "a stalled run must fail; output: ${output.take(600)}") + assertTrue(output.contains("made no progress"), "the abort must name inactivity; output: ${output.take(600)}") + } + + @Test + fun `a progressing run is not aborted`() { + val (exit, output) = runWatchdog(command = "bash ${helper("progressing")}", ceiling = 120, inactivity = 3) + assertTrue(exit == 0, "a progressing run must survive the inactivity guard; output: ${output.take(600)}") + assertTrue(!output.contains("::error::"), "no guard may fire; output: ${output.take(600)}") + } + + @Test + fun `a run that keeps progressing is still aborted at the ceiling`() { + val (exit, output) = runWatchdog(command = "bash ${helper("progressing")}", ceiling = 3, inactivity = 60) + assertTrue(exit == 1, "the absolute ceiling must fire despite progress; output: ${output.take(600)}") + assertTrue( + output.contains("wall-clock ceiling"), + "the abort must name the ceiling; output: ${output.take(600)}", + ) + } + + // ── Harness ────────────────────────────────────────────────────────────── + + /** + * Runs the real watchdog script with test-sized thresholds. The synthetic command is a helper + * script so the value stays a single command, which keeps `exec` semantics and therefore the + * kill in the abort path honest. + */ + private fun runWatchdog( + command: String, + ceiling: Int, + inactivity: Int, + ): Pair { + val work = Files.createTempDirectory("watchdog-contract-").toFile() + val process = + ProcessBuilder("bash", script.path) + .directory(repoRoot) + .redirectErrorStream(true) + .apply { + environment()["WATCHDOG_COMMAND"] = command + environment()["WATCHDOG_CEILING_SECONDS"] = ceiling.toString() + environment()["WATCHDOG_INACTIVITY_SECONDS"] = inactivity.toString() + environment()["WATCHDOG_POLL_SECONDS"] = "1" + environment()["WATCHDOG_RESULTS_ROOT"] = File(work, "results").apply { mkdirs() }.path + environment()["WATCHDOG_DUMP_DIR"] = File(work, "dumps").path + }.start() + val output = process.inputStream.bufferedReader().readText() + assertTrue(process.waitFor(90, TimeUnit.SECONDS), "the watchdog did not terminate") + work.deleteRecursively() + return process.exitValue() to output + } + + private fun helper(name: String): String { + val start = File(repoRoot, "build-logic/src/test/resources/ci-watchdog/$name.sh") + assertTrue(start.isFile, "missing helper ${start.path}") + // Copy out of the repository so a test run never executes from a shared, writable path. + val copy = Files.createTempFile("watchdog-$name-", ".sh").toFile() + start.copyTo(copy, overwrite = true) + return copy.path + } +} diff --git a/build-logic/src/test/resources/ci-watchdog/progressing.sh b/build-logic/src/test/resources/ci-watchdog/progressing.sh new file mode 100644 index 000000000..135c37baa --- /dev/null +++ b/build-logic/src/test/resources/ci-watchdog/progressing.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Synthetic progress: advances the test-result tree the way Gradle does as tests execute. +for i in 1 2 3 4 5 6 7 8; do + mkdir -p "$WATCHDOG_RESULTS_ROOT/build/test-results/test" + echo "class $i" >>"$WATCHDOG_RESULTS_ROOT/build/test-results/test/results.bin" + sleep 1 +done diff --git a/build-logic/src/test/resources/ci-watchdog/quiet.sh b/build-logic/src/test/resources/ci-watchdog/quiet.sh new file mode 100644 index 000000000..c8d025a6a --- /dev/null +++ b/build-logic/src/test/resources/ci-watchdog/quiet.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Synthetic stall: a long-running child that never advances the test-result tree. +sleep 30 diff --git a/build.gradle.kts b/build.gradle.kts index 304902738..7ca541353 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -158,7 +158,7 @@ sonar { } val tramaiGroup = providers.gradleProperty("tramaiGroup").orElse("dev.tramai") -val tramaiVersion = providers.gradleProperty("tramaiVersion").orElse("0.6.0") +val tramaiVersion = providers.gradleProperty("tramaiVersion").orElse("0.7.0-SNAPSHOT") val tramaiProjectUrl = providers.gradleProperty("tramaiProjectUrl").orElse("https://github.com/GionaGranchelli/tramAI") val tramaiScmUrl = providers.gradleProperty("tramaiScmUrl").orElse("https://github.com/GionaGranchelli/tramAI.git") val tramaiScmConnection = providers.gradleProperty("tramaiScmConnection").orElse("scm:git:https://github.com/GionaGranchelli/tramAI.git") diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 703855263..89c167c83 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -70,9 +70,9 @@ CyclomaticComplexMethod:JdbcStepAttemptRecordStore.kt$JdbcStepAttemptRecordStore$private fun ResultSet.toVerifiedRecord(): StepAttemptRecord CyclomaticComplexMethod:JdbcWorkflowSchedulerStore.kt$@Suppress("kotlin:S3776") // Manual JSON character-level parser — each step depends on shared state (index, inString, escaped). Extracting would add more complexity than it removes, and cannot change the behavior without risking regressions. private fun parseCalendarRuleObject(payload: String): Map<String, String> CyclomaticComplexMethod:JdbcWorkflowSchedulerStore.kt$@Suppress("kotlin:S3776") // Manual JSON character-level parser — each step depends on shared state (index, inString, escaped, depth). Extracting would add more complexity than it removes, and cannot change the behavior without risking regressions. private fun splitJsonObjects(body: String): List<String> - CyclomaticComplexMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$fun scan(source: String, module: String, file: String): List<CancellationCatchFinding> - CyclomaticComplexMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun checkRethrowsCancellation(lines: List<String>, catchIdx: Int): Boolean - CyclomaticComplexMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findConstructEndIdx(lines: List<String>, startIdx: Int, match: MatchResult): Int? + CyclomaticComplexMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$fun scan( source: String, module: String, file: String, ): List<CancellationCatchFinding> + CyclomaticComplexMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun checkRethrowsCancellation( lines: List<String>, catchIdx: Int, ): Boolean + CyclomaticComplexMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findConstructEndIdx( lines: List<String>, startIdx: Int, match: MatchResult, ): Int? CyclomaticComplexMethod:MaintainabilityBaselinePlugin.kt$MaintainabilityBaselinePlugin$override fun apply(project: Project) CyclomaticComplexMethod:ModuleBoundaries.kt$ModuleBoundaries$fun checkEdge( fromPath: String, toPath: String, catalog: ModuleCatalog, ): VerificationDiagnostic? CyclomaticComplexMethod:ModuleBoundaries.kt$ModuleBoundaries$fun parse(): BoundaryResult @@ -743,7 +743,7 @@ LongMethod:JdbcWorkflowSchedulerStore.kt$JdbcWorkflowSchedulerStore$private fun createAndClaimDueTicks( connection: Connection, now: Instant, ownerId: String, claimExpiresAt: Instant, limit: Int, ): List<ClaimedScheduledTick> LongMethod:JdbcWorkflowSchedulerStore.kt$JdbcWorkflowSchedulerStore$private fun upsertScheduleSql(connection: Connection): String LongMethod:JdbcWorkflowSchedulerStore.kt$JdbcWorkflowSchedulerStore$suspend fun recover( now: Instant = Instant.now(), limit: Int = 500, ): Int - LongMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findConstructEndIdx(lines: List<String>, startIdx: Int, match: MatchResult): Int? + LongMethod:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findConstructEndIdx( lines: List<String>, startIdx: Int, match: MatchResult, ): Int? LongMethod:MaintainabilityBaselinePlugin.kt$MaintainabilityBaselinePlugin$override fun apply(project: Project) LongMethod:MaintainabilityBaselinePlugin.kt$MaintainabilityBaselinePlugin$private fun addApiCompatibilityDiagnostics( project: Project, checks: Map<String, MutableList<VerificationDiagnostic>>, ) LongMethod:McpStep.kt$McpWorkflowStep$private suspend fun executeMcpCall(toolCall: McpToolCall): McpToolResult @@ -787,7 +787,7 @@ LongMethod:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$fun jvmAiFrameworkComparison(rootDir: File) LongMethod:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$fun productPositioning(rootDir: File) LongMethod:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$fun readmePositioning(rootDir: File) - LongMethod:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$fun versionAlignment(rootDir: File, expectedVersion: String, expectedReleaseDate: String) + LongMethod:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$fun versionAlignment( rootDir: File, expectedVersion: String, expectedReleaseDate: String, ) LongMethod:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$fun workflowApiStabilityBoundary(rootDir: File) LongMethod:RuntimeEvidenceBundleIntegrationTest.kt$RuntimeEvidenceBundleIntegrationTest$@Test fun `three real exporters produce complete runtime evidence section`() LongMethod:RuntimeEvidenceContractValidator.kt$RuntimeEvidenceContractValidator$private fun validateMetadata(record: RuntimeEvidenceRecord) @@ -2427,7 +2427,6 @@ MaxLineLength:KotlinCancellationCatchScannerTest.kt$KotlinCancellationCatchScannerTest$assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must not truncate at raw-string brace, got: $baseFp") MaxLineLength:KotlinCancellationCatchScannerTest.kt$KotlinCancellationCatchScannerTest$assertTrue(baseFp.contains("deleteTemporaryState"), "Fingerprint must not truncate at string brace, got: $baseFp") MaxLineLength:KotlinCancellationCatchScannerTest.kt$KotlinCancellationCatchScannerTest$assertTrue(baseFp.contains("oldWork"), "Outer-catch fingerprint must include statement after inner runCatching, got: $baseFp") - MaxLineLength:KotlinCancellationCatchScannerTest.kt$KotlinCancellationCatchScannerTest$assertTrue(currentFp.contains("completelyDifferentWork"), "Outer-catch fingerprint must include statement after inner runCatching, got: $currentFp") MaxLineLength:KotlinCancellationCatchScannerTest.kt$KotlinCancellationCatchScannerTest$assertTrue(currentFp.contains("publishExternalResult"), "Fingerprint must not truncate at block-comment brace, got: $currentFp") MaxLineLength:KotlinCancellationCatchScannerTest.kt$KotlinCancellationCatchScannerTest$assertTrue(currentFp.contains("publishExternalResult"), "Fingerprint must not truncate at comment brace, got: $currentFp") MaxLineLength:KotlinCancellationCatchScannerTest.kt$KotlinCancellationCatchScannerTest$assertTrue(currentFp.contains("publishExternalResult"), "Fingerprint must not truncate at nested-comment brace, got: $currentFp") @@ -2986,7 +2985,7 @@ MaxLineLength:ResumeOperationRegistryTest.kt$} MaxLineLength:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$"Found another Gradle command at position $firstGradleIndex before governed-workflow at $governedRunIndex." MaxLineLength:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$"examples/README.md example matrix must use :run as primary command for tool-governance, found: ${matrixLine?.take(80)}" - MaxLineLength:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$Regex("""<groupId>dev\.tramai</groupId>.*?<version>\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)\s*</version>""", setOf(RegexOption.DOT_MATCHES_ALL)) + MaxLineLength:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$"""<groupId>dev\.tramai</groupId>.*?<version>\s*([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)\s*</version>""" MaxLineLength:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$require MaxLineLength:RootDocGuardVerifiers.kt$RootDocGuardVerifiers$val sdiSection = sectionBetween(text, "### Sovereign Document Intelligence", "### Sovereign Offline Verification") MaxLineLength:RuleBasedDlpInterceptor.kt$RuleBasedDlpInterceptor$require(rule.toolNames.all { it == it.trim() }) { "DLP rule must not contain tool names with surrounding whitespace" } @@ -3824,7 +3823,7 @@ NestedBlockDepth:JdbcWorkflowLeaseStore.kt$JdbcWorkflowLeaseStore$private fun loadLease( connection: java.sql.Connection, workflowName: String, workflowId: String, ): WorkflowLease? NestedBlockDepth:JdbcWorkflowSchedulerStore.kt$@Suppress("kotlin:S3776") // Manual JSON character-level parser — each step depends on shared state (index, inString, escaped, depth). Extracting would add more complexity than it removes, and cannot change the behavior without risking regressions. private fun splitJsonObjects(body: String): List<String> NestedBlockDepth:JdbcWorkflowSchedulerStore.kt$JdbcWorkflowSchedulerStore$private fun createAndClaimDueTicks( connection: Connection, now: Instant, ownerId: String, claimExpiresAt: Instant, limit: Int, ): List<ClaimedScheduledTick> - NestedBlockDepth:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findConstructEndIdx(lines: List<String>, startIdx: Int, match: MatchResult): Int? + NestedBlockDepth:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findConstructEndIdx( lines: List<String>, startIdx: Int, match: MatchResult, ): Int? NestedBlockDepth:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun joinCatchLines(lines: List<String>): JoinedLines NestedBlockDepth:MavenPublishedArtifactResolver.kt$MavenPublishedArtifactResolver$private fun snapshotValueForExtension(metadata: File, extension: String): String? NestedBlockDepth:ModuleDocContractVerifier.kt$ModuleDocContractVerifier$fun verify(rootDir: File): List<VerificationDiagnostic> @@ -3929,8 +3928,8 @@ ReturnCount:JdbcAuditStoreTest.kt$JdbcAuditStoreTest$private fun checkChain(events: List<AuditEvent>): Boolean ReturnCount:JdbcSovereignOpsAuditOutboxStore.kt$JdbcSovereignOpsAuditOutboxStore$private fun selectClaimableSerialized( conn: Connection, limit: Int, now: Instant, ): List<OutboxRow> ReturnCount:JdbcWorkflowSchedulerStore.kt$private fun decodeCalendarRules(payload: String?): List<CalendarRule> - ReturnCount:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun checkRethrowsCancellation(lines: List<String>, catchIdx: Int): Boolean - ReturnCount:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findCatchBodyEnd(lines: List<String>, catchIdx: Int): Int + ReturnCount:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun checkRethrowsCancellation( lines: List<String>, catchIdx: Int, ): Boolean + ReturnCount:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$private fun findCatchBodyEnd( lines: List<String>, catchIdx: Int, ): Int ReturnCount:MarkdownWorkflowCheckpointStore.kt$MarkdownWorkflowCheckpointStore$private fun effectiveCheckpointPath( workflowName: String, workflowId: String, ): Path ReturnCount:MavenPublishedArtifactResolver.kt$MavenPublishedArtifactResolver$fun resolve(moduleDir: File, baseNameWithoutVersion: String, expectedVersion: String, extension: String): File ReturnCount:ModelResponse.kt$ModelResponse$fun totalTokens(): Int? @@ -4539,7 +4538,7 @@ UnusedPrivateProperty:JdbcApprovalInboxQueryService.kt$JdbcApprovalInboxQueryService$private val clock: Clock = Clock.systemUTC() UnusedPrivateProperty:JdbcApprovedContinuationResumeQueueTest.kt$JdbcApprovedContinuationResumeQueueTest$private val baseNow: Instant = Instant.parse("2026-06-01T12:00:00Z") UnusedPrivateProperty:JdbcWorkflowPersistenceCancellationContractTest.kt$JdbcWorkflowPersistenceCancellationContractTest.InterruptIgnoringPreparedStatement$private val blockOnCompletion: CountDownLatch - UnusedPrivateProperty:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$val rethrowIfCancellationPattern = Regex( """\b${Regex.escape(catchVar)}\.rethrowIfCancellation\s*\(\s*\)""" ) + UnusedPrivateProperty:KotlinCancellationCatchScanner.kt$KotlinCancellationCatchScanner$val rethrowIfCancellationPattern = Regex( """\b${Regex.escape(catchVar)}\.rethrowIfCancellation\s*\(\s*\)""", ) UnusedPrivateProperty:MaintainabilityBaselinePlugin.kt$MaintainabilityBaselinePlugin$val criticalTestTaskPaths = criticalModules.sorted().map { "$it:test" } UnusedPrivateProperty:McpStep.kt$WorkflowMcpException$safe: Boolean UnusedPrivateProperty:ProviderCircuitBreakerLifecycleDiscriminatorTest.kt$ProviderCircuitBreakerLifecycleDiscriminatorTest$val observer = dev.tramai.core.observation.OperationObserver { RecordingObservation() } diff --git a/config/quality/AGENTS.md b/config/quality/AGENTS.md index 9e8a5a24b..b88dd6980 100644 --- a/config/quality/AGENTS.md +++ b/config/quality/AGENTS.md @@ -12,6 +12,7 @@ This directory contains TramAI's quality baseline and configuration files. | `module-boundaries.yml` | Allowed dependency directions between modules. | | `test-quality.yml` | Coverage, mutation, and performance targets for critical modules. | | `mutation-classifications.yml` | Pitest mutation family classification overrides. | +| `mutation-population-admissions.yml` | Base-side preauthorizations for admitting *appearing* candidate-only NON_KILLED identities (M30-M39). Empty by default: fail-closed, and every appearing survivor still fails M06. See `TASK-0.7.1g1G3` and `TASK-0.7.1g1G0` §6. | | `runtime-protocol-catalog.json` | Registered runtime protocol identifiers (auto-generated). | ## Rules diff --git a/config/quality/api-migrations.yml b/config/quality/api-migrations.yml index 5e79511da..9b82f8eec 100644 --- a/config/quality/api-migrations.yml +++ b/config/quality/api-migrations.yml @@ -45,16 +45,65 @@ migrations: rationale: "Refresh committed API dump to match current source; the SovereignOpsAuditOutboxStore API changed before API-compatibility enforcement existed (attempt-count parameter type). No new API surface introduced by this PR." migration: "Consumers of SovereignOpsAuditOutboxStore with an attempt-count parameter must pass int instead of the previous parameter layout; source-compatible callers require no change." - - module: ":tramai-spring-boot-starter-sovereign-persistence-jdbc" - fromSha256: "a362d955e271d5318ba65359f090dd3410b5a5e0af79836ff39ff36aec314bf3" - toSha256: "c12710086064ec051631dfef7fdadf268daf9ae1f43aedb1f7e00d90ed2d1da7" - targetVersion: "0.5.0" - rationale: "Refresh committed API dump to match current source; the SovereignOpsAuditOutboxStore API changed before API-compatibility enforcement existed (attempt-count parameter type). No new API surface introduced by this PR." - migration: "Consumers of SovereignOpsAuditOutboxStore with an attempt-count parameter must pass int instead of the previous parameter layout; source-compatible callers require no change." - - module: ":tramai-orchestration" fromSha256: "c5d9c58e5be2036e5786827251c5f44edd8d432e4644ebc5837b070372617cec" toSha256: "64c1ff59baae3a49faa0f1d1c8a617ab7cc7773413c302203b230e1ac2c8a733" targetVersion: "0.5.0" rationale: "Intentional additive API: JdbcStepAttemptRecordStore.createTableSql() is restored to a single executable DDL statement; multi-statement provisioning is exposed explicitly via createSchemaSql()/createSequenceTableSql() (one statement per entry, generic-JDBC safe). No signature changed; binary compatibility preserved. toSha256 reconciled to the post-BCV-surface (#352) canonical dump: 39 sanctioned-marked (@ExperimentalTramaiInternalApi) declarations removed from the surface, authorization unchanged." migration: "Applications executing createTableSql() alone must additionally execute createSequenceTableSql() (or createSchemaSql()) so the sequence counter table exists; source-compatible callers require no other change." + + - module: ":tramai-orchestration" + fromSha256: "64c1ff59baae3a49faa0f1d1c8a617ab7cc7773413c302203b230e1ac2c8a733" + toSha256: "beefaf0846ba9d94964e0303b20a1014e415fb96150096502fb61e4ab2e07c34" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1d run attribution: adds GovernedRun, the run handle that binds a workflow context to its canonical governed identity." + migration: "No consumer action: GovernedRun is additive; ungoverned workflow execution is unchanged." + + - module: ":tramai-scheduler" + fromSha256: "5e1ae0be11be0b3d00edb1ff02cf49409603bb4ea32e49a9dbc47ae71187f795" + toSha256: "18656b08156f0cef13589468dcdb7b9d0b7ac6e1bdbeb8eef37fc789d2e25b5e" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1d run attribution: adds the governed schedule-binding surface (CompositeWorkflowSchedulerStore and the governed binding store/timer pair) so a scheduled wake-up recovers the bound deployment identity." + migration: "No consumer action: governed scheduling is opt-in; the existing scheduler store contract is unchanged." + + - module: ":tramai-control-plane" + fromSha256: "b73b76dea59ef5dbd27bebfaf83260479164d7baa02d229423772b0897bf69cd" + toSha256: "71acccdcab31390b76032fc1979384dea72cce7dca8f6a5156254c2fb319e675" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1e/0.7.1f control-plane authority contract and safe exposure model: adds ClassifiedRead (WorkloadExposure + QueryConsistency + WorkloadStateVersion) as the classified read shape, and narrows the generic outcome payloads from RegisteredWorkload to WorkloadExposure so the CAS/rebinding witness is no longer reachable from a generic read." + migration: "Binary-incompatible for preview consumers: LifecycleTransitionOutcome.Applied and the non-stale outcome payloads change descriptor (RegisteredWorkload -> WorkloadExposure) and ClassifiedRead.registration/outcome.registration are renamed to exposure, so constructors, copy(...), componentN() and getters must be updated and recompiled. The exposed payload is strictly narrower: the configuration fingerprint is returned on no generic surface. WorkloadRegistrationAuthority command signatures are unchanged." + + - module: ":tramai-engine" + fromSha256: "2be18464bc69a5c9c0b387d7a88cf6701066f5f4bf31c4e93ec8d9a82aa45dfc" + toSha256: "97449797943f051f255c0f96d69746f9145059fb577c8927d7443e9958da435f" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1d1 governed approval identity carriage: adds GovernedSuspendedInvocation, binding SuspendedInvocationMetadata to the framework-owned GovernedRunIdentity so a resumed suspension recovers the exact workload/configuration attribution." + migration: "Additive. GovernedSuspendedInvocation is a new public type; existing suspension and resume signatures are unchanged, so no recompilation is required." + + - module: ":tramai-persistence-file" + fromSha256: "2c116948a04c020d41bb3e8af5339b542f1398f8ddfc706b5b0ecfde0f6e583d" + toSha256: "f4f21c7cb3a6185994706adf4340ad734c8072520363e03faf75b097900c450e" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1d1 governed approval identity carriage on the file-backed store: adds GovernedFileApprovalStore and its factory, implementing ApprovalStore plus the governed approval surface (createGovernedApproval, attributionOf) so the file backend carries the same governed attribution contract as the JDBC backend." + migration: "Additive. GovernedFileApprovalStore is a new public type and the existing file store contract is unchanged; file-backed consumers gain the governed surface without recompiling." + + - module: ":tramai-persistence-jdbc" + fromSha256: "820477f11b52fdadb78157ed9d7b45404c9f54f642cfe5b51d1efe87fe9c3815" + toSha256: "0b04daf0a52a32e5467840089ba305dbe0979f2875b81ca02be8cd063025f4fa" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1d/0.7.1e persistence work on the JDBC backend: the governed approval mutation store and the governed suspension surfaces change the committed dump relative to release/0.7.0." + migration: "Source-compatible for existing callers; the governed surfaces are additive. Java/Spring consumers must recompile against this version where a constructor descriptor changed." + + - module: ":tramai-spring-boot-starter-sovereign-ops" + fromSha256: "7100484d4ea3e5a5e84b19abce1b1046de4aceab5c01722186f9450d2d24e416" + toSha256: "5e773044a6ae45de22fa3f983ebc192b4f2c3ee7df530d5e2cf74b92f71a6dda" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1d run attribution: the sovereign ops autoconfiguration carries the governed ApprovalRunAttribution variant through the outbox, changing the committed dump relative to release/0.7.0." + migration: "Source-compatible for existing callers: the governed attribution variant is additional. Recompile against this version if you construct the attribution types directly." + + - module: ":tramai-spring-boot-starter-sovereign-persistence-jdbc" + fromSha256: "c12710086064ec051631dfef7fdadf268daf9ae1f43aedb1f7e00d90ed2d1da7" + toSha256: "2f12c7776fc64c59b1cf128038db52bbdb400f4c2c928e87dcabf3459b81557b" + targetVersion: "0.7.0" + rationale: "Epic 0.7.1d run attribution on the sovereign JDBC starter: the approval mutation store bean method gained a governed-suspension-store parameter, changing the committed dump relative to release/0.7.0." + migration: "The bean method keeps a default parameter, so Spring wiring is unaffected, but the JVM descriptor changed: Spring/Java consumers must recompile against this version." diff --git a/config/quality/maintainability-deviations.yml b/config/quality/maintainability-deviations.yml index da9faf677..e0796586a 100644 --- a/config/quality/maintainability-deviations.yml +++ b/config/quality/maintainability-deviations.yml @@ -263,6 +263,16 @@ deviations: targetPhase: "0.6.1" owner: "GionaGranchelli" + - id: MQ-0026 + metric: globalMutableState + scope: ":tramai-control-plane" + baseline: 0 + allowed: 1 + reason: "The module's single global-state finding is InMemoryWorkloadRegistrationStore (mutable-collection: HashMap in tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/InMemoryWorkloadRegistrationStore.kt). It is deliberately lock-guarded: every operation runs inside synchronized(lock) on a store-wide monitor, so registrations and configurationBindings are mutated only under that lock and the create/compareAndSet compound invariants stay atomic across both maps. GlobalStateInventory classifies a declaration from its source text alone — threadSafety and lifecycle are emitted as the constants 'unknown' and 'process' for every match, and the same regex flags ConcurrentHashMap and mutableMapOf identically — so switching collection type cannot clear this finding, and a concurrent map would in fact break the cross-map atomicity the authority contract requires. tramai-control-plane did not exist at the 0.6.0 freeze, so there is no committed identity to inherit: allowed 1 covers exactly this one finding and blocks any growth in this module. Resolution path: teach GlobalStateInventory to distinguish lock-guarded declarations (a build-logic scanner change), or retire the reference store once the JDBC store is the only supported implementation." + acceptedAt: "2026-09-15" + targetPhase: "0.7.2" + owner: "GionaGranchelli" + # Verification policy: # - New findings that match an existing deviation (same metric + scope) are allowed. # - New findings with no matching deviation cause verify tasks to fail. diff --git a/config/quality/mutation-authority-digest-certificates.yml b/config/quality/mutation-authority-digest-certificates.yml new file mode 100644 index 000000000..07afeb81f --- /dev/null +++ b/config/quality/mutation-authority-digest-certificates.yml @@ -0,0 +1,31 @@ +# Digest-migration certificate ledger (0.7.1g1P2, M40-M47). +# +# Base-side authority: a certificate here records that one exact raw whole-population authority +# digest is semantically equal to one exact authority-projections digest, for one exact bounded set +# of population admissions. It exists so a semantic upgrade of the authority digest never requires +# rewriting historical authority (M36 stays literally unchanged). +# +# Lifecycle: mint against the exact base (M45) -> retain byte-identically (M46) -> consume only from +# base (M43 + M42 + M41 + M40) -> remove in the valid consuming transition (M44 + M47). Rules live in +# MutationAuthorityDigestCertificateCeremony; this file carries only the certificate itself. +# +# The certificate below is minted by the P1M transition and changes nothing else: the 67 P1 +# population admissions remain byte-identical and keep their historical raw-v1 digest. +schemaVersion: "1" + +certificates: + - fromAlgorithm: "raw-v1" + fromDigest: "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + toAlgorithm: "authority-v2" + toDigest: "e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad" + admissionSetDigest: "98a9587a1068ec0cd7158a05ba6909c61c522308c272e7fa9cb27d5edd93a79c" + fromBaseSha: "64d05450c285ecd9cf3635ca2935da816f649856" + reason: >- + Bounds the semantic migration of the P1 whole-population authority digest from raw-v1 to + authority-v2, per C7: raw PIT status is diagnostic evidence and not authority. The 67 P1 + population admissions are unchanged and remain bound to their historical raw-v1 digest + (fromDigest); this certificate states that the same frozen unrestricted measurement, whose + canonical authority-v2 projection was byte-identical across all four preserved campaigns, + projects to toDigest. It authorizes a later transition to consume that pre-existing + population-context authority; it does not upgrade, rewrite, re-mint or re-adjudicate any + admission, and it changes no mutation outcome. diff --git a/config/quality/mutation-evolution.yml b/config/quality/mutation-evolution.yml new file mode 100644 index 000000000..a1825ffd8 --- /dev/null +++ b/config/quality/mutation-evolution.yml @@ -0,0 +1,2 @@ +schemaVersion: "1" +records: [] diff --git a/config/quality/mutation-population-admissions.yml b/config/quality/mutation-population-admissions.yml new file mode 100644 index 000000000..dbfdd8d3d --- /dev/null +++ b/config/quality/mutation-population-admissions.yml @@ -0,0 +1,1715 @@ +# Population-admission preauthorizations for appearing candidate-only NON_KILLED identities. +# +# Authority: M30-M39 (TASK-0.7.1g1G3). Design and trust properties: +# TASK-0.7.1g1G0-POPULATION-ADMISSION-PREFLIGHT.md section 6. +# +# Trust boundary (same as the classification enrollment ledger, M22-M29): the authorization must +# already exist in the PR's BASE. A candidate may never create the authority it uses. +# +# P1 - MINT the identity is absent from the committed population and from this ledger; the +# transition proposes an authorization. The population does not change. +# P2 - CONSUME a LATER transition, whose base already contains the authorization, commits the +# exact fresh measurement. The authorized row must appear as NON_KILLED, the row +# must equal the authorized row field for field, the analyzer semantics must match, +# the measurement digest must equal populationDigest, and the authorization must be +# removed (consumed) in the same transition. +# +# An empty list is the fail-closed default: nothing may be admitted and every candidate-only +# NON_KILLED identity absent from the committed population still fails M06. +# +# identity canonical 64-hex schema v2 mutation identity - the only key; descriptor or +# source-line similarity is never authority +# status, outcome the exact persisted row: raw status and canonical outcome +# family, module the exact persisted row: family and module +# analyzer the semantics this authorization is valid under (M16-M19); consuming it +# under different settings fails +# fromBaseSha the exact authority base SHA this authorization was first proposed against. +# Enforced on introduction; thereafter immutable provenance, and deliberately +# NOT required to equal the immediate base SHA of later retaining or consuming +# transitions - a pending authorization legitimately survives intermediate +# merges. +# populationDigest projection hash of the measurement in which the identity appears as +# NON_KILLED. Compared at consumption against the verifier's own canonical +# fresh measurement, never against candidate-supplied data, so a candidate can +# never construct the evidence that authorizes itself. +# reason the adjudication rationale; rewriting it after the decision fails (M36) +# issue, targetPhase optional tracking fields; rewriting them after the decision fails +# authorizedBy, authorizedAt audit only - no enforcement value +schemaVersion: "1" + +admissions: + - identity: 050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc145" + - identity: 098258d70a2379a58e39d24f5f85824705998bb6d3e20cf0bec54ffecc4db0a6 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: 09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc69" + - identity: 10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc321" + - identity: 16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc1420" + - identity: 1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc151" + - identity: 1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc1100" + - identity: 2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc185" + - identity: 252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc634" + - identity: 2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc120" + - identity: 3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc287" + - identity: 3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc161" + - identity: 4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc457" + - identity: 43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc1072" + - identity: 4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc143" + - identity: 492f9a132e141f512c5690d8b0d43c6e5925b488de13d80ef71504bcf61b62d4 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "final adjudication EQUIVALENT in TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json; predecessor TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json disposition UNDETERMINED" + - identity: 564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67 + status: NO_COVERAGE + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5d UNREACHABLE, TASK-0.7.1g1G5d-NO-COVERAGE-4-MANIFEST.json, pc194; artifact-relative: if resolveGovernedSuspension()/resolveGovernedIdentity() ever gains a suspension point this becomes a re-adjudication candidate rather than remaining grandfathered" + - identity: 56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc100" + - identity: 5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1 + status: NO_COVERAGE + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5d UNREACHABLE, TASK-0.7.1g1G5d-NO-COVERAGE-4-MANIFEST.json, pc204; artifact-relative: if resolveGovernedSuspension()/resolveGovernedIdentity() ever gains a suspension point this becomes a re-adjudication candidate rather than remaining grandfathered" + - identity: 5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc308" + - identity: 60f7b020e009cc99a8a80e19e34a956e570614449294444db057993e433b1ac6 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: 611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc198" + - identity: 62911071da6c7a9fe85f76a41c5a23d56b1156e2d1447dd96ccce962aabeff3b + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "final adjudication EQUIVALENT in TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json; predecessor TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json disposition UNDETERMINED" + - identity: 63e5372ba37bc3b097def60c22bddd050404d168b26c2fa73bc94a99a6705c1e + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "final adjudication EQUIVALENT in TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json; predecessor TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json disposition UNDETERMINED" + - identity: 66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e + status: NO_COVERAGE + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5d UNREACHABLE, TASK-0.7.1g1G5d-NO-COVERAGE-4-MANIFEST.json, pc163; artifact-relative: if resolveGovernedSuspension()/resolveGovernedIdentity() ever gains a suspension point this becomes a re-adjudication candidate rather than remaining grandfathered" + - identity: 6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc244" + - identity: 6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc804" + - identity: 6cef3fd2e23d3df1571d390564b8274596c70c97965bc5719603d39116b74b8c + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: 6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc158" + - identity: 6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc304" + - identity: 70d345f72064e7cea4ebb9cff97f74946f00d582ba38a4e62c5498261494b0a0 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (discarded lambda result)'}]" + - identity: 7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc478" + - identity: 827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc803" + - identity: 8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc187" + - identity: 8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc63" + - identity: 8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc189" + - identity: 9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc465" + - identity: 9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc147" + - identity: 9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc799" + - identity: a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc738" + - identity: a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc341" + - identity: ab78ed8577ff2073f4690730365919999ad7f76ddb68bef5c56a08deeef15c31 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc243" + - identity: c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc568" + - identity: c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc362" + - identity: c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc155" + - identity: c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc228" + - identity: ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc1785" + - identity: d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc189" + - identity: d5b70d305cec04c7aca39a5a9c629221c88509f4a8ee07d9643b2f8e37d1277f + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc130" + - identity: e0ae69141eabff4855f7acc9e498139795c71ae3aca5a096d468101f3ceca1f0 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc64" + - identity: e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc618" + - identity: e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32 + status: NO_COVERAGE + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5d UNREACHABLE, TASK-0.7.1g1G5d-NO-COVERAGE-4-MANIFEST.json, pc164; artifact-relative: if resolveGovernedSuspension()/resolveGovernedIdentity() ever gains a suspension point this becomes a re-adjudication candidate rather than remaining grandfathered" + - identity: e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc306" + - identity: eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc54" + - identity: eb8827c4f4ca1d6b1f90686e5335ffb189d78b4694548a6843f7ebc5d1d95a79 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: ebc96b3e8599f671eb60c470b2a259a474aa994c2599547731b9944800e60b57 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: eec481d97a434f85884bdc18877d8a54801c8ba289658e0fb49d92e286cad2f7 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: f175e8698db5459d85e11e56b04155c7b963d148d906f57908c6440dd533fead + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (discarded lambda result)'}]" + - identity: f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc567" + - identity: f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5c EQUIVALENT, TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json, pc267" + - identity: f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc144" + - identity: f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590 + status: TIMED_OUT + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "G5b TOOLING_LIMITATION, TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json, sentinel bytecode pc159" + - identity: f60db12bdb9ea3f1b61d5c01867a18b9ebe7f2a8726a8287f8129dad183a4625 + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (case-0 entry check)'}]" + - identity: f802165800d8737a082118c89e9c5eae4e7d8470cd15c54c57bc19476be9a15c + status: SURVIVED + outcome: NON_KILLED + family: approval + module: ":tramai-engine" + analyzer: + pluginVersion: "1.19.0" + engineVersion: "1.22.1" + mutators: + - CONDITIONALS_BOUNDARY + - INCREMENTS + - INVERT_NEGS + - MATH + - NEGATE_CONDITIONALS + - TRUE_RETURNS + - FALSE_RETURNS + - PRIMITIVE_RETURNS + - EMPTY_RETURNS + - NULL_RETURNS + - VOID_METHOD_CALLS + timeoutConst: 4000 + timeoutFactor: 1.25 + fromBaseSha: 5bcb030c953c425289fad9e640ab65c15936ecda + populationDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0 + reason: "prior adjudication recorded in TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json: [{'source': 'TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json', 'disposition': 'EQUIVALENT (discarded lambda result)'}]" diff --git a/config/quality/mutation-population-promotions.yml b/config/quality/mutation-population-promotions.yml new file mode 100644 index 000000000..7d4a1d91f --- /dev/null +++ b/config/quality/mutation-population-promotions.yml @@ -0,0 +1,30 @@ +# Promotion declarations: carry an already-certified evidence population across a release promotion +# boundary. +# +# Authority: TASK-0.7.1i. Minting and promotion are different operations. M35 and M45 bind a NEWLY +# INTRODUCED admission/certificate to the exact authority base it was minted against, so a promotion +# - where the entire promoted line is new relative to its base - can satisfy them by no legitimate +# means: re-minting admissions, rewriting fromBaseSha, manufacturing replacement certificates and a +# per-entry migration chain are all forbidden. +# +# A declaration here states the narrow fact the promotion actually needs: this exact evidence +# population already exists, is already certified, and is CARRIED ACROSS unchanged. Because it was not +# minted by this transition, the mint-binding rules do not apply to it. +# +# This grants nothing. It is exact: the base SHA must be the transition's own, and both counts and +# every row's digest must match the ledgers as they appear against that base. Anything else is "no +# promotion", and every appearing row fails M35/M45 exactly as before. +# +# SHA-exact and candidate-side, deliberately: the declaration names the base SHA it promotes onto, so +# it can never authorize a different transition, and it must ride in the promotion's own candidate +# because a file committed to the base branch cannot name the commit that contains it. +schemaVersion: "1" + +promotions: + - # 0.7.1 -> release/0.7.0 promotion. The 67 admissions and the single raw-v1 -> authority-v2 + # digest-migration certificate were minted and certified on the 0.7.1 line; the promotion carries + # them across unchanged, so M35/M45 do not bind them to the promotion base. + promotionBase: "9ccbd0d3c6d2b0c0f30277724ef9a33ef240a22a" + populationDigest: "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0" + admissions: 67 + certificates: 1 diff --git a/docs/modules/tramai-control-plane.md b/docs/modules/tramai-control-plane.md index 0c1c89666..448ad9f3a 100644 --- a/docs/modules/tramai-control-plane.md +++ b/docs/modules/tramai-control-plane.md @@ -38,6 +38,16 @@ lifecycle/metadata and state version?"* — durably and without silent overwrite Suspending/retiring a registration never cancels running workflows. RETIRED is terminal. `WorkloadStateVersion` advances on every successful authoritative mutation and never changes identity. +### Safe exposure model (0.7.1f) + +`WorkloadExposure` is the single generic control-plane payload. It contains only deployment +identity, bounded owner/purpose metadata, registration lifecycle, and state version. The +`configurationFingerprint` is a DECLARATION INPUT, NOT READABLE: a client originates it when it +declares a registration, and it is never reflected back through a generic read, an outcome, or HTTP +— it is the CAS/rebinding witness `WorkloadRegistrationStore.compareAndSet` compares. +`WorkloadRegistrationStore` remains the deliberate exception because its SPI must retain +`RegisteredWorkload` for CAS. + ### Thread-safety and concurrency - Every store mutation is atomic; every authority mutation is a version-guarded compare-and-set — stale writers lose, no last-writer-wins. @@ -67,7 +77,18 @@ authoritative mutation and never changes identity. - Do not cancel running workflows when a registration is suspended/retired. - Do not embed `WorkloadStateVersion` in any identity type. - Do not replace `WorkflowRegistry` executable-definition lookup. -- Do not add REST/query surfaces, If-Match/ETag, projection consistency classes or arbitrary metadata maps here (later candidates). +- Do not add HTTP mechanics here (If-Match/ETag parsing, status codes, problem DTOs) or a web + dependency of any kind: since 0.7.1e the framework-neutral command/query contract lives in this + module and the transport adapter lives in `tramai-server`. +- Do not add a projection engine, a generic CQRS layer or arbitrary metadata maps: 0.7.1e ships the + read classification (`QueryConsistency`, `ClassifiedRead`) and the read-only guarantee, not a + projection store. +- Do not expose `RegisteredWorkload` on a generic control-plane port or outcome, add `Map`/`Any`/ + `JsonNode` to `WorkloadExposure`, or read an authority witness off a generic surface. +- Do not re-implement registration, lifecycle or version rules in an adapter: `WorkloadRegistrationAuthority` + implements `WorkloadControlPlaneCommands`/`WorkloadControlPlaneQueries` directly, so an adapter only + parses transport input and maps typed outcomes. Reproducing `find -> compare -> mutate` outside the + authority creates a second authority. ### Related architecture diff --git a/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md b/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md index 59d7fb70e..b19c38b38 100644 --- a/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md +++ b/docs/roadmap/0.7.0/EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md @@ -1,7 +1,7 @@ # Epic 0.7.1 — Control-Plane Authority & Workload Identity +**Status:** ✅ **CONTENT FROZEN** — ready for 0.7.1i integration closure / master promotion. 0.7.1a–0.7.1g implemented and proven; 0.7.1h integration/docs closure recorded in [§0.7.1h](#071h--integration-closure--content-freeze) below. Task records: [0.7.1a](TASK-0.7.1a-BASELINE-AUTHORITY-AUDIT.md), [0.7.1b](TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md), [0.7.1c](TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md), [0.7.1d](TASK-0.7.1d-RUN-ATTRIBUTION.md), [0.7.1d1](TASK-0.7.1d1-GOVERNED-APPROVAL-IDENTITY-CARRIAGE.md), [0.7.1e](TASK-0.7.1e-CONTROL-PLANE-AUTHORITY-CONTRACT.md), [0.7.1f](TASK-0.7.1f-SAFE-EXPOSURE-MODEL.md), [0.7.1g1P2](TASK-0.7.1g1P2-AUTHORITY-MODEL-RECONCILIATION.md) **Branch:** `epic/0.7.1-control-plane-authority` -**Status:** 🟡 Active — 0.7.1a audit recorded in [TASK-0.7.1a-BASELINE-AUTHORITY-AUDIT.md](TASK-0.7.1a-BASELINE-AUTHORITY-AUDIT.md); 0.7.1b identity contract implemented in [TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md](TASK-0.7.1b-WORKLOAD-IDENTITY-CONTRACT.md); 0.7.1c authoritative registration/state boundary implemented in [TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md](TASK-0.7.1c-AUTHORITATIVE-REGISTRATION-STATE-BOUNDARY.md) **Dependencies:** NONE ## Executive decision @@ -48,10 +48,16 @@ stale privileged mutation cannot silently overwrite newer authoritative state | 0.7.1b | Workload identity contract | Define stable workload, configuration/version, environment/deployment and correlation types/validation | | 0.7.1c | Authoritative registration/state boundary | Establish source-of-truth and lifecycle semantics for control-plane workloads | | 0.7.1d | Run attribution | Persist/propagate workload+configuration identity through supported runs without ambiguity | +| 0.7.1d1 | Governed approval identity carriage | Get both gateway creation paths onto the existing governed suspension path; carry framework-owned attribution through the approval lifecycle; enforce continuity at reconstruction (#418) | | 0.7.1e | Control-plane authority contract | Define query vs command boundaries, consistency classes and stale-precondition semantics | -| 0.7.1f | Safe exposure model | Define default metadata categories; protected payloads have no generic query surface | +| 0.7.1f | Safe exposure model | Implement the explicit `WorkloadExposure` safe exposure model; protected payloads have no generic query surface | | 0.7.1g | Evidence/compatibility proof | Typed identity/lifecycle evidence, API/TCK impact, adversarial and mutation proof | | 0.7.1h | Integration/docs | Update architecture/docs/reference fixtures and prove exact-head Epic acceptance | +| 0.7.1i | Integration closure / master promotion authority | Bind API migrations to the frozen `master → final-0.7.1` hashes, certify the release-sovereign population pin from evidence, and establish a promotion sequence that keeps analyzer/build-logic changes out of the runtime transition — without weakening `analyzer-runtime-separation` ([TASK-0.7.1i](TASK-0.7.1i-INTEGRATION-CLOSURE.md)) | +| 0.7.1g1G1 | Mutation-debt taxonomy | Classify the 195 candidate-only `NON_KILLED` identities from their mutated instructions: 124 glue, 10 weak-assertion (8 closed by #444, 1 closed by #446, 1 deferred), 4 equivalent, 2 compiler guards, 9 closed by #442 — with the 46 `TIMED_OUT` recorded as **undetermined** rather than a settled category ([TASK-0.7.1g1G1](TASK-0.7.1g1G1-MUTATION-DEBT-TAXONOMY.md)) | +| 0.7.1g1G2 | Residual taxonomy and adjudication | Give every residual candidate-only identity exactly one disposition at the post-0.7.1g1G1 frozen head: 177 residual = 12 equivalent + 46 tooling limitation + 1 deferred structural + 118 undetermined, with the re-census showing zero status movement and lost/duplicate/unexplained all zero ([TASK-0.7.1g1G2](TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md)) | +| 0.7.1g1G3 | Appearing population admission ceremony | Implement M30-M39: a base-authoritative, exact-row, single-use admission mechanism that lets a later exact-population transition admit specifically authorized candidate-only `NON_KILLED` identities while every unauthorized appearance still fails M06 — 0 admissions, 0 preauthorizations ([TASK-0.7.1g1G3](TASK-0.7.1g1G3-POPULATION-ADMISSION-CEREMONY.md)) | +| 0.7.1g1P2 | Authority-model reconciliation | Close the canonical T1–T19 authority model: one shared `AdmissionAuthority` fact threaded to M34/admission/certificate consumers, real-task transport boundaries, and the base-side raw-v1 → authority-v2 migration certificate chain. Step 3c closed as COMPLETE ([TASK-0.7.1g1P2](TASK-0.7.1g1P2-AUTHORITY-MODEL-RECONCILIATION.md) §7/§11) | ## Acceptance criteria @@ -76,6 +82,42 @@ Tests must reject at least: Kill mutations that remove identity/version checks, bypass stale preconditions, or reclassify protected fields as default-safe where such checks are implemented in 0.7.1. +## 0.7.1h — Integration closure & content freeze + +Reconciled against the merged implementation at the epic tip, not against task-document `Status:` +lines. This task changed documentation only: no production code, no tests, no authority artifacts, +no mutation baselines. + +### Epic acceptance criteria — existing evidence + +| criterion | evidence | result | +|---|---|---| +| Supported runs identify workload and configuration/version unambiguously | `ServerGovernedRunAttributionTest` ("a registered active deployment starts a governed run with a fresh run id"; "two governed runs of the same deployment get distinct run ids"; "a configured identity differing only by configuration version is rejected") + `WorkloadRegistrationAuthorityTest` (register/idempotency/lifecycle preserve identity) | PASS | +| Two distinct deployments cannot collapse to one identity accidentally | `WorkloadRegistrationAuthorityTest` ("configuration rebinding is rejected from a different deployment scope"; "the same configuration on a different deployment registers cleanly"; "re-registering the same scope with a different configuration is rejected, not upserted") | PASS | +| Dashboard/query replacement cannot change governance semantics | `WorkloadControlPlaneContractTest` ("the read port exposes no operation that can mutate authoritative state"; "a lagging projection observation never authorizes a command"; "a command takes an explicit version, never a read record") + `WorkloadExposureModelTest` ("query and command type graphs are exact and safe") | PASS | +| Runtime mutation paths are distinct from projection/query paths | `WorkloadControlPlaneContractTest` ("authoritative read is classified as such and reports the authoritative version"; "projection read states its consistency class and the version it observed") + `WorkloadExposureModelTest` ("authoritative and projection reads expose identical safe shape") | PASS | +| Stale-state protection is defined and tested for commands that require it | `WorkloadControlPlaneContractTest` ("concurrent commands on the same expected version yield exactly one winner and a truthful stale loser"; "RETIRED is terminal through the command port and stays distinct from a stale expectation") + `WorkloadRegistrationAuthorityTest` ("stale metadata update is rejected without mutation"; "stale lifecycle transition loses and does not mutate"; "two concurrent metadata writers produce one winner and one stale loser") | PASS | +| Sensitive content is excluded from generic control-plane metadata | `WorkloadExposureModelTest` ("exposure has exactly the approved four properties"; "mapper excludes the authority fingerprint"; "metadata has exactly owner and purpose"; "port signatures expose no forbidden extension type"; "classified read exposes safe model and rejects version contradiction") | PASS | + +All six criteria are satisfied by tests that already existed. **No implementation gap was found**, +and no test was created or renamed for this task. + +### Documentation reconciliation + +- `docs/modules/tramai-control-plane.md` already states the responsibility, the safe exposure model + (a declaration input that is never reflected back), the CAS/stale-writer semantics, the + RETIRED-terminal lifecycle and the TCK inventory correctly — **left unchanged**. +- This record's `Status:` line was materially stale (it named only 0.7.1a/b/c/f) — corrected, and the + missing 0.7.1d/d1/e/g1P2 records linked. +- The invariant "candidate transitions cannot mint the authority they consume" is recorded in its own + task record ([0.7.1g1P2](TASK-0.7.1g1P2-AUTHORITY-MODEL-RECONCILIATION.md) §7/§11) rather than + duplicated here. + +### Content freeze + +`epic/0.7.1-control-plane-authority` is **CONTENT FROZEN** for 0.7.1i: no implementation work, no new +tests and no authority-artifact changes land on this branch except 0.7.1i's own promotion authority. + ## Exit 0.7.1 is done when later Epics can consume stable identity/authority contracts without inventing parallel representations. diff --git a/docs/roadmap/0.7.0/README.md b/docs/roadmap/0.7.0/README.md index 5b60e73d6..b178f2346 100644 --- a/docs/roadmap/0.7.0/README.md +++ b/docs/roadmap/0.7.0/README.md @@ -107,7 +107,8 @@ Dashboard != policy authority 4. the reference control-plane scenario, provider-input release/minimization scenario, and persisted-cancellation scenario pass end to end; 5. no deferred 0.8/0.9/0.10 scope has leaked into the release without an explicit release-cut change; 6. `release/0.7.0 → master` is a certification/promotion PR, not a first-time integration event; -7. the [XR1 external-runtime authority proof](CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md) proves that one non-TramAI-authored JVM workload can cross an authoritative TramAI governance boundary without a second policy model or an overstated enforcement claim. +7. the 0.7.1 integration-closure gates are met — API migration hashes bound to the frozen head, the release-sovereign population pin certified from measurement, and analyzer/build-logic changes kept out of the runtime promotion transition — and meeting them did **not** involve weakening `analyzer-runtime-separation` ([TASK-0.7.1i](TASK-0.7.1i-INTEGRATION-CLOSURE.md)); +8. the [XR1 external-runtime authority proof](CHECKPOINT-0.7-XR1-EXTERNAL-RUNTIME-AUTHORITY-PROOF.md) proves that one non-TramAI-authored JVM workload can cross an authoritative TramAI governance boundary without a second policy model or an overstated enforcement claim. ## Execution documents diff --git a/docs/roadmap/0.7.0/TASK-0.7.1d-RUN-ATTRIBUTION.md b/docs/roadmap/0.7.0/TASK-0.7.1d-RUN-ATTRIBUTION.md new file mode 100644 index 000000000..f432e8295 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1d-RUN-ATTRIBUTION.md @@ -0,0 +1,188 @@ +# Task 0.7.1d — Authoritative Run Attribution + +**Parent:** [Epic 0.7.1 — Control-Plane Authority & Workload Identity](EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md) + +**Branch:** `task/0.7.1d-run-attribution` → `epic/0.7.1-control-plane-authority` + +**Baseline:** epic head `e8ad7136` (0.7.1c authoritative registration, #403; #415 release-line +API/versioning policy) + +**Change class:** `runtime-behaviour` (primary) + +**Status:** Implementation complete, in review. Mutation/adversarial certification is deferred to +0.7.1g by the Epic (§ Sequencing). + +## Decision + +A governed run acquires exactly one authoritative workload/configuration context at creation and +that context survives every supported runtime boundary unchanged: + +```text +RegisteredWorkload (authority, 0.7.1c) + │ snapshot once, at run creation + ▼ +GovernedRunIdentity = WorkloadDeploymentIdentity + RunId + │ + ├── GovernedRun (typed runtime envelope; NEW in 0.7.1d) + ├── approval suspension / outbox (V2 provenance, durable) + ├── persisted checkpoint (internal encoding, durable witness) + ├── resume / worker recovery (recovered from the persisted witness) + └── scheduler continuation (durable deployment binding, delay/wakeup ticks) +``` + +Every copy MUST represent the same canonical identity. Reuse the 0.7.1b/0.7.1c types; no new +identity type, no parallel attribution store, and no second run identifier. + +### How the identity is carried, per boundary + +- **Runtime boundary — `GovernedRun`.** A governed execution is represented by the additive + `GovernedRun(context, identity)` envelope. It carries the canonical identity *alongside* the + existing `WorkflowContext` instead of widening it: `WorkflowContext(workflowId, attributes)` + stays the public contract, so ungoverned execution and its ABI are untouched. + `GovernedRun.start(...)` establishes identity exactly once; `GovernedRun.resume(...)` rebuilds + the boundary for an existing run and never regenerates the run id. +- **Durable boundary — reserved checkpoint metadata, an internal encoding.** The witness is encoded + into five framework-owned keys (`checkpoint.identity.workload`, + `checkpoint.identity.configuration`, `checkpoint.identity.configuration_version`, + `checkpoint.identity.environment`, `checkpoint.identity.deployment`). This is a **persistence + encoding, not an authority**: the authority is the canonical identity of the running execution, + the framework always writes these keys last, and application checkpoint metadata can never + override them. The keys are deliberately not prefixed `tramai.`, a namespace the architecture + guard reserves for runtime identifiers and configuration properties. +- **The run id is not persisted a second time.** The checkpoint's own `workflowId` *is* the run id, + so persistence cannot store an identifier that diverges from the run it describes. Invariant, + enforced structurally at construction: `governedRunIdentity.runId.value == context.workflowId`. +- **Checkpoint-store schemas are unchanged.** Existing implementations round-trip the reserved + metadata keys through the schema they already have; no store gains identity columns, and no + second attribution store appears. Ungoverned checkpoints encode to an empty map, so records + written before attribution existed remain byte-compatible. +- **Approval suspension and sovereign-ops outbox — V2 provenance.** A governed suspension and a + governed outbox record carry the complete `GovernedRunIdentity` as one non-optional nested + object in a V2 payload; V1 keeps exactly its released DTO shape. A malformed or mistyped + `schemaVersion` declaration is corruption; an unknown integral version is unsupported format. + Neither case is legacy. +- **Scheduler — durable deployment binding.** Continuation classification and deployment identity + come from the durable binding store, not from the transient runtime registration, so a schedule + created before a registration change still recovers the identity it was created with. +- **Continuation — recovered, never rebuilt.** `recoverGovernedRun` reads the witness from the run's + own checkpoint and returns a `GovernedRun`; it is never reconstructed from whatever registration, + binding or schedule happens to exist at continuation time. + +## Contract + +### Identity rules + +- **`RunId` is the existing `workflowId`.** The server already creates exactly one `workflowId` per + run; `RunId(workflowId)` wraps it. A second generated identifier would immediately create two + competing run identities. +- **Snapshot at creation, then immutable.** At governed run start, resolve the runtime binding + against the 0.7.1c authoritative registration and snapshot `registration.identity`. The + registration is never consulted again to reconstruct this run's identity: later + metadata/lifecycle/version changes cannot rewrite historical attribution. +- **Attribution is typed, never an attribute.** No `attributes["governedRunIdentity"] = …`; that + would recreate the untyped-metadata problem 0.7.1 exists to remove. The reserved + `checkpoint.identity.*` keys are the *durable* form of a typed identity, not a public metadata + contract. +- **The binding is a pointer, not authority.** The runtime binding from an executable workflow to + `WorkloadDeploymentScope` (workload/environment/deployment) carries no fingerprint, lifecycle or + configuration semantics. `WorkflowRegistry` keeps owning executable lookup and gains no authority + over configuration identity. + +### Fail-closed rules + +- No authoritative registration for the bound scope → **governed run does not start**. +- No authoritative registration record → **admission is rejected** (`WorkloadAdmissionRejectedException`). +- All reserved keys absent → intentionally ungoverned (legacy) run; **a partial set is corruption** + and throws rather than resuming un-attributed. A partially attributed identity is never returned. +- Checkpoint attributed to deployment B resumed through a binding for deployment A → **fail closed**. + The identity is compared in full (run id, workload, configuration id and version, environment, + deployment); comparing the run id alone is explicitly insufficient, because a run id can be + replayed against another workload or deployment. +- Exactly one of {persisted, requested} attribution present at a reconstruction boundary → **fail + closed**: a governed run cannot become un-attributed, and an ungoverned run cannot resume as + governed. +- A governed run reaching an un-attributed approval creation path → **fail closed before the first + persistence operation** (`GovernedRunContinuityException`); legacy ungoverned execution is + unchanged. +- Legacy ungoverned workflows/checkpoints remain supported where intentionally un-attributed + (attribution `null`). + +## Surfaces + +| Surface | Change | +|---|---| +| `tramai-core` identity types | reuse `GovernedRunIdentity` / `RunId`; adds only `GovernedRunContinuityException` (additive, stable module) | +| `tramai-control-plane` | authoritative registration lookup at run creation; binding → scope → `RegisteredWorkload` snapshot; admission rejection | +| `tramai-orchestration` | `GovernedRun` envelope; `WorkflowContext` **unchanged**; reserved checkpoint metadata encoding + decode; whole-identity continuity gate at every reconstruction boundary; supervisor recovers, never rebuilds | +| checkpoint stores + TCK | unchanged schemas; durable attribution round-trip through the reserved metadata keys in every implementation (InMemory/File/Markdown/JDBC) | +| `tramai-persistence-file`, `-jdbc` | governed suspended-invocation and outbox stores; V1 legacy vs V2 governed codecs with strict schema-version dispatch | +| sovereign ops | approval suspension V2 provenance; the approval mutation carries identity inside its native JDBC transaction; `ApprovalRunAttribution.Governed` | +| `tramai-scheduler` | durable deployment binding; governed schedule continuation | +| `tramai-server` | start path resolves + snapshots; idempotent replay returns the existing attribution; resume loads it, never rebuilds it | + +## Test matrix + +Mandatory in 0.7.1d (implemented and green on the reviewed head): + +1. Governed run start snapshots the exact authoritative `WorkloadDeploymentIdentity`. +2. `governedRunIdentity.runId.value == workflowId` (structurally enforced at envelope construction). +3. Missing authoritative registration prevents a governed run from starting; admission is rejected. +4. Two deployment scopes sharing one executable/workflow name produce distinct identities. +5. Initial execution carries the canonical identity in the typed envelope. +6. Checkpoint round-trip preserves attribution across **every** supported implementation. +7. Continuation receives exactly the same `GovernedRunIdentity` as initial execution. +8. Worker/recovery resume preserves it too. +9. Missing attribution on a checkpoint expected to be governed fails closed. +10. A partial reserved-key set is corruption, never a silent un-attributed resume. +11. A checkpoint attributed to deployment B cannot be resumed through deployment A. +12. Reusing an idempotency key returns the pre-existing run identity. +13. Changing mutable registration metadata after creation cannot change historical attribution. +14. Framework-generated scheduled/delayed continuation cannot drop attribution. +15. Legacy ungoverned workflow/checkpoint compatibility remains intact. +16. V2 payloads whose declared `schemaVersion` is mistyped (text, float, null, out-of-range) are + corruption — never a bogus V1/V2 decode and never an "unsupported version" report. +17. A governed run reaching an un-attributed approval creation path fails closed with 0 approval / + 0 suspension / 0 continuation rows and 0 mutation-store calls. + +Deferred to **0.7.1g** (evidence/compatibility/mutation phase), not claimed here: mutation kill +proof for removal of workload/configuration/run-identity checks, adversarial verification matrices, +and the compatibility/evidence proof set. + +## Compatibility + +Source compatibility and binary/API signature compatibility are different claims, and this task is +not "purely additive": + +- `tramai-core` is **stable**; its only change is additive (a new `GovernedRunContinuityException`), + which passes under the backward-compatibility rule settled in #415. +- `ApprovalRunAttribution` was promoted `internal` → public `@ExperimentalTramaiInternalApi`. +- `JdbcSovereignOpsApprovalMutationStore.` and its autoconfiguration bean method each gained a + parameter **with a default**. Source-compatible, but a default parameter does not remove the JVM + descriptor change: consumers must recompile. This is recorded as an explicit preview transition in + `config/quality/api-migrations.yml` rather than described as additive. +- Seven further preview/experimental modules change dump additively; every transition is authorized + by an exact hash-bound migration entry with `targetVersion: "0.7.0"` (the release this + `0.7.0-SNAPSHOT` development line targets). + +## Non-goals + +0.7.1d does NOT: expose the new attribution through REST/query surfaces (0.7.1e/0.7.1f); +add If-Match/ETag or consistency classes (0.7.1e); classify safe metadata categories (0.7.1f); +carry governed identity through `ApprovalGatewayPersistenceRequest` (the affected creation paths +fail closed instead); add IAM/RBAC; select providers or policy from workload identity; change +registration lifecycle/admission semantics; touch the dashboard; introduce a new run lifecycle +authority or a separate `RunAttributionStore`; pin cross-deployment idempotency scoping in +`WorkflowRunStore` (recorded as a follow-up before that store becomes durable/shared). + +## Verification + +- `./gradlew :tramai-orchestration:test` — checkpoint attribution round-trip + continuity gate. +- `./gradlew :tramai-server:test` — start/idempotency/resume attribution. +- `./gradlew :tramai-scheduler:test` — continuation attribution. +- `./gradlew :tramai-persistence-file:test`, `:tramai-spring-boot-starter-sovereign-persistence-file:test`, + `:tramai-spring-boot-starter-sovereign-persistence-jdbc:test` — V1/V2 codec fail-closed matrix + including strict schema-version dispatch. +- `./gradlew verify060Architecture -PchangePolicyBase=` — API architecture, + authorized by hash-bound migration entries. +- `./gradlew verifyPr` — primary local gate (requires JDK 21 on this machine). +- Exact-head CI + Sovereign Runtime RC — final certification. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1d1-GOVERNED-APPROVAL-IDENTITY-CARRIAGE.md b/docs/roadmap/0.7.0/TASK-0.7.1d1-GOVERNED-APPROVAL-IDENTITY-CARRIAGE.md new file mode 100644 index 000000000..c9780a3ed --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1d1-GOVERNED-APPROVAL-IDENTITY-CARRIAGE.md @@ -0,0 +1,304 @@ +# Task 0.7.1d1 — Governed Approval Identity Carriage + +**Parent:** [Epic 0.7.1 — Control-Plane Authority & Workload Identity](EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md) + +**Branch:** `task/418-governed-approval-identity-carriage` → `epic/0.7.1-control-plane-authority` + +**Baseline:** epic head `08a78745` (0.7.1e control-plane authority contract, #420; release-closure +example-guide guard repair, #422) + +**Issue:** #418 — carried forward from the 0.7.1d review (#407) + +**Change class:** `runtime-behaviour` (primary) + +**Status:** Frozen — ownership/trust model ruled; implementation in progress. + +## Decision + +0.7.1d established the canonical governed identity and its continuity rules, but deliberately left +the approval *creation* path un-attributed: `ApprovalGatewayPersistenceRequest` carried no +identity, so `DefaultApprovalGateway` and `SovereignOpsTransactionalApprovalGateway` had to reject +a governed run before the first persistence operation rather than durably record an un-attributed +suspension. + +The capability that is actually missing is **not** durable governed-suspension storage. That +already exists and is wired: + +- `GovernedSuspendedInvocationStore.createGoverned(GovernedSuspendedInvocation(metadata, identity), envelope)` + writes ONE durable record: metadata + replay envelope + canonical identity; +- `ApprovalSuspensionCoordinator.resolveGovernedSuspension()` already fails closed with + `ConfigurationException` when the configured store does not implement the governed interface; +- `SovereignJdbcPersistenceAutoConfiguration` and its file counterpart already wire the governed + stores. + +What is missing is (1) getting the two gateway creation paths onto that existing governed path, +(2) carrying attribution onto the approval lifecycle so an approval stays attributable while +pending and after it is decided, and (3) enforcing continuity when that lifecycle is +reconstructed. This slice does those three things and nothing else. + +```text +creation authority + GovernedRunScope + │ + ▼ +GovernedSuspendedInvocation ← canonical suspension identity + │ + ├──────── full equality ────────┐ + │ ▼ + └──────────────────── approval-row attribution snapshot + │ + ▼ + survives approval lifecycle/history +``` + +## Authority model (frozen) + +```text +CREATION + GovernedRunScope is authoritative. + Application/factory supplied identity is never trusted. + +DURABLE SUSPENSION + GovernedSuspendedInvocation is the canonical full governed identity. + +APPROVAL ROW + ApprovalBinding.workflowRunId stores the run id. + Reserved framework metadata stores the five remaining identity components. + Together they form an immutable attribution snapshot, not an independently + mutable identity authority. + +RECONSTRUCTION + Identity is decoded from durable state, never synthesized from correlation IDs, + workflow IDs alone, caller input, or ambient scope. + +CONTINUITY + Where canonical suspension identity and approval-row attribution both exist, + all components must match exactly. + +AMBIENT SCOPE AT RECONSTRUCTION + May corroborate persisted identity but may never override it. + If it disagrees, abort as an identity-substitution/continuity failure. + +LEGACY + A legacy approval has no reserved governed-attribution keys. + A partial governed-attribution key set is corruption, never legacy. +``` + +Terminology: the approval-row copy is **not** a second authority. It is a framework-owned immutable +attribution snapshot whose only job is to keep the approval attributable throughout and after its +lifecycle. Where both representations exist, exact equality is required. Persisted identity is +authoritative; disagreement **aborts** — it does not "win" and continue under the persisted value +while a conflicting scope is active. + +## Trust boundary (owner of the identity) + +```text +factory + └── produces persistence payload (identity-blind) + +gateway + ├── resolves canonical runtime identity (GovernedRunScope) + ├── validates the caller's workflowRunId + └── produces resolved payload + attribution + +stores / mutation boundary + └── accepts only resolved attribution for governed creation +``` + +`ApprovalGatewayRequestFactory` currently receives high-level request data plus `WorkflowRunId?`. +It has no canonical governed-identity input and **stays that way**. At creation the gateway: + +1. resolves `GovernedRunScope`; +2. if governed, the ambient `GovernedRunIdentity` is authoritative; +3. if the caller supplied `workflowRunId`, require it to equal `identity.runId`; +4. if absent, derive the effective `WorkflowRunId` from the canonical identity for the existing + factory call; +5. lets the factory construct the ordinary persistence payload; +6. adds trusted resolved attribution **after** the factory boundary; +7. only then allows persistence to begin. + +A caller or factory must never be able to nominate *run A + workload/configuration/deployment +identity B* and have the gateway accept it. + +### Structural distinction (not a nullable field) + +The factory-produced `ApprovalGatewayPersistenceRequest` MUST NOT gain a +`governedRunIdentity: GovernedRunIdentity?` field — with or without a default. That would force the +factory to manufacture an attribution state the factory is explicitly not trusted to supply. + +```kotlin +sealed interface ApprovalRunAttribution { + data object Ungoverned : ApprovalRunAttribution + + data class Governed( + val identity: GovernedRunIdentity, + ) : ApprovalRunAttribution +} + +interface GovernedSovereignOpsApprovalRequestMutationStore : SovereignOpsApprovalRequestMutationStore { + suspend fun createGovernedApprovalRequest( + request: ApprovalGatewayPersistenceRequest, + identity: GovernedRunIdentity, + auditIntent: SovereignOpsAuditOutboxRecord? = null, + inboxMetadata: ApprovalInboxMetadata? = null, + resumeCredential: ApprovalResumeCredentialRecord? = null, + ): SovereignOpsApprovalRequestMutationResult +} +``` + +As built, the outer wrapper above is a **governed capability on the store boundary**, not a DTO: the +illustrative `ResolvedApprovalPersistenceRequest` shape was deliberately not introduced, because the +attribution is derived from the canonical identity inside the store that already owns the single +transaction. The engine-side equivalent is `GovernedApprovalStore`, mirrored for suspensions by +`GovernedSuspendedInvocationStore`. Names/package follow the existing architecture; the invariant is +what matters: the factory produces a payload, the gateway resolves identity and hands it over as the +canonical value, and the store boundary accepts only a whole identity for governed creation. + +The invariant is **carriage through the gateway persistence boundary**, not allegiance to one DTO. +If introducing the wrapper at the lower mutation-store API changes a Preview public surface, that is +acceptable: let the architecture/API gate report the required migration. Do not contort the +ownership model to avoid an API hash, and do not manufacture a migration entry if the gate reports +no change. + +## Contract + +### Reserved attribution metadata + +- Reserved keys carry exactly the five remaining identity components: + `approval.identity.workload`, `approval.identity.configuration`, + `approval.identity.configuration_version`, `approval.identity.environment`, + `approval.identity.deployment`. +- Deliberately not prefixed `tramai.`: `RuntimeEventCatalogueArchitectureTest` reserves that + namespace for runtime identifiers and configuration properties, and a persisted metadata key is + neither. +- `ApprovalBinding.workflowRunId` remains the single stored run-id source. Decode reconstructs + `GovernedRunIdentity.runId` from it; another run-id copy is never encoded. +- The reserved values are framework-owned. Application metadata can never determine them, and an + application attempt to supply a reserved key is **rejected** as a collision rather than silently + overwritten, so an attribution-injection attempt is observable instead of hidden. +- The reserved values are immutable across `pending → approved/denied/expired` transitions and are + preserved verbatim by any decision/update path. + +### Fail-closed rules + +- No reserved keys → legacy approval, intentionally un-attributed; unchanged behavior. +- Partial reserved key set → **corruption**, never legacy, never a partial identity. +- Reserved values invalid (blank/unparseable) → corruption. +- When persisted approval attribution is checked against the canonical + `GovernedSuspendedInvocation` identity, the canonical suspension run id must equal + `ApprovalBinding.workflowRunId`; disagreement → **fail closed**. The run id decoded from the + approval row itself is derived from `ApprovalBinding.workflowRunId`, so it cannot be the + independent authority in this comparison. +- Persisted approval attribution and canonical `GovernedSuspendedInvocation` identity differ in any + component → **fail closed**: run-id-only comparison is explicitly insufficient. +- An active ambient scope that disagrees with persisted attribution at reconstruction → abort as an + identity-substitution/continuity failure. Ambient scope may corroborate, never override. +- A governed run whose configured suspension store does not implement + `GovernedSuspendedInvocationStore` → `ConfigurationException` before any write (unchanged, now a + configuration failure rather than an unconditional rejection). +- Nothing is written, and the mutation store is never called, before attribution is resolved and + validated. + +### Identity is never synthesized + +At reconstruction, identity comes from durable state only. It is never rebuilt from +`WorkflowRunId`, correlation IDs, caller input, or ambient `GovernedRunScope`. A non-authoritative +decoy run id supplied to a reconstruction path must not produce an identity. Where a reconstruction +path *does* use a run id as an authoritative lookup/correlation key, disagreement with +`ApprovalBinding.workflowRunId` fails closed rather than being ignored. + +## Surfaces + +| Surface | Change | +|---|---| +| `tramai-engine` — `ApprovalGatewayPersistenceRequest` | **unchanged**; factory payload stays identity-blind | +| `tramai-engine` — `ApprovalRunAttribution` (sealed) + `GovernedApprovalStore` / `GovernedSuspendedInvocationStore`; ops — `GovernedSovereignOpsApprovalRequestMutationStore` | NEW: governed capabilities carrying the canonical identity into the store that owns the transaction (no `ResolvedApprovalPersistenceRequest` DTO was introduced) + reserved-key codec (encode/decode/collision) | +| `tramai-engine` — `DefaultApprovalGateway` | resolve ambient identity, validate `workflowRunId`, route governed suspensions to `createGoverned`; reject → carry | +| `tramai-spring-boot-starter-sovereign-ops` — `SovereignOpsTransactionalApprovalGateway` | same resolution/validation/carriage; inbox/audit artifacts carry only continuity-required attribution | +| approval-row persistence (JDBC + file) | write/read the reserved keys in existing `sanitized_metadata`; immutability across lifecycle transitions | +| `tramai-persistence-jdbc`, `tramai-persistence-file` | governed suspension route already exists; no new durable authority | +| approval reconstruction boundaries | whole-identity continuity gate | + +## Test matrix + +**Carriage** + +1. Governed suspend through `DefaultApprovalGateway` → durable suspension is governed and carries + all five components; zero fail-closed rejection. +2. Governed suspend through `SovereignOpsTransactionalApprovalGateway` → same. +3. Caller `workflowRunId` equal to `identity.runId` → accepted; caller `workflowRunId` different → + fail closed, nothing written. +4. Caller `workflowRunId` absent while governed → effective run id derived from the identity; the + persisted binding's `workflowRunId` equals `identity.runId`. + +**Reconstruction without synthesis** + +5. Reconstruct in a fresh store with **no ambient scope and no correlation id**; a decoy run id is + supplied as a non-authoritative input → identity equals the original (proves it is read back, + not rebuilt). +6. Where a run id *is* authoritative at the reconstruction boundary: disagreement with + `ApprovalBinding.workflowRunId` → fail closed. +7. Ambient scope present at reconstruction pointing at a different identity → abort (persisted + identity is authoritative; the scope never overrides). + +**Corruption / substitution** + +8. Persisted identity vs canonical suspension identity differ in one component with an identical + run id → fail closed (run-id-only comparison proven insufficient). +9. Partial reserved key set → corruption exception; never a partial identity. +10. Reserved values present but run id disagrees with the binding → fail closed. + +**Injection / immutability** + +11. Application metadata supplying one or more reserved keys with a different but valid identity → + rejected as a collision before durable mutation; the application value never reaches + persistence. +12. Reserved-key mutation preservation: create a governed approval, transition it through at least + one decision/update path, then verify the five reserved values are byte/semantically identical + — D1 is not merely proven at creation. + +**Boundary / legacy** + +13. Governed run + non-governed suspension store → `ConfigurationException`, nothing written. +14. Legacy ungoverned approval → zero reserved keys, behavior unchanged. + +## Compatibility + +- Legacy un-attributed approvals remain fully supported and byte-compatible (no reserved keys). +- No schema migration: reserved keys ride the existing `sanitized_metadata` carrier. +- `ApprovalGatewayPersistenceRequest` is not widened; the factory contract does not change. +- API/migration bookkeeping is produced from the architecture gate's own diagnostics on the final + rebase — never speculatively, and not at all if the gate reports no ABI change. + +## Non-goals + +0.7.1d1 does NOT: add approval-table columns or a schema migration; introduce a second identity +type, a parallel attribution store, or a second run identifier; redesign the audit/inbox schema or +expose new audit/query surfaces; widen the application-facing factory contract; add IAM/RBAC or +policy selection from workload identity; perform 0.7.1f exposure classification or 0.7.1g evidence +work; touch the dashboard; change approval lifecycle semantics. + +## Verification + +- `./gradlew :tramai-engine:test` — codec round-trip, collision rejection, corruption matrix, + gateway carriage and fail-closed seams. +- `./gradlew :tramai-spring-boot-starter-sovereign-ops:test`, + `:tramai-spring-boot-starter-sovereign-persistence-jdbc:test`, + `:tramai-spring-boot-starter-sovereign-persistence-file:test` — governed approval row persistence, + immutability across decisions, restart/cross-instance reconstruction. +- `./gradlew verify060Architecture -PchangePolicyBase=08a78745…` — API architecture, authorized by + hash-bound migration entries taken from its own diagnostics. +- `./gradlew verifyStaticAnalysis` (0 new findings), `spotlessCheck`, `verifyChangePolicy`. +- Exact-head CI + Sovereign Runtime RC — final certification. + +## Implementation order + +```text +P0 freeze ownership + trust model (this spec) +P1 reserved attribution codec + corruption/collision discriminators +P2 approval-row immutable attribution persistence +P3 gateway-resolved attribution + governed store routing +P4 reconstruction continuity gates + inbox/audit continuity +P5 file/JDBC restart + adversarial proofs +P6 docs/API bookkeeping last +``` diff --git a/docs/roadmap/0.7.0/TASK-0.7.1e-CONTROL-PLANE-AUTHORITY-CONTRACT.md b/docs/roadmap/0.7.0/TASK-0.7.1e-CONTROL-PLANE-AUTHORITY-CONTRACT.md new file mode 100644 index 000000000..ed92aab01 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1e-CONTROL-PLANE-AUTHORITY-CONTRACT.md @@ -0,0 +1,304 @@ +# Task 0.7.1e — Control-Plane Authority Contract + +**Parent:** [Epic 0.7.1 — Control-Plane Authority & Workload Identity](EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md) + +**Branch:** `task/0.7.1e-control-plane-authority-contract` → `epic/0.7.1-control-plane-authority` + +**Baseline:** epic head `ecadc4b5` (0.7.1d merged, #407) + +**Change class:** `runtime-behaviour` (primary) + `public-api` (new public control-plane surface) + +**Status:** Specified; implementation not started. + +## Objective + +Give an external control-plane client a safe way to read and mutate authoritative workload state: +one mutable authority, explicit read semantics, version-guarded commands, and no path by which a +projection or query can become a second source of truth. + +Central invariant: + +```text +commands -> authoritative state +queries -> authoritative state, or an explicitly classified read projection +query/projection path MUST NOT mutate runtime authority +mutation MUST carry the expected authoritative state version +STALE expected version -> reject, never overwrite +``` + +Derivation: the 0.7.1a authority audit recorded two remaining gaps — no general command precondition +across control-plane mutations, and no defined projection boundary (a controller can currently expose +a store view with no contract stating that it is read-only or how stale it may be). + +## What already exists (audit before invention) + +Most of the concurrency machinery landed in 0.7.1c and is **reused, not rebuilt**: + +- `WorkloadStateVersion` — monotonic value type with `next()`, `INITIAL = 1`, overflow guard. +- `WorkloadRegistrationStore.compareAndSet(expected, updated): Boolean` — atomic, deployment scope + + state version is the concurrency token, and the immutable witness (identity + configuration + fingerprint) must also match, so a fabricated record cannot gain mutation authority by guessing a + version. Returns false on absent scope, moved version, or mismatched witness. +- `WorkloadRegistrationAuthority` — already returns **typed** outcomes and already reports the + *current* authoritative version on a lost race: + `MetadataUpdateOutcome.{Applied, Stale(currentVersion, expectedVersion), Unchanged, NotFound}`, + `LifecycleTransitionOutcome.{Applied, Stale, Unchanged, NotFound, InvalidTransition}`, + `RegisterOutcome.{Created, AlreadyRegistered, Rejected}`. +- `tramai-server` — already constructs the store/authority (`ServerGovernance`, + `ServerGovernanceConfiguration`) and already answers with `ProblemDetail` (RFC 7807) through + `@ExceptionHandler` methods. +- `tramai-persistence-jdbc` — `JdbcWorkloadRegistrationStore` implements the atomic store contract. + +Consequence for scope: 0.7.1e does **not** build concurrency control, a CQRS framework, or an event +projection engine. It builds the *external contract* around machinery that exists, and proves the +separation holds. + +## Contract + +### A. Command authority + +A public, framework-agnostic command port over the existing authority. It exposes the authority's own +semantic shape — it does **not** invent convenience operations, and it never exposes the store: + +```kotlin +interface WorkloadControlPlaneCommands { + suspend fun register(...): RegisterOutcome + + suspend fun updateMetadata( + ..., + expectedVersion: WorkloadStateVersion, + ... + ): MetadataUpdateOutcome + + suspend fun transitionLifecycle( + ..., + expectedVersion: WorkloadStateVersion, + target: WorkloadLifecycleState, + ): LifecycleTransitionOutcome +} +``` + +The authority **implements the port** — there is no forwarding wrapper: + +```kotlin +class WorkloadRegistrationAuthority( + private val store: WorkloadRegistrationStore, +) : WorkloadControlPlaneCommands +``` + +A `suspend`/`activate`/`retire` convenience layer would only forward to `transitionLifecycle` and +would duplicate the authority; lifecycle targets are expressed as +`transitionLifecycle(target = SUSPENDED | ACTIVE | RETIRED)`, which keeps one place for lifecycle +rules and one place for the port. + +```text +Command -> WorkloadControlPlaneCommands (public preview contract) + -> WorkloadRegistrationAuthority + -> WorkloadRegistrationStore.compareAndSet +``` + +- The command layer delegates lifecycle rules to `WorkloadRegistrationAuthority`; it re-implements + no lifecycle rule, no fingerprint check and no CAS. +- Every state-dependent mutation carries `expectedVersion`. No unconditional save exists anywhere on + this path — "read, modify, unconditional write" is structurally unavailable, not merely discouraged. +- The outcome types stay exactly as the authority defines them. 0.7.1e adds no parallel result type + and no generic persistence exception. + +### B. Stale-precondition semantics + +Already typed at the authority; 0.7.1e makes it externally consumable and guarantees: + +- `Stale(currentVersion, expectedVersion)` reports the **current** authoritative version (the value a + retrying client needs), never the version the caller guessed. On a lost race the authority + re-reads authoritative state before reporting, so this holds under concurrency, not just in the + sequential case. +- `NotFound` is distinct from `Stale`; `Unchanged` is distinct from `Applied`; `InvalidTransition` is + distinct from both. None of these may be collapsed into `false`, `null` or a 500. +- `RETIRED` stays terminal through the command surface: the authority's transition rules are the only + authority for that, and the command layer must not add a bypass. +- Concurrency is resolved by the store, never by the command layer: two commands carrying the same + `expectedVersion` can both reach `compareAndSet`, and exactly one can win. + +### C. Query / projection contracts + +Reads are split structurally from commands, as a separate public port: + +```kotlin +interface WorkloadControlPlaneQueries { + suspend fun authoritative(...): RegisteredWorkload? // QueryConsistency.AUTHORITATIVE + suspend fun projection(...): ClassifiedReadProjection<...> // QueryConsistency.PROJECTION +} + +enum class QueryConsistency { AUTHORITATIVE, PROJECTION } +``` + +- **Authoritative query** — reads the authority/store; returns the current authoritative record, + including its `stateVersion`. Consistency class `AUTHORITATIVE`. +- **Projection query** — read-only derived state; may lag; **must** report the authoritative version + it observed so a client can compare (e.g. authoritative `stateVersion = 18`, projection + `observedVersion = 16`), rather than asserting a vague "eventually consistent: true". Consistency + class `PROJECTION`. +- Projection results are immutable and cannot be fed back as a mutation input: a projection value is + not a valid `expected` witness for a command. + +No generic CQRS framework, no event bus, no arbitrary metadata query language. + +### C1. What is public contract vs what stays server-only + +Public in `:tramai-control-plane` (the deliverable, and the reason the API migration entries are +legitimate rather than bookkeeping): + +`expectedVersion`, `WorkloadStateVersion`, `Stale(currentVersion, expectedVersion)`, the outcome +types, `QueryConsistency`, `observedVersion`, authoritative-vs-projection read semantics, +`WorkloadControlPlaneCommands`, `WorkloadControlPlaneQueries`. + +Server-only in `:tramai-server` (never in the public control-plane ABI): + +`If-Match`/`ETag` parsing, HTTP status codes, `ProblemDetail`, Spring annotations, `ResponseEntity`, +HTTP request/response DTO mechanics. + +### D. HTTP concurrency contract + +0.7.1c deferred REST query/mutation APIs and `If-Match`/ETag to this slice; nothing in the repository +implements `If-Match` today, so this defines the contract rather than extending one. + +- `GET workload` → `RegisteredWorkload` with `ETag` derived from `WorkloadStateVersion`. The ETag is a + version token: `""` (strong, since it changes on every authoritative mutation). +- **Frozen `If-Match` form: exactly one strong numeric ETag** — `If-Match: "17"`. The header can + syntactically express more than our contract allows, so the unsupported forms are rejected rather + than interpreted: + + | `If-Match` value | Result | Why | + |---|---|---| + | `"17"` | evaluated | exactly one strong version token | + | `W/"17"` | 400 | weak tag — not a version-specific precondition | + | `*` | 400 | satisfies resource-existence semantics without naming an expected version, which would undermine the invariant that every mutation is conditioned on a specific observed version | + | `"16", "17"` | 400 | multiple alternatives — the authority has exactly one version | + | `abc`, empty | 400 | malformed | + +- mutation → requires `If-Match`; matching version executes the command, a stale version is a + precondition failure, an absent precondition is a precondition-required response. +- Failure mapping (extends the existing `ProblemDetail` vocabulary; the server's current 409 handler + proves the convention, it does not mean every new semantic conflict must become 409): + + | Condition | Status | Notes | + |---|---|---| + | `If-Match` present but malformed/unsupported form | 400 | never interpreted as "no precondition" | + | mandatory `If-Match` absent | 428 | never silently last-write-wins | + | `If-Match` valid but no longer the current version | 412 | precondition failed — the command may be well-formed, its basis is stale | + | deployment scope absent | 404 | | + | version current, but the requested lifecycle transition is illegal | 409 | domain conflict, distinct from a stale precondition | + + 412 and 409 stay separate on purpose: 412 means *the version you conditioned on is no longer + current*; 409 means *your precondition is current but the transition itself conflicts with domain + state*. Collapsing them would discard information the authority layer already preserves. + +- The **412 body carries `expectedVersion` and `currentVersion`**, and the response returns the + current `ETag`, so a client can re-read and reconcile without parsing human-readable text. +- Placement: the HTTP surface lives in **`tramai-server`**, which already owns governance wiring and + the `ProblemDetail` error convention; `ETag`/`If-Match` parsing, status codes and DTO mechanics stay + there. `tramai-control-plane` stays framework-agnostic (its only `api` dependency is `tramai-core`) + and must not gain a web dependency; its module doc currently states "no REST/query surfaces … here + (later candidates)" and is reconciled in this slice. + +- Critically: `Controller -> command/query contract -> authority`. Never + `Controller -> JdbcWorkloadRegistrationStore`. + +## Invariants + +1. Exactly one mutable authority for workload registration state. +2. Every state-dependent mutation carries an expected version; no unconditional write path exists. +3. A stale expected version is rejected and never overwrites newer state, including across restart and + across instances. +4. A query or projection value is never accepted as mutation authority. +5. Projection values are immutable and self-describe their consistency class and observed version. +6. Authoritative reads return the authority's current version; projections cannot claim authority. +7. Lifecycle rules (including RETIRED terminality) live only in `WorkloadRegistrationAuthority`. +8. No persistence implementation type is reachable through the public query/command contract. + +## Test matrix + +Focused verification (async/TCK style matching the existing control-plane and JDBC suites): + +1. Matching `expectedVersion` → mutation applied, version advances exactly once. +2. Stale `expectedVersion` → rejected with `Stale(current, expected)`, state unchanged. +3. Two concurrent commands with the same `expectedVersion` → exactly one succeeds, one reports `Stale` + with the winner's version. +4. `RETIRED` remains terminal through the command surface. +5. A query path cannot invoke authoritative mutation (compile-time separation, not a runtime check). +6. A projection result exposes its `QueryConsistency` and observed version. +7. Projection lag does not authorize a stale command (a lagging projection version is rejected as + stale). +8. No store implementation type is reachable through the public command/query contract. +9. Metadata/lifecycle mutation cannot replace immutable identity or configuration fingerprint. +10. Stale rejection survives JDBC persistence, restart and cross-instance execution. +11. `If-Match` form is enforced: `"17"` accepted; `W/"17"`, `*`, `"16", "17"` and `abc` rejected as + unsupported precondition forms (not silently coerced, not treated as absent). + +### Adversarial discriminator + +The strongest tests are the ones the Epic names: **mutation through a projection/read-model path must +be rejected**, and a **stale precondition against newer state must be rejected**. Both are proven by +attempting them through the public surface, not by asserting a type is absent. Additional +discriminators: `If-Match` present but stale (412); `If-Match` absent on a required mutation (428); +`If-Match` malformed (400, never last-write-wins); `If-Match: *` (must not authorize a mutation +without an expected version, even though the resource exists); two same-version commands racing +(exactly one wins); a projection value used as a mutation witness (must be unusable). + +## Failure semantics + +Unknown scope → `NotFound`. Stale version → rejected with the current authoritative version. +Same-state command → `Unchanged`, no version advance, no `next()` (at `Long.MAX_VALUE` this must not +overflow). Invalid transition → `InvalidTransition`, distinct from stale. Lost CAS race → re-read and +report the authoritative version. Missing precondition → rejected, never implicit. + +## Compatibility + +- New public surface in `:tramai-control-plane` (preview) changes its API dump → exact hash-bound + entries in `config/quality/api-migrations.yml` with `targetVersion: "0.7.0"` (the release the + `0.7.0-SNAPSHOT` line targets), derived from the gate's own diagnostics on the exact base. +- `tramai-server` gains endpoints → additive; no existing endpoint's contract changes. +- Existing store/authority signatures must remain compatible: 0.7.1e consumes `compareAndSet` and the + outcome types as they are. A signature change there would be a breaking change to a preview module + and requires an explicit migration entry and justification. + +## Non-goals + +Not in 0.7.1e: policy/trust zones (0.7.2); provider-selection projections (0.7.3); evidence query API +(0.7.4); timeline reconstruction (0.7.5); IAM/OIDC authorization (0.7.6); persisted run cancellation +(0.7.7); Dashboard work (0.7.8); a generic projection framework; an arbitrary metadata query language; +a CQRS/event-sourcing framework; classifying safe metadata categories or restricting protected +payload exposure (0.7.1f); carrying `GovernedRunIdentity` through `ApprovalGatewayPersistenceRequest` +(tracked separately — see below). + +## Socratic clause — must not disappear + +`ApprovalGatewayPersistenceRequest` still does not carry `GovernedRunIdentity`. 0.7.1d deliberately +kept the affected governed approval creation paths **fail-closed** rather than silently dropping +attribution. This is not a query/command-authority concern, so it does not derail 0.7.1e — but it must +be resolved before 0.7.1 closes, either as a small 0.7.1d follow-up slice or inside 0.7.1g once the +carriage contract itself is implemented. Tracked as its own issue. + +## Implementation boundary + +- Touches: `tramai-control-plane` (command/query contract types), `tramai-server` (HTTP surface + + handlers), `docs/modules/tramai-control-plane.md`, this spec. +- Does not touch: `tramai-core` identity types, persistence implementations, scheduler, engine, + dashboard module code. + +## Verification + +- `./gradlew :tramai-control-plane:test` — command outcomes, projection contract, invariants. +- `./gradlew :tramai-server:test` — HTTP `ETag`/`If-Match` mapping, error DTO shape, no-store-leak. +- `./gradlew :tramai-persistence-jdbc:test` — stale rejection across restart/cross-instance. +- `./gradlew verify060Architecture -PchangePolicyBase=` — API transitions authorized + by migration entries. +- `./gradlew verifyPr` — primary local gate (JDK 21 locally). +- Exact-head CI + Sovereign Runtime RC — final certification. + +## Definition of done + +All ten test-matrix items implemented and green; the two Epic-named adversarial cases proven through +the public surface; no store type reachable from the public contract; API transitions authorized with +exact entries; spec, module doc and PR body consistent with the implementation; exact-head CI + RC +green on a single SHA. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1f-SAFE-EXPOSURE-MODEL.md b/docs/roadmap/0.7.0/TASK-0.7.1f-SAFE-EXPOSURE-MODEL.md new file mode 100644 index 000000000..0fecf6779 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1f-SAFE-EXPOSURE-MODEL.md @@ -0,0 +1,404 @@ +# Task 0.7.1f — Safe Exposure Model + +**Parent:** [Epic 0.7.1 — Control-Plane Authority & Workload Identity](EPIC-0.7.1-CONTROL-PLANE-AUTHORITY.md) + +**Branch:** `task/0.7.1f-safe-exposure-model` → `epic/0.7.1-control-plane-authority` + +**Baseline:** epic head `f2650e067320ef12f9503ce3b68c09259522f80e` (0.7.1d1 merged, #423) + +**Change class:** `runtime-behaviour` (primary) + `public-api` (control-plane port payloads) + +**Status:** Implemented. + +## Objective + +Epic requirement: *"Define default metadata categories; protected payloads have no generic query +surface."* + +Make that requirement true **structurally** on the Kotlin control-plane contract, not by convention: + +```text +generic control-plane read + │ + ▼ +explicitly safe operational model + +protected workload/runtime payload + X + └── NOT reachable through any generic control-plane API +``` + +The deliverable is one explicit safe exposure type that both the framework-neutral ports and the +HTTP adapter can expose, so a later Dashboard, evidence, policy or administration layer consumes +*one* definition of safe metadata instead of inventing its own. + +## Baseline — what the audit found (this is the real work of the slice) + +Audited at `f2650e06`: `WorkloadControlPlaneQueries`, `ClassifiedRead`, `RegisteredWorkload`, +`WorkloadMetadata`, `WorkloadRegistrationResponse`, `WorkloadControlPlaneController`, +`WorkloadControlPlaneCommands` + the three outcome hierarchies, `WorkloadRegistrationStore` / +`CreateResult`, `InMemoryWorkloadRegistrationStore`, `JdbcWorkloadRegistrationStore` (+ migration +`V8__control_plane_workload_registration.sql`), and the server surfaces that construct them. + +Two findings decide the implementation: + +**Finding 1 — the HTTP adapter is already safer than the Kotlin contract.** 0.7.1e built +`WorkloadRegistrationResponse` as a deliberate allowlist that omits `configurationFingerprint`, and +documented why ("a witness has no business being handed back to a client"). But +`WorkloadControlPlaneQueries.authoritative()`/`projection()` return `ClassifiedRead`, which carries +the whole internal `RegisteredWorkload` **including the fingerprint**, and every command outcome +(`RegisterOutcome.*`, `MetadataUpdateOutcome.Applied/Unchanged`, `LifecycleTransitionOutcome.Applied/Unchanged`) +handles a `RegisteredWorkload` too. The generic *programmatic* control-plane boundary therefore +exposes an authority witness that the HTTP boundary deliberately withholds. The epic's own Socratic +clause predicted exactly this asymmetry. + +**Finding 2 — nothing protected is reachable today, and that is currently an accident of the type +graph rather than a frozen contract.** No control-plane type has a field for prompts, model +input/output, user/conversation content, tool arguments/results, credentials, approval/suspension/ +replay payloads, evidence or storage rows. The safety comes from the fact that `RegisteredWorkload` +happens to be narrow — which means a future slice can widen the generic surface by adding one field +to an internal record and changing nothing else (E9). This slice converts that accident into an +exact, test-enforced contract. + +## Current exposure inventory + +Classification legend: **SAFE** (approved for generic *read* surfaces) · **DECLARATION INPUT, NOT +READABLE** (a client legitimately originates it on the write path; it must never be reflected back) · +**INTERNAL AUTHORITY / STORAGE** (needed to establish/validate authority or to persist; must not cross +a generic surface) · **PROTECTED** (payload content; must never cross) · **OUT OF SCOPE HERE** +(different surface/epic). + +### Generic control-plane surfaces (in scope) + +| # | field / value | source authority | stored where | currently readable through | class | reason | +|---|---|---|---|---|---|---| +| 1 | `workloadId` | `WorkloadDeploymentIdentity` (`tramai-core`) | `tramai_workload_registration.workload_id` | Kotlin read port + every outcome + HTTP GET/POST/PUT | SAFE | identifies the workload; validated bounded id | +| 2 | `configurationId`, `configurationVersion` | `WorkloadConfigurationIdentity` | `tramai_workload_registration.configuration_id/_version` | same | SAFE | configuration identity/version — what a client must name to operate the workload | +| 3 | `environmentId`, `deploymentId` | `WorkloadDeploymentIdentity` | `tramai_workload_registration.environment_id/deployment_id` | same | SAFE | deployment scope | +| 4 | `owner`, `purpose` | `WorkloadMetadata` | `tramai_workload_registration.owner/purpose` | same | SAFE | bounded operational metadata; validated in `tramai-core` (non-blank, trimmed, ≤256/≤512, no ISO control chars) and bounded again by DB CHECK constraints | +| 5 | `lifecycle` | `WorkloadLifecycleState` | `tramai_workload_registration.lifecycle_state` (CHECK ACTIVE/SUSPENDED/RETIRED) | same | SAFE | registration lifecycle state | +| 6 | `stateVersion` | `WorkloadStateVersion` | `tramai_workload_registration.state_version` (CHECK ≥ 1) | same | SAFE | authoritative state version | +| 7 | `consistency` | `QueryConsistency` (derived) | not stored | HTTP only | SAFE | read classification, not workload content | +| 8 | `observedVersion` | `ClassifiedRead` | not stored | Kotlin read port + HTTP `ETag` | SAFE | the version the read testifies to | +| 9 | **`configurationFingerprint`** | `ConfigurationFingerprint` | `tramai_configuration_revision.fingerprint` | **Kotlin read port (`ClassifiedRead.registration`) and every non-stale command outcome; deliberately NOT on HTTP** — and legitimately an *input* of `register(...)` / the HTTP registration body | **DECLARATION INPUT, NOT READABLE — see ruling** | the witness the store compares to decide mutation authority (`compareAndSet` rejects a caller whose immutable witness differs) and the value that makes `(configurationId, version)` unrebindable. Clients originate it on the write path, so it is not secret material; it must never be reflected back, and must not be mistaken for a checkable credential | +| 10 | `RegisteredWorkload` as a *type* | internal authority record | — | return/parameter-reachable from both ports | INTERNAL AUTHORITY / STORAGE | carrying the witness is its job **as a store record**; as a generic contract type it is the leak vector | +| 11 | persisting/runtime records (`JdbcWorkloadRegistrationStore`, `InMemoryWorkloadRegistrationStore`, JDBC `ResultSet` rows) | storage | DB / maps | not reachable through either port | not exposed | E4 holds today; pinned by test | +| 12 | any `Map`, `Map`, `JsonNode`, `Any`, `Object` metadata bag | — | — | does not exist on any control-plane port or type | does not exist | E2 holds today; pinned by test | + +### Adjacent generic surfaces (audited, deliberately NOT changed here) + +| surface | observation | disposition | +|---|---|---| +| `GET /workflows/{name}/runs/{id}` → `WorkflowRunDetail.history/result/error`, `WorkflowRunResponse.result: Any?` (`tramai-server`) | a generic workflow-run surface that *does* carry unchecked application payload (`Any?`), and SSE/`result` echoes of run output | OUT OF SCOPE HERE: it is the workflow-execution API, not the control-plane workload surface; it does not cross the control-plane boundary and is not reachable from `WorkloadControlPlaneQueries`/`Commands`. Recorded as an adjacent finding for its own slice — 0.7.1f may not silently widen into it. | +| `AuditPage`/`AuditRecord`, `WorkerInfo`, `ScheduleSummary` | operational/audit read surfaces | OUT OF SCOPE HERE; audited, not reachable from the control-plane ports | + +### Fields deliberately judged *not* in scope + +Narrowly-scoped maps elsewhere in the repository were judged individually against the question +*"does this cross the generic control-plane boundary?"* — none of them does. 0.7.1f therefore adds +no repository-wide classification system and no redaction framework. + +## Authority / exposure boundary + +```text + INTERNAL AUTHORITY + RegisteredWorkload + WorkloadRegistrationStore (CAS witness) + │ + │ WorkloadExposure.from(record) ← the explicit allowlist mapper + ▼ + WorkloadExposure (safe control-plane model) + │ + ┌───────────┴───────────┐ + ▼ ▼ + WorkloadControlPlaneQueries command outcome payloads + (authoritative | projection) (register | metadata | lifecycle) + │ │ + └───────────┬───────────┘ + ▼ + WorkloadRegistrationResponse (HTTP, unchanged shape) + +PROTECTED PAYLOAD STORES / persistence records +prompts · tool data · approvals · evidence · replay · secrets · storage rows + │ + X not reachable — no control-plane type can hold them + │ + generic control-plane + +WorkloadRegistrationStore / CreateResult — the persistence SPI — keeps carrying +RegisteredWorkload: the CAS witness is *required* there. The witness stops at the +storage boundary; it does not continue into the generic ports. +``` + +## Safe-by-default categories (frozen allowlist) + +The complete set of facts a generic control-plane surface may expose is exactly: + +```kotlin +WorkloadExposure( + identity: WorkloadDeploymentIdentity, // workloadId, configuration (id, version), environmentId, deploymentId + metadata: WorkloadMetadata, // owner, purpose — bounded, unchanged, not extensible + lifecycle: WorkloadLifecycleState, // ACTIVE | SUSPENDED | RETIRED + stateVersion: WorkloadStateVersion, // authoritative state version +) +``` + +plus the read classification (`QueryConsistency`, `observedVersion`) which travels beside it in +`ClassifiedRead`, never inside it. This is an allowlist of named, typed fields — not permission to +expose "nearby" fields. + +## Classes 2-4 — declaration input, internal authority/storage, protected payload (frozen) + +The boundary has four classes, not three. The distinction between class 1 and class 2 is *direction*, +not secrecy: a client legitimately originates the fingerprint on the write path, and must never read +it back. + +| class | value | ruling | +|---|---|---| +| 1 | `WorkloadExposure` payload (see above) | **SAFE TO READ.** The complete allowlist. | +| 2 | `configurationFingerprint` | **DECLARATION INPUT, NOT READABLE.** A client supplies it when it declares a registration — `WorkloadControlPlaneCommands.register(...)`, and the HTTP registration body — so it is neither secret material nor a mutation credential, and it is legitimately part of the approved *input* type set. It must never be **reflected back**: it is the witness `WorkloadRegistrationStore.compareAndSet` compares to decide mutation authority and the value that makes a `(configurationId, version)` binding unrebindable, so it is absent from every read model and from every command *outcome*. The HTTP adapter already ruled this way in 0.7.1e; 0.7.1f makes the Kotlin contract agree instead of leaving a documented-but-nowhere-enforced exception. | +| 3 | `RegisteredWorkload` as a port payload type | **INTERNAL AUTHORITY / STORAGE.** It remains public because `WorkloadRegistrationStore` (the persistence SPI) requires the witness; it ceases to be reachable from `WorkloadControlPlaneQueries` / `WorkloadControlPlaneCommands`, including through their outcome hierarchies. | +| 3 | `CreateResult`, store records, JDBC `ResultSet` rows | storage-only; reachable through the store SPI, never through a generic surface (E4). | +| 4 | prompts, model I/O, user content, tool arguments/results, credentials, tokens/secrets, PII, approval/suspension/replay payloads, evidence bodies, provider requests/responses, arbitrary metadata maps | **PROTECTED.** Not representable in any control-plane contract type. | + +**Post-fix, the fingerprint is still visible in one legitimate place**: a caller of +`WorkloadRegistrationStore` (a persistence adapter, or an application that installs its own store) +sees it, and a registrant supplies it. Those are the store SPI and the declaration contract; neither +is a generic *read* surface, and 0.7.1f does not change them — the CAS witness must be comparable and +the registration witness must be declarable. + +## Protected categories (frozen — must remain unreachable) + +`prompts` · `model input` · `model output` · `conversation/user content` · `tool arguments` · +`tool results` · `credentials` · `tokens/secrets` · `PII-bearing payloads` · `approval payload +content` · `suspension/replay payloads` · `raw evidence payload bodies` · `raw provider +requests/responses` · `runtime state containing application payload` · `arbitrary caller-controlled +metadata maps`. + +This is a category list, not a request to build an enum. The structural guarantee is that the +control-plane type graph cannot carry any of them: the exposure type declares four properties, all +of whose types are bounded identity/state/metadata values, and the reachable-type test fails closed +the moment anything else becomes reachable. + +## Generic query contract (frozen) + +```kotlin +data class ClassifiedRead( + val exposure: WorkloadExposure, // was: registration: RegisteredWorkload + val consistency: QueryConsistency, + val observedVersion: WorkloadStateVersion, +) { + init { require(exposure.stateVersion == observedVersion) { ... } } // unchanged invariant +} + +interface WorkloadControlPlaneQueries { + suspend fun authoritative(...): ClassifiedRead? // unchanged signature + suspend fun projection(...): ClassifiedRead? // unchanged signature +} +``` + +The 0.7.1e guarantees are preserved verbatim: `AUTHORITATIVE` vs `PROJECTION` stay distinct, +`observedVersion` stays the version the read testifies to, the state/version contradiction guard +stays, absence stays `null` on the read path, and no read path can mutate. + +## Command outcome contract (frozen) + +Every outcome payload that was a `RegisteredWorkload` becomes a `WorkloadExposure`: + +| type | before | after | +|---|---|---| +| `RegisterOutcome.Created` / `AlreadyRegistered` | `registration: RegisteredWorkload` | `exposure: WorkloadExposure` | +| `RegisterOutcome.Rejected` | `existing: RegisteredWorkload` + `reason` | `existing: WorkloadExposure` + `reason` (**unchanged** `RegistrationConflictReason`) | +| `MetadataUpdateOutcome.Applied` / `Unchanged` | `registration: RegisteredWorkload` | `exposure: WorkloadExposure` | +| `LifecycleTransitionOutcome.Applied` / `Unchanged` | `registration: RegisteredWorkload` | `exposure: WorkloadExposure` | + +`Stale`, `NotFound`, `InvalidTransition` carry no record and are unchanged, as are the typed +`currentVersion`/`expectedVersion` semantics and the command/query split (0.7.1e must not be +weakened). + +Why the command path is in scope even though the Epic says *query surface*: an outcome is a generic +control-plane response consumed directly by a replaceable transport adapter (that is literally how +`WorkloadControlPlaneController` uses it), E8 forbids a create/update becoming a backdoor for a +field excluded from GET, and `RegisterOutcome.Rejected(existing)` currently hands the authoritative +fingerprint to a caller whose declaration *did not match* — a genuine disclosure, not a false +positive. + +`WorkloadRegistrationStore.create/compareAndSet/find` and `CreateResult` are **unchanged**: the +store SPI is where the witness is required. + +## HTTP / serialization contract (frozen) + +`WorkloadRegistrationResponse` keeps its exact 10 properties and its omission of the fingerprint; +only its **input** changes: + +```kotlin +fun from(exposure: WorkloadExposure, consistency: String = "AUTHORITATIVE"): WorkloadRegistrationResponse +``` + +`okResponse(read: ClassifiedRead)`, `okResponse(exposure: WorkloadExposure)` and +`createdResponse(exposure: WorkloadExposure)` map the safe model. The controller no longer holds a +`RegisteredWorkload` in any branch, so the adapter *cannot* serialize an internal record even by +accident (E1) — the mapper call is the only construction path. + +Status/ETag/`ProblemDetail` semantics are untouched (400/404/409/412/428, `ETag: ""` from +`observedVersion` or the outcome's exposure). + +## Invariants + +| id | invariant | how it is enforced | +|---|---|---| +| E1 | a generic control-plane response is constructed from an explicit safe schema, never from the internal record | the only construction path is `WorkloadExposure.from(record)`; HTTP maps the exposure; `WorkloadRegistrationResponse.from` no longer accepts a `RegisteredWorkload` | +| E2 | no generic public API widens the surface with `Map<*,*>`, `JsonNode`, `Any`, `Object` | exact input/output type-set tests over both ports | +| E3 | no generic path can return prompts, model/user/tool/approval/evidence/secret payloads | reachable-type test (exact set) + exact exposure property set | +| E4 | no persistence record is returned by the public contract | reachable-type test walks transitively and **fails loudly** on any type outside the two approved packages — nested getter types included, so a `java.sql.ResultSet` field on an outcome payload cannot disappear from the walk | +| E5 | `WorkloadMetadata` stays bounded | exact property-set test on `WorkloadMetadata` (owner, purpose only); no bag can be added silently | +| E6 | query consistency/version semantics preserved | existing 0.7.1e contract tests, unchanged and green | +| E7 | authority witnesses get an explicit ruling | fingerprint ruled DECLARATION INPUT, NOT READABLE (never a generic output); exclusion asserted on the type graph *and* on HTTP JSON, while its input position stays approved | +| E8 | command responses cannot widen reads | outcome payload type-set test + HTTP exact-shape tests on POST and PUT | +| E9 | additions fail closed | every shape assertion is exact (`==` approved set), so a new field/type/JSON property reddens the build and forces an explicit decision | + +## Adversarial test matrix + +`tramai-control-plane` (new `WorkloadExposureModelTest`): + +1. `WorkloadExposure` declares exactly `{identity, metadata, lifecycle, stateVersion}` with exactly + the approved types. +2. The mapper is the allowlist: an exposure built from a record whose fingerprint carries a unique + sentinel contains no trace of it (`toString`, `equals`/`hashCode` inputs), and no exposure field + type can hold a `ConfigurationFingerprint`. +3. Port **output** type graph == approved set (exact) — `RegisteredWorkload` and + `ConfigurationFingerprint` are absent. The walk is fail-closed: any type outside the approved + packages — including a *nested* getter type such as a `java.sql.ResultSet` field on an outcome + payload — makes it fail loudly instead of vanishing from the set. +4. Port **input** type graph == approved set (exact) — the witness may be *submitted* (`register`) + but no input bag type exists; `RegisteredWorkload` is not an input. +5. `ClassifiedRead.exposure` is the safe type and the state/version contradiction guard still fires. +6. `WorkloadMetadata` declares exactly owner/purpose (no `Map`, no `Any`). +7. Read-path classifiers (`authoritative`, `projection`) yield the same exposure shape — a + projection cannot widen the surface relative to an authoritative read. +8. Query-consistency behaviour is unchanged (existing 0.7.1e assertions re-run against the new + payload type). + +`tramai-server` (extended `WorkloadControlPlaneControllerTest`): + +9. GET/POST/PUT JSON property set == the approved 10-property set **exactly** (`readTree().fieldNames()`), + for authoritative GET, 201 create, 200 duplicate-register, 200 metadata update, 200 lifecycle update. +10. A registration whose fingerprint is a unique sentinel produces no response body containing that + sentinel (GET and both mutations). +11. 412 body still carries `expectedVersion`/`currentVersion`, and 404/409/428 bodies are unchanged. + +Preferred discriminator: `assertThat(jsonProperties) == approvedSet` (not +`assertFalse(body.contains("password"))`, which proves nothing about the next field). + +## Compatibility / API impact + +- `:tramai-control-plane` is `maturity: preview`, `apiStability: preview`, published → the dump + changes and requires an **ACTIVE** entry in `config/quality/api-migrations.yml` with + `fromSha256 = 0c656c8654f45c0d8fc33b25c8e8935e3bcc6bf1d6ff6d1f395b4a19725db7e8` (the committed + dump at the epic baseline), the post-`apiDump` `toSha256`, `targetVersion: "0.7.0"`, a rationale + naming the 0.7.1f invariant, and the consumer-facing migration note. +- `:tramai-server` is `apiStability: internal` (`applicable()` is false in + `ApiCompatibilityVerifier`, so dumps of internal modules authorize nothing and need no entry — + 0.7.1e changed `tramai-server.api` with entries for `:tramai-control-plane` only). +- No consumer-visible behaviour change is required: this is a preview contract, additive in intent + (the safe model replaces a strictly wider payload), and no signature *name* other than the payload + property (`registration` → `exposure`) changes. +- No `tramai-core` identity type, no `WorkloadMetadata` bound, no store SPI and no persistence + implementation changes. + +## Non-goals + +Not in 0.7.1f: DLP/PII/secret scanning; field-level RBAC; OIDC/OAuth/SAML; an authorization policy +system; an encryption framework; a policy DSL; an evidence query API (0.7.4); an approval reviewer +UI; Dashboard work (0.7.8); a generic CQRS framework; a generic data taxonomy/ontology; arbitrary +metadata schemas; a redaction framework; data-residency work; and the adjacent workflow-run payload +surface recorded above. No speculative fields for clients that do not exist. + +## Mutation expectations + +`:tramai-control-plane` is **not** in the mutation population: `config/quality/test-quality.yml` +`mutation.targetFamilies.*.modules` covers `:tramai-core`, `:tramai-engine`, `:tramai-security`, +`:tramai-sovereign`, `:tramai-orchestration`, `:tramai-persistence-file`, +`:tramai-persistence-jdbc` — and `config/quality/mutation-baseline.json` contains no +`:tramai-control-plane` record. Onboarding the module is a quality-config + baseline change, which +`AGENTS.md` forbids riding inside a runtime-behaviour PR (and `mutation-baseline.json` is a +canonical-baseline artifact). 0.7.1f therefore proves the semantic mutations with the exact-shape +discriminators instead, and records the gap as a **deferred concern** rather than building a second +mutation mechanism: + +| mutation | killed by | +|---|---| +| safe response starts returning an internal field | test 1 / 9 (exact property sets) | +| a protected value becomes default-safe | test 3 (exact reachable-type set) | +| the explicit mapper is bypassed by the internal record | test 5 + `from(exposure)` only accepting the safe type (compile-time) | +| metadata bounds removed / bag introduced | test 6 (exact property set on `WorkloadMetadata`) | +| an authority witness becomes generically exposed | tests 2, 3, 9, 10 | + +## Implementation boundary + +- Touches: `tramai-control-plane` (`WorkloadExposure`, `ClassifiedRead`, the three outcome + hierarchies, `WorkloadRegistrationAuthority` mapping), `tramai-server` + (`WorkloadRegistrationResponse.from`, `WorkloadControlPlaneResponses`, controller branches), + their tests, `docs/modules/tramai-control-plane.md`, this spec, and the API migration entry. +- Does **not** touch: `tramai-core` identity/metadata types, `WorkloadRegistrationStore`/`CreateResult`, + persistence implementations, migration SQL, engine/orchestration/security/sovereign, workflows, + dashboard, or the adjacent workflow-run surface. + +## Verification + +- `./gradlew :tramai-control-plane:test` — exposure model, type graph, outcomes, 0.7.1e consistency re-run. +- `./gradlew :tramai-server:test` — HTTP exact-shape + sentinel + unchanged status/ETag semantics. +- `./gradlew :tramai-persistence-jdbc:test` — the store SPI still carries the witness (no regression). +- `./gradlew verify060Architecture -PchangePolicyBase=` — the API transition is authorized + by the ACTIVE migration entry. +- `./gradlew verifyPr` (JDK 21 locally), then `verifyStaticAnalysis`, `verifyCompilerWarnings`, + `verifyCancellationSafety -PtramaiCancellationBaseSha=`, `verifyChangePolicy + -PchangePolicyBase=`, `spotlessCheck`, plus exact-head CI as the certifying signal. +- A gate is green only when it was actually run on the reported head. + +## Definition of done + +- [x] exposure inventory recorded and frozen (this document) +- [x] safe / declaration-input / internal-authority-storage / protected categories frozen (this document) +- [x] `configurationFingerprint` ruled DECLARATION INPUT, NOT READABLE and structurally excluded from every generic read and command outcome +- [x] generic query port, command outcomes and HTTP all expose only the approved model +- [x] projection reads cannot widen the surface +- [x] no arbitrary metadata bag reachable; `WorkloadMetadata` stays bounded +- [x] no persistence record reachable from the public contract +- [x] exact-shape/type-graph discriminators implemented and proven to fail when the boundary moves +- [x] API transition authorized by an ACTIVE migration entry +- [x] focused module suites green; repository quality gates green on the reported head +- [x] task doc, module doc and PR body agree with the final implementation +- [x] working tree clean + +## Review round — 0.7.1f + +The first review found one blocker and two contract inconsistencies, all fixed on the same branch: + +1. **Nested out-of-package types could silently evade the type-graph walk** (the walk dropped any + type outside the two approved packages before asserting, so `val row: java.sql.ResultSet` on an + outcome payload left the exact set unchanged). The walk now skips only terminal scalars + (primitives, `String`) and `require`s the approved packages for everything else, so it fails + loudly. Proven by adding exactly that nested field and observing the discriminator red, then + restoring it. +2. **The fingerprint's classification contradicted the API** ("authority-only" while + `register(...)`/the HTTP body accept it). Now frozen as **declaration input, not readable**. +3. **The API migration text was wrong** (`Rejected.existing` keeps its name; the change is binary + incompatible via constructors/`copy`/`componentN`/getters) and now says so, including the + recompile requirement. + +## Socratic clause / unresolved risks + +1. **This slice does not prove absence of a leak into unrelated payload surfaces.** The adjacent + workflow-run surface (`WorkflowRunDetail.result: Any?`) is a real generic-payload exposure on the + *workflow execution* API. It is out of 0.7.1f's boundary, and this document records it rather than + claiming the repository has no such surface. +2. **Mutation coverage for the boundary is deferred, not delivered.** Onboarding + `:tramai-control-plane` into `test-quality.yml` requires a canonical mutation baseline; that must + be its own PR. Until then the boundary survives because the tests are exact, not because a + mutation engine proved them. +3. **A safe model is only as safe as its mapper.** `WorkloadExposure.from` is a hand-written + allowlist; nothing prevents a future slice from adding a *protected* property to the exposure if + the reviewer allows the corresponding test update. The protection is that the update is visible + and deliberate (E9), not that it is impossible. +4. **Endpoint gating is unchanged.** `tramai.control-plane.http.enabled` still defaults to off; this + slice hardens the contract behind it, and does not add authentication. 0.7.6 owns auth. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1A-MUTATION-POPULATION-DRIFT-AUDIT.md b/docs/roadmap/0.7.0/TASK-0.7.1g1A-MUTATION-POPULATION-DRIFT-AUDIT.md new file mode 100644 index 000000000..e07f40ded --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1A-MUTATION-POPULATION-DRIFT-AUDIT.md @@ -0,0 +1,1120 @@ +# Task 0.7.1g1A — Canonical Mutation Population Drift Audit + +**Status:** evidence record (measurement and diagnosis only; no repair) + +**Parent Epic:** 0.7.1 — Control-Plane Authority & Workload Identity + +**Epic branch:** `epic/0.7.1-control-plane-authority` + +**Task branch:** `task/0.7.1g1a-mutation-population-drift-audit` + +**Audit base (exact):** `0f36ab8744c14ebbdb3b10964311d0416e012ab2` + +**Authority semantics audited:** PR #429 — commit `0f36ab87` *fix(quality): fail closed on incomplete mutation measurement* + +## 1. Baseline + +| Item | Value | +|---|---| +| Epic head SHA audited | `0f36ab8744c14ebbdb3b10964311d0416e012ab2` | +| Epic head subject | `fix(quality): fail closed on incomplete mutation measurement (#429)` | +| Committed authority commit (last write of `mutation-baseline.json`) | `488f28e8fb066f477438625d1f2e030dc32f5309` | +| Committed authority `measuredCommit` | `5856530e0fa4a9538b0828b89d730812f4cc5cac` | +| Measurement worktree | `/tmp/tramai-071g1a-audit` (detached `git worktree` at the exact Epic head, tree clean at start of each campaign) | +| OS | Linux `7.0.0-31-generic` x86_64 (host `gionag-ThinkStation-P620`) | +| Measurement JVM | `openjdk version "21.0.7"` / Temurin-21.0.7+6-LTS (`/home/gionag/.sdkman/candidates/java/21.0.7-tem`, Gradle daemon JVM) | +| JVM on `PATH` | `openjdk 25.0.4.1` (`/usr/bin/java`) — **not** used by the measurement; the Gradle daemon ran on 21.0.7 | +| Gradle | 9.0.0 (wrapper `gradle-9.0.0-bin.zip`) | +| PIT plugin / engine | 1.19.0 / 1.22.1 (pinned in `MutationProbeInitScript`, re-validated by `MutationPopulationAggregator.validateSemantics`) | + +Configuration hashes at the audited SHA (sha256): + +| File | sha256 | +|---|---| +| `config/quality/test-quality.yml` | `dff61c7775472ca9ed6e460bce28d74072821cfcf14a95793d97e8c9ec659f14` | +| `config/quality/mutation-baseline.json` | `07f595225666e0b0cbd8914db9881d9be6d155baec7a802bcb1e06507da4af54` | +| `config/quality/mutation-classifications.yml` | `2440fd5be443b1073b98d9461b21a646fc513b997597cc6e59af7a9d31ab2be0` | +| `config/quality/mutation-evolution.yml` | `23808e191d627b13c27c4adc87cf3fd2368d20a2c0b534cf8e2dff8b6d3c7bec` | + +Fresh population hashes produced by this audit (sha256): + +| Artifact | sha256 | +|---|---| +| `/tmp/tramai-071g1a-population-A.json` | `06549e8a21e199b3f4c94fed359a3ae783e7796a29b27d6189c0dd261bc8a457` | +| `/tmp/tramai-071g1a-population-B.json` | `06549e8a21e199b3f4c94fed359a3ae783e7796a29b27d6189c0dd261bc8a457` | +| committed authority `config/quality/mutation-baseline.json` | `07f595225666e0b0cbd8914db9881d9be6d155baec7a802bcb1e06507da4af54` | + +Configured mutation target topology at the audited SHA (7 families / 14 family-module pairs, from `config/quality/test-quality.yml`): + +| Family | Modules | +|---|---| +| `approval` | `:tramai-engine`, `:tramai-security` | +| `evidence` | `:tramai-engine`, `:tramai-security`, `:tramai-sovereign` | +| `policy` | `:tramai-engine`, `:tramai-security`, `:tramai-sovereign` | +| `retry` | `:tramai-core`, `:tramai-engine` | +| `routing` | `:tramai-core`, `:tramai-engine` | +| `structuredOutput` | `:tramai-structured` | +| `tools` | `:tramai-engine` | + +Commands run for each campaign (identical for A and B, executed inside the detached worktree): + +```bash +BASE=0f36ab8744c14ebbdb3b10964311d0416e012ab2 +AUDIT_DIR=/tmp/tramai-071g1a-audit +git worktree add --detach "$AUDIT_DIR" "$BASE" +cd "$AUDIT_DIR" +./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks +``` + +## Campaign A + +Command: `./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks` + +Gradle result: **BUILD SUCCESSFUL in 48m 41s** (task `generateCriticalMutationBaseline` executed; log `/tmp/tramai-071g1a-run-A.log`) + +| Family | Mutants | Killed | Survived | NoCoverage | Timeout/Err | Time(s) | +|---|---|---|---|---|---|---| +| approval | 918 | 508 | 141 | 193 | 76 | 1103.0 | +| evidence | 808 | 539 | 194 | 75 | 0 | 244.6 | +| policy | 209 | 154 | 30 | 23 | 2 | 1243.2 | +| retry | 155 | 138 | 6 | 6 | 5 | 95.7 | +| routing | 15 | 12 | 2 | 0 | 1 | 65.6 | +| structuredOutput | 382 | 262 | 90 | 30 | 0 | 75.4 | +| tools | 57 | 54 | 0 | 3 | 0 | 60.0 | +| **TOTAL** | **2544** | **1667** | **463** | **330** | **84** | **2887.6** | + +Raw PIT status distribution (whole population): KILLED=1667, NO_COVERAGE=330, SURVIVED=463, TIMED_OUT=84 + +Canonical outcome distribution: KILLED=1667, NON_KILLED=877 + +| Family | Module | Total | Killed | Survived | NoCoverage | TimedOut | Errors | +|---|---|---|---|---|---|---|---| +| approval | `:tramai-engine` | 492 | 195 | 76 | 156 | 65 | 0 | +| approval | `:tramai-security` | 426 | 313 | 65 | 37 | 11 | 0 | +| evidence | `:tramai-engine` | 74 | 39 | 29 | 6 | 0 | 0 | +| evidence | `:tramai-security` | 286 | 207 | 60 | 19 | 0 | 0 | +| evidence | `:tramai-sovereign` | 448 | 293 | 105 | 50 | 0 | 0 | +| policy | `:tramai-engine` | 21 | 8 | 8 | 3 | 2 | 0 | +| policy | `:tramai-security` | 148 | 124 | 10 | 14 | 0 | 0 | +| policy | `:tramai-sovereign` | 40 | 22 | 12 | 6 | 0 | 0 | +| retry | `:tramai-core` | 70 | 55 | 6 | 4 | 5 | 0 | +| retry | `:tramai-engine` | 85 | 83 | 0 | 2 | 0 | 0 | +| routing | `:tramai-core` | 4 | 4 | 0 | 0 | 0 | 0 | +| routing | `:tramai-engine` | 11 | 8 | 2 | 0 | 1 | 0 | +| structuredOutput | `:tramai-structured` | 382 | 262 | 90 | 30 | 0 | 0 | +| tools | `:tramai-engine` | 57 | 54 | 0 | 3 | 0 | 0 | + +`measuredCommit` recorded in the generated population: `0f36ab8744c14ebbdb3b10964311d0416e012ab2`; `status=measured`; `byFamily` keys: approval, evidence, policy, retry, routing, structuredOutput, tools + + +## Campaign B + +Command: `./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks` + +Gradle result: **BUILD SUCCESSFUL in 47m 14s** (task `generateCriticalMutationBaseline` executed; log `/tmp/tramai-071g1a-run-B.log`) + +| Family | Mutants | Killed | Survived | NoCoverage | Timeout/Err | Time(s) | +|---|---|---|---|---|---|---| +| approval | 918 | 508 | 141 | 193 | 76 | 1063.9 | +| evidence | 808 | 539 | 194 | 75 | 0 | 223.8 | +| policy | 209 | 154 | 30 | 23 | 2 | 1253.9 | +| retry | 155 | 138 | 6 | 6 | 5 | 90.9 | +| routing | 15 | 12 | 2 | 0 | 1 | 63.4 | +| structuredOutput | 382 | 262 | 90 | 30 | 0 | 68.2 | +| tools | 57 | 54 | 0 | 3 | 0 | 59.4 | +| **TOTAL** | **2544** | **1667** | **463** | **330** | **84** | **2823.5** | + +Raw PIT status distribution (whole population): KILLED=1667, NO_COVERAGE=330, SURVIVED=463, TIMED_OUT=84 + +Canonical outcome distribution: KILLED=1667, NON_KILLED=877 + +| Family | Module | Total | Killed | Survived | NoCoverage | TimedOut | Errors | +|---|---|---|---|---|---|---|---| +| approval | `:tramai-engine` | 492 | 195 | 76 | 156 | 65 | 0 | +| approval | `:tramai-security` | 426 | 313 | 65 | 37 | 11 | 0 | +| evidence | `:tramai-engine` | 74 | 39 | 29 | 6 | 0 | 0 | +| evidence | `:tramai-security` | 286 | 207 | 60 | 19 | 0 | 0 | +| evidence | `:tramai-sovereign` | 448 | 293 | 105 | 50 | 0 | 0 | +| policy | `:tramai-engine` | 21 | 8 | 8 | 3 | 2 | 0 | +| policy | `:tramai-security` | 148 | 124 | 10 | 14 | 0 | 0 | +| policy | `:tramai-sovereign` | 40 | 22 | 12 | 6 | 0 | 0 | +| retry | `:tramai-core` | 70 | 55 | 6 | 4 | 5 | 0 | +| retry | `:tramai-engine` | 85 | 83 | 0 | 2 | 0 | 0 | +| routing | `:tramai-core` | 4 | 4 | 0 | 0 | 0 | 0 | +| routing | `:tramai-engine` | 11 | 8 | 2 | 0 | 1 | 0 | +| structuredOutput | `:tramai-structured` | 382 | 262 | 90 | 30 | 0 | 0 | +| tools | `:tramai-engine` | 57 | 54 | 0 | 3 | 0 | 0 | + +`measuredCommit` recorded in the generated population: `0f36ab8744c14ebbdb3b10964311d0416e012ab2`; `status=measured`; `byFamily` keys: approval, evidence, policy, retry, routing, structuredOutput, tools + + +## Determinism — Campaign A vs Campaign B + +Exact byte comparison: + +``` +$ cmp /tmp/tramai-071g1a-population-A.json /tmp/tramai-071g1a-population-B.json +(no output, exit code 0) +``` + +Both files are byte-identical: **True** + +Semantic comparison, independent of JSON serialization (canonical projection = sorted `identity|raw status|canonical outcome|family|module` + family/module topology + analyzer semantics + mutator set, mirroring `MutationPopulationEvolutionProof.canonicalProjection`): + +| Check | Campaign A | Campaign B | Equal | +|---|---|---|---| +| canonical projection sha256 | `d7b6af9ca4d18e65b19a537d89490226` | `d7b6af9ca4d18e65b19a537d89490226` | True | +| identity count | 2544 | 2544 | True | +| identity set | 2544 ids | 2544 ids | True | +| identities only in A | 0 | — | True | +| identities only in B | — | 0 | True | +| rows differing in any field | 0 | — | True | +| raw status differences | 0 | — | True | +| canonical outcome differences | 0 | — | True | +| family differences | 0 | — | True | +| module differences | 0 | — | True | +| family/module topology | approval, evidence, policy, retry, routing, structuredOutput, tools | approval, evidence, policy, retry, routing, structuredOutput, tools | True | +| analyzer semantics block | `{"engineVersion": "1.22.1", "mutators": ["CONDITIONALS_BOUNDARY", "INCREMENTS", "INVERT_NEGS", "MATH", "NEGATE_CONDITIONALS", "TRUE_RETURNS", "FALSE_RETURNS", "PRIMITIVE_RETURNS", "EMPTY_RETURNS", "NULL_RETURNS", "VOID_METHOD_CALLS"], "pluginVersion": "1.19.0", "timeoutConst": 4000, "timeoutFactor": 1.25}` | `{"engineVersion": "1.22.1", "mutators": ["CONDITIONALS_BOUNDARY", "INCREMENTS", "INVERT_NEGS", "MATH", "NEGATE_CONDITIONALS", "TRUE_RETURNS", "FALSE_RETURNS", "PRIMITIVE_RETURNS", "EMPTY_RETURNS", "NULL_RETURNS", "VOID_METHOD_CALLS"], "pluginVersion": "1.19.0", "timeoutConst": 4000, "timeoutFactor": 1.25}` | True | + +**Determinism: PASS** — the canonical population (identity set, raw PIT status, canonical outcome, family, module, topology and analyzer semantics) is exactly reproducible across two independent `--rerun-tasks` campaigns at the same SHA. No serialization-order difference exists either: the two files are byte-identical. + +## Committed authority vs fresh measurement + + +| Measure | Count | +|---|---| +| committed authority total (`mutation-baseline.json` at `0f36ab87`) | 2384 | +| fresh total (Campaign A) | 2544 | +| identities present in both (shared) | 2195 | +| identities only in committed authority (disappeared) | 189 | +| identities only in fresh measurement (fresh-only) | 349 | +| shared identities with changed **raw PIT status** | 2 | +| shared identities with changed **canonical outcome** | 1 | +| shared identities that changed family | 0 | +| shared identities that changed module | 0 | +| configured family/module topology changes | 0 | +| analyzer-semantics changes | 0 | +| mutator-set changes | 0 | + +Committed authority `byFamily` totals: approval=768, evidence=798, policy=209, retry=155, routing=15, structuredOutput=382, tools=57 +Fresh `byFamily` totals: approval=918, evidence=808, policy=209, retry=155, routing=15, structuredOutput=382, tools=57 +Committed authority raw status distribution: KILLED=1595, NO_COVERAGE=294, SURVIVED=430, TIMED_OUT=65 +Fresh raw status distribution: KILLED=1667, NO_COVERAGE=330, SURVIVED=463, TIMED_OUT=84 + +Neither total is reported as a mutation score: the population identity set is the authority, and the delta below is the finding. + +## Disappearance attribution + + +All 189 committed-authority identities absent from the fresh measurement are attributed. There are no `UNEXPLAINED` items. + +| Category | Count | +|---|---| +| A. SOURCE_REMOVED | 0 | +| B. SOURCE_REFACTORED | 189 | +| C. TARGET_CONFIGURATION_HISTORY | 0 | +| D. COMPILER_OR_BYTECODE_IDENTITY_EVOLUTION (mechanism of the B set, see below) | 0 as a stand-alone category | +| E. UNEXPLAINED | 0 | + +### Evidence for category B + + +Every one of the 189 disappeared identities belongs to one of four mutated classes, and each has exactly one or more fresh counterparts carrying the **identical source-level mutation point** — same `module`, `className`, `method`, `methodDescription` (JVM descriptor), `mutator` and `description` — with a different PIT bytecode coordinate (`block`/`index`). The v2 identity schema is `SHA-256(module, class, method, descriptor, mutator, description, block, index)`, so a rewritten method body necessarily re-numbers `block`/`index` and the old identity cannot reappear. + + +| Class | Disappeared identities | Fresh counterpart on the same source-level mutation point | Source file diff | Commits responsible | +|---|---|---|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 71 | 71 / 71 | 155 insertions(+), 55 deletions(-); 267 -> 367 lines | `ecadc4b5` — 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 48 | 48 / 48 | 160 insertions(+), 80 deletions(-); 233 -> 313 lines | `ecadc4b5` — 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 27 | 27 / 27 | 240 insertions(+), 55 deletions(-); 155 -> 340 lines | `cb09fb3a` — 0.7.1d1: governed identity carriage through the preview approval gateway (P3, engine half) (#418); `ecadc4b5` — #407 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | 43 | 43 / 43 | 53 insertions(+), 27 deletions(-); 235 -> 261 lines | `ecadc4b5` — 0.7.1d: governed run attribution & identity continuity (#407) | + +Corroborating facts, each verified against git and the measured reports: + +- The mutated method of every disappeared identity still exists at `0f36ab87` (`fun` declaration present in the same class): 189 / 189. +- `config/quality/test-quality.yml` is byte-identical between the authority's `measuredCommit` (`5856530e`) and the audited SHA (`diff` empty), so no target family, module, target class or target test was added or narrowed. Category C is therefore empty. +- All 14 configured family/module pairs produced a `mutations.xml` in both campaigns (see section 5): nothing disappeared because a configured target stopped being measured. +- The four classes above are the *only* classes containing disappeared identities, and each of them was rewritten inside the audited range by #407 / #418. + +No behavioural equivalence is claimed for any pair. The claim made here is narrower and is the one the evidence supports: the source-level mutation point is preserved and only its compiled bytecode coordinates moved. + +### Complete disappeared-identity inventory + + +| # | identity | family | module | class | method | mutator | description | line | block | index | baseline status/outcome | source file changed by | enclosing method at base | fresh counterpart(s) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | `82fe089c156d3cab018d2c369c1d9a579b3437dcb4f60ad52a00c2e7b5395fcd` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 13 | 85 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 2 | `546a615f4436ae3c4551bac83587debf19f41acb30f77ddf50ac1a032a908b39` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 26 | 156 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 3 | `ef0c447154578eb65b0eab3c9a5eb7d6dae5fdb9a973000c141659b3fc7fee12` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 38 | 248 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 4 | `1d9dc7a8c9b27ecacc1ce9a793dd858006fa2756f9af04459e7f0ee5344bb5e4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 45 | 340 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 5 | `ac0be306eb26093e449960234cc23ae4360f2a9f24ce7755548d91c41a8cf0e2` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 51 | 422 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 6 | `30fe02f32b41cb86a7cc26fc2c9ca8daae09d0f876a1640261c3bcfe8b8d1985` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 66 | 527 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 7 | `c4c37ffff62b410b50781206c508dd2116afe304d0b6352711342bbdfb8a5b6a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 83 | 635 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 8 | `ba6ebc3e7076aad611c1b8b8e1a2c8bb32fb8e9023083786e0cb9aa11b10683d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 95 | 737 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 9 | `ec7d1ab598d682932fc840658003cc3de493213cb7f7bec10ef1e9fa2b57043f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 108 | 843 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 10 | `ebf31b386086133eb9870daff2229bd15e1d1950dfade56d84ce97ccd2a1008f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 123 | 991 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 11 | `f2d34adda4b651689c1314670d1c4c24f767f4e0a4e5f25c69d0f27519203a9c` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 140 | 1131 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 12 | `cf1441ce7d467171fecd593d5b3396dae9e746cca078d995de70022c95c6d024` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 162 | 1281 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 13 | `f05dac1eeae60233c8063f5e5d6d51936c4160d819cd58b1bc072e03793e685f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 35 | 186 | 1444 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `0bc1f243`, `441d3343`, `5397c6bf`, `9026b993`, `92b019e1`, `ceba69cd`, `ecb1e83a`, `ece7d119`, `ef13cbec` | +| 14 | `083b220893a987e60174fe419f93fed1a68054923ea384effe2dc1fb5fd3fa1d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 12 | 72 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 15 | `b7b2dfec2fa09b6eb182fea85d233cc87faac535cc65c56da85825d1a80b68c7` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 25 | 138 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 16 | `efacd2b1e0f485e273560d796700fa9827d03c93e51328b758068d34f4ace86a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 37 | 220 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 17 | `c52b419c90e9aeaf63ea6a1d8c7cba7eea201ad5aca14a2fad28107c219f3de9` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 44 | 302 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 18 | `3555704187bd3a2f436216918a2c486828d59193985ee03b9de3f58638a3d9d7` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 50 | 384 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 19 | `246350bcbd765bab30f01bd098a4ac4343dca32a12b62313fb86886269547f11` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 65 | 484 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 20 | `976f65569f36d2e3edd8a235b9ff9ebfaaa571be0c3acd960b1df205ef0b7197` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 82 | 592 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 21 | `32df7c11c787780f1f4858c17032291bca4c707c06e677736a5d1b7f47231811` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 94 | 699 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 22 | `0e8a40e624e101b8387b3e5657c69280b6b6b5c5f9c395a1129aed7c23fb8dd5` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 107 | 800 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 23 | `b1e34f861946bca679216e4b049070608e98c66fab5f766f229d3c3671b640bb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 122 | 931 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 24 | `319b7bee9207f52fc24858a6c755ed014c79ab39a252c7c3292f1e8799c49d3a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 139 | 1068 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 25 | `d64b7292b499228c8682560ed181e1067cff585542f5d1af25caf413e37f232d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 161 | 1218 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 26 | `f0cf0add6920df8153ee3ae07249fb86b8f771a5879482a900addd12af1faf96` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 35 | 185 | 1381 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 27 | `bd46cba9bba932b8ccd3c2559fb736144c4a0ec61c301c69cba3fd573f14a6be` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 37 | 11 | 68 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 28 | `130b60fa7f074f598b0bf133ef3eb4107a6508e011a1abd4bf5fef96aea13c34` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 40 | 15 | 95 | KILLED / KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 29 | `59bbf214663690ff40abf6695e8508b450b1ba104299445761c6dd3945e91102` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 41 | 17 | 101 | KILLED / KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 30 | `85ae74cb2ffc3e0a9b39a47a55f090adbdc94804bfd3a14000e8a6e8dcd2f711` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 46 | 24 | 134 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 31 | `2155f6a06a7837e9d35bc4d2318d244639ff8f6095564243149e0b8918557325` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 46 | 28 | 163 | KILLED / KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 32 | `e200bda99ea856d765bdebe57b8fd5b0371f9d948c31eed3b4b3fb39000be406` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 49 | 36 | 216 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 33 | `319df689ce2b58ca09f69673e63be5d46ca210a4306f5ca057d6780dacd6768c` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 52 | 43 | 298 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 34 | `8c909e76359d54e15ed4f892d5fd3763402b9a2c967c4a38d8d0612954358dfc` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 53 | 49 | 380 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 35 | `f43a1391235fa3af0911ecc49adf3d9556deb3c4d273a74c4d23cd2b9b001403` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 54 | 53 | 432 | KILLED / KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 36 | `9e05d5b73798f8e90a50de3460dfb21a4fb853fec8bd1bededcbab6f42df19da` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 54 | 64 | 480 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 37 | `216bc036a8ad39df78010e2a47317c344aeed1eceb26a9f02baa86bd8efcd628` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 55 | 71 | 543 | KILLED / KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 38 | `0c6a0f5da99ccc30abfb217561fae68d69dc66fbc9ecb83af70a25b4ccc4d554` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 55 | 81 | 588 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 39 | `cfa7fa1e346a7cec464cfb674699b00f1ad7bfc4be02815700f5656fd6f6148a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 56 | 87 | 650 | KILLED / KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 40 | `05abfbc4e98099ec67da3968eac4760f3d3e3d450951a03bed5a3c680ae61774` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 59 | 93 | 695 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 41 | `665f7e153ec5c9a5183074ecce43b947aa673ecc7ea783888fcebffe8a692533` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to dev/tramai/engine/approval/ReplayAuthorizationService::emitAuthorizationReplayed | 60 | 98 | 751 | KILLED / KILLED | ecadc4b5 | yes | `0985c461` | +| 42 | `bb7c63ab70f627db63573fb75cab2e3f899ae3eb1ba8cbbd8699c65b6d737b51` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 61 | 106 | 796 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 43 | `0110d7f7665b47ebc3bf1cea5a362b545205a50795ab96652968a21ae4677f22` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 65 | 189 | 1457 | KILLED / KILLED | ecadc4b5 | yes | `13586a63`, `18ac9e9b`, `2d592835`, `51703956`, `6ec884e7`, `7cd98a90`, `d20dbac3`, `d3f812e8`, `f0effc0a` | +| 44 | `a9e240548004e6069badf22c734284702c95dbc463696a949eadc578f8fab267` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 66 | 121 | 927 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 45 | `e52fd6ae714d4313ce487f557d2a39b7423478038f3ffc1e7755eea81ccce667` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 68 | 138 | 1064 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 46 | `692b201e3aa17a96282b2540feca325a10e9f599a825bf6646eab69a41bdfcc6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 71 | 146 | 1154 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 47 | `1fb81d5d3133a74314590ac5dca4d3e3c9d5648a7e33d3f64420ccf9a3676e55` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 71 | 160 | 1214 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 48 | `0a1c3c1c58bbd3fbc81a661140302ed7924b88eabc3f4cb13ba994b81ec09691` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 76 | 166 | 1305 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `1ce5967e` | +| 49 | `995ddfdf0973289bd2aaae3c7ac6d5d193bddb254942c68a214532601f15ad38` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 77 | 170 | 1317 | KILLED / KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 50 | `f2f3de5d9785c42b69876a9d63a22a7cbfcb84a7b2412ff6e16d65fcfc6db83d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 77 | 184 | 1377 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `25259608`, `4865b74c`, `4a64ba00`, `611bdacf`, `7b2a3115`, `827431d2`, `8b4d579f`, `9b9bfadf`, `a9760bea`, `e118041e`, `e78cb26e` | +| 51 | `6371b23fe46996857beba836eb604bbad25d658c8b84c3be0ed90554c9b6f381` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 82 | 21 | 194 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `021b3c45`, `577c0730`, `66f8b3fe`, `b280cd7e`, `d78b9c0d` | +| 52 | `4e1e2df1c11eae41ce0876811804019e367164d932eb485d4eee2734d245f3e2` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 82 | 41 | 368 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `021b3c45`, `577c0730`, `66f8b3fe`, `b280cd7e`, `d78b9c0d` | +| 53 | `527a99cc5db7370b85b52164d27928486931a7f3c5247b7a67004fbf9f1ef5f9` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 82 | 56 | 489 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `021b3c45`, `577c0730`, `66f8b3fe`, `b280cd7e`, `d78b9c0d` | +| 54 | `950b0b5ac5a964b4a53962a1e439caca8a7943474f1919be4eaf164159577778` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 82 | 20 | 163 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c94b48`, `477612a6`, `4b1bdb9f`, `d4e39f4e`, `ef7dc1d6` | +| 55 | `be663ad795d6885d7609aca076dfc7e1502fe28e063cd7ff2c21d7f5d281f3e6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 82 | 40 | 317 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c94b48`, `477612a6`, `4b1bdb9f`, `d4e39f4e`, `ef7dc1d6` | +| 56 | `4948fdbaf262d7c72c4f3f2c6a29ed7219c62cb0f1d820a9d8f0be6a3e7e49c1` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 82 | 55 | 434 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c94b48`, `477612a6`, `4b1bdb9f`, `d4e39f4e`, `ef7dc1d6` | +| 57 | `949c8ad8dfd437397bb1938ed16ced78b35521bc1e81770a35d3f0b91a03a245` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 90 | 19 | 159 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `5d700610`, `65694901`, `6cadd339`, `f23b4d09` | +| 58 | `062c0e53f177bc67178766900107469b2751614ddbbdc486618320ac6746b6d3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 98 | 39 | 313 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `5d700610`, `65694901`, `6cadd339`, `f23b4d09` | +| 59 | `dc0810d0cc145bf0e3745796d66a9175a03676369e161f328dc8cd87fb3512a7` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 110 | 54 | 430 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `5d700610`, `65694901`, `6cadd339`, `f23b4d09` | +| 60 | `56b12a836fef73fbc3184febbffbd2824b0357c966758422fb2d7c042052dac8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 111 | 58 | 496 | KILLED / KILLED | ecadc4b5 | yes | `04c94b48`, `477612a6`, `4b1bdb9f`, `d4e39f4e`, `ef7dc1d6` | +| 61 | `189b579940ae63f9aba4a4be8f21e4c9e6e043e4186d838cb76bce1a8410494e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 33 | 204 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04229efd`, `62911071`, `7c71e3a4` | +| 62 | `83cb636cae4b11deeaf924238c4227f9df48436ece99b3b35337cdab42a897ef` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::revealAndValidateReplayPayload | 114 | 32 | 173 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `20ca1a1d`, `2117cb76`, `38238be2` | +| 63 | `c479cc1c69a1d68182be1c0c3a69714d5700105821024efb7ef1419fe1a87808` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NegateConditionalsMutator` | negated conditional | 123 | 23 | 130 | KILLED / KILLED | ecadc4b5 | yes | `0b1dfa38`, `10512d6c`, `1313dad6`, `57984816` | +| 64 | `26ff0a87eb1ba6c06a77256c3a16d075d9db7d7fb107ed4077380a3f5686dc56` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NegateConditionalsMutator` | negated conditional | 124 | 31 | 169 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `0b1dfa38`, `10512d6c`, `1313dad6`, `57984816` | +| 65 | `e2ccc920c43c3a0337efc61a8ba030edac2840deb59ea80ebeaa0f38c0fe6796` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::revealAndValidateReplayPayload | 127 | 37 | 222 | KILLED / KILLED | ecadc4b5 | yes | `20ca1a1d`, `2117cb76`, `38238be2` | +| 66 | `e3c33ca72888706f68d059899580e3275695bb1ed3ead44e0cc1469d7a5afd08` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 258 | 9 | 34 | KILLED / KILLED | ecadc4b5 | yes | `01904bee`, `174b91ba`, `30b1b3b7`, `b075932b`, `ba56c474`, `c7018b51` | +| 67 | `e21521614f27a6f3c0cce2315a108667da6ff1bac8d9a29bd085ef6e7b21a96e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 261 | 17 | 58 | KILLED / KILLED | ecadc4b5 | yes | `01904bee`, `174b91ba`, `30b1b3b7`, `b075932b`, `ba56c474`, `c7018b51` | +| 68 | `057b79621cabf73bf2880c446d83ddf31daba59eaa5bfa4a6d128683f273c632` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 262 | 25 | 84 | KILLED / KILLED | ecadc4b5 | yes | `01904bee`, `174b91ba`, `30b1b3b7`, `b075932b`, `ba56c474`, `c7018b51` | +| 69 | `f5c7739fa85d69bddd1f37d79efba20426d4f721499383d5ff64006dc9c0606e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 263 | 32 | 109 | KILLED / KILLED | ecadc4b5 | yes | `01904bee`, `174b91ba`, `30b1b3b7`, `b075932b`, `ba56c474`, `c7018b51` | +| 70 | `80aacc22c2e7948c37e5ba8474219e0aa24f18acef8e19daf7b3ff3575354763` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 264 | 39 | 134 | KILLED / KILLED | ecadc4b5 | yes | `01904bee`, `174b91ba`, `30b1b3b7`, `b075932b`, `ba56c474`, `c7018b51` | +| 71 | `6ced1d5fb8f4a1d8376837e4722f24b7032a4f1ddce619325992ad743c337a40` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resolveAndValidateResumeTool | 265 | 43 | 155 | KILLED / KILLED | ecadc4b5 | yes | `bb6e32ec` | +| 72 | `780c644a8598c145eb32f407bd7bfe9cff29be63b081db0752103dd7f9a9a718` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 84 | 19 | 72 | KILLED / KILLED | ecadc4b5 | yes | `2a893a6a`, `3be6c394`, `44eac63d`, `554be71f`, `6a008b1f`, `9881620e`, `affd482f`, `d07e14bb`, `d4e55711`, `ec57deeb` | +| 73 | `83d1ef42f35ca1df37706e5b40916522be7b46303e0b77452bb0c2f671dace71` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 87 | 28 | 99 | KILLED / KILLED | ecadc4b5 | yes | `2a893a6a`, `3be6c394`, `44eac63d`, `554be71f`, `6a008b1f`, `9881620e`, `affd482f`, `d07e14bb`, `d4e55711`, `ec57deeb` | +| 74 | `b33d3e8cc0a1eeba34b1b4220ffe8cf736ab882bfc7816046a303c46654e0bf6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 87 | 31 | 108 | KILLED / KILLED | ecadc4b5 | yes | `2a893a6a`, `3be6c394`, `44eac63d`, `554be71f`, `6a008b1f`, `9881620e`, `affd482f`, `d07e14bb`, `d4e55711`, `ec57deeb` | +| 75 | `3523d4890c1d86eb5799b72af98aa2b10e732db3877faaacd8af5236a68772b5` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 87 | 35 | 115 | KILLED / KILLED | ecadc4b5 | yes | `2a893a6a`, `3be6c394`, `44eac63d`, `554be71f`, `6a008b1f`, `9881620e`, `affd482f`, `d07e14bb`, `d4e55711`, `ec57deeb` | +| 76 | `c869df82a567053d34ba7a512a4418c6a8ac0cfcfe0caf8303badc0924ecace8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 87 | 38 | 125 | KILLED / KILLED | ecadc4b5 | yes | `2a893a6a`, `3be6c394`, `44eac63d`, `554be71f`, `6a008b1f`, `9881620e`, `affd482f`, `d07e14bb`, `d4e55711`, `ec57deeb` | +| 77 | `b5217c93437c82fa7f6516f742eb044f23d19f9f5f6fd022d09d15afb66d2eb2` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `ConditionalsBoundaryMutator` | changed conditional boundary | 90 | 43 | 147 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `65f7e7cc` | +| 78 | `142d3419d52f38a97243c03b42ab2f3fdb463f282d2e4ff1978fdbed1284fdfa` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 90 | 43 | 147 | KILLED / KILLED | ecadc4b5 | yes | `2a893a6a`, `3be6c394`, `44eac63d`, `554be71f`, `6a008b1f`, `9881620e`, `affd482f`, `d07e14bb`, `d4e55711`, `ec57deeb` | +| 79 | `56fb3e2cb039aab55a0c0cd6348b13dac467e1f9de7ef1a526edd6f85fae9738` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 90 | 46 | 155 | KILLED / KILLED | ecadc4b5 | yes | `2a893a6a`, `3be6c394`, `44eac63d`, `554be71f`, `6a008b1f`, `9881620e`, `affd482f`, `d07e14bb`, `d4e55711`, `ec57deeb` | +| 80 | `f7d77ffa48e3b779d4b3bdd325816b513aa52f37f44361d0daf5aac4abff5b46` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `ConditionalsBoundaryMutator` | changed conditional boundary | 105 | 19 | 70 | KILLED / KILLED | ecadc4b5 | yes | `07a00f8c`, `dd46e402` | +| 81 | `8e8f88d468af58cc0a5533980fb74d476432d9e90cd71aa826714ca6e769828c` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 105 | 19 | 70 | KILLED / KILLED | ecadc4b5 | yes | `0255ddf8`, `165b7e85`, `5c76e674`, `6b2892e8`, `6eeabb63`, `ce222ceb`, `dcf9c84e` | +| 82 | `38958541295a62e3daa09e3781f9eb466a913b8a68c0cc09d58c2e033e25cb6b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 105 | 22 | 79 | KILLED / KILLED | ecadc4b5 | yes | `0255ddf8`, `165b7e85`, `5c76e674`, `6b2892e8`, `6eeabb63`, `ce222ceb`, `dcf9c84e` | +| 83 | `87dcca678e077eb7f49987f8bd0ec806a643f6bbba7f203b2d3a8326772adcd3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 106 | 26 | 88 | KILLED / KILLED | ecadc4b5 | yes | `0255ddf8`, `165b7e85`, `5c76e674`, `6b2892e8`, `6eeabb63`, `ce222ceb`, `dcf9c84e` | +| 84 | `35a85692107c57e4ca7bb4ae19e3824dabcf2276a70a70e01afb72e0ed061de2` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `ConditionalsBoundaryMutator` | changed conditional boundary | 110 | 31 | 110 | KILLED / KILLED | ecadc4b5 | yes | `07a00f8c`, `dd46e402` | +| 85 | `47b75ca5bb946fce6a09beebbebf9912d83d4946a9d3a6e313ebdf462222d93a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 110 | 31 | 110 | KILLED / KILLED | ecadc4b5 | yes | `0255ddf8`, `165b7e85`, `5c76e674`, `6b2892e8`, `6eeabb63`, `ce222ceb`, `dcf9c84e` | +| 86 | `909339da34dde55b76a7b1043465b145c79b0889d2e360bde0756791c58ae253` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 110 | 34 | 118 | KILLED / KILLED | ecadc4b5 | yes | `0255ddf8`, `165b7e85`, `5c76e674`, `6b2892e8`, `6eeabb63`, `ce222ceb`, `dcf9c84e` | +| 87 | `a5a4c02eda08ced6c11f295e09b7a01670bdd737d853db22248198667e3c354c` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `EmptyObjectReturnValsMutator` | replaced return value with "" for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::validateInitialApprovalRequirement-zjKNo-Q | 111 | 38 | 139 | KILLED / KILLED | ecadc4b5 | yes | `79f0e7fb` | +| 88 | `0aacdd75008dc597dfc796387bc7138d3efdde59a54f7abce241ee5013ce4581` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 37 | 232 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c2e1c3`, `30f9bb41`, `564517b2`, `62897c20`, `6524608d`, `87fbd8e6`, `f1a19c07` | +| 89 | `d0bb3df5d0eafe76663fb617ea2bcaacad24af2b38bccfd85a961180039d5672` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 62 | 412 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c2e1c3`, `30f9bb41`, `564517b2`, `62897c20`, `6524608d`, `87fbd8e6`, `f1a19c07` | +| 90 | `5afb3c677c23a861ed8eafdbcaf88cb0bd467c2bcf57f49a2af1026b770556ef` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 109 | 692 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c2e1c3`, `30f9bb41`, `564517b2`, `62897c20`, `6524608d`, `87fbd8e6`, `f1a19c07` | +| 91 | `5a41f3ffc662fa55d35dba1909b94c1848dedc1146268ab889f7df98ac9abd34` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 130 | 860 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c2e1c3`, `30f9bb41`, `564517b2`, `62897c20`, `6524608d`, `87fbd8e6`, `f1a19c07` | +| 92 | `364e06b64a99a1b4a086b6d003d62eae024d6c38e42ab21c25129df101fd635d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 153 | 1044 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c2e1c3`, `30f9bb41`, `564517b2`, `62897c20`, `6524608d`, `87fbd8e6`, `f1a19c07` | +| 93 | `e0f4a6a950eb63f0639eec3c497921fa703d01b12def70603dbc0d4b56fbd110` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 167 | 1193 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c2e1c3`, `30f9bb41`, `564517b2`, `62897c20`, `6524608d`, `87fbd8e6`, `f1a19c07` | +| 94 | `261e4a81cca1c5cd532e953501c99d467fed7993b8b35f4bc27eb046bf809640` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 114 | 183 | 1344 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `04c2e1c3`, `30f9bb41`, `564517b2`, `62897c20`, `6524608d`, `87fbd8e6`, `f1a19c07` | +| 95 | `5ecf004235db47c42fbba875f40c8bada66e1714a0c4cf3804787bfd94fd2b82` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 114 | 36 | 194 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `1843d3a3`, `3b12d1a5`, `453e4aaf`, `4f99ba33`, `66c311cf`, `c2be62de` | +| 96 | `8d7e1fa01d045eeba98fd6f90840468e02875757e3c7270f150d812a47298895` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 114 | 61 | 367 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `1843d3a3`, `3b12d1a5`, `453e4aaf`, `4f99ba33`, `66c311cf`, `c2be62de` | +| 97 | `72555a80f9468d15109776a23d4b48105afd7dae4c688b774353706a3dd8f7fb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 114 | 108 | 622 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `1843d3a3`, `3b12d1a5`, `453e4aaf`, `4f99ba33`, `66c311cf`, `c2be62de` | +| 98 | `b7ddd5aaa42259755c26e7d4d36f66abfebc82de7108278f0e5ac5e85c13acd3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 114 | 129 | 790 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `1843d3a3`, `3b12d1a5`, `453e4aaf`, `4f99ba33`, `66c311cf`, `c2be62de` | +| 99 | `cfe2ccb893ca48be3894ee6d61957c9b732a16a8830edb66f4aac1ae41d308cc` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 114 | 152 | 990 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `1843d3a3`, `3b12d1a5`, `453e4aaf`, `4f99ba33`, `66c311cf`, `c2be62de` | +| 100 | `3aa7e614aa6ac84aaecf1a4fb0d54e670bddc881b07f59a35b50f1cc9f1b62cd` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 114 | 166 | 1139 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `1843d3a3`, `3b12d1a5`, `453e4aaf`, `4f99ba33`, `66c311cf`, `c2be62de` | +| 101 | `35c0b8afec49c52d4f4e939cbb0fb5bbd9e4aeb65a06ebabf3c432bb03d2e36a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 114 | 182 | 1290 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `1843d3a3`, `3b12d1a5`, `453e4aaf`, `4f99ba33`, `66c311cf`, `c2be62de` | +| 102 | `5e13cf15e29cb717376bada0008b5a705dcee0a70341f96df2b19f625de33678` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 115 | 8 | 50 | KILLED / KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 103 | `acb3b3ec95bc51217b7214b5893cb46dc2a9982d6b88789f86ab2fd5d5db0a42` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 117 | 11 | 71 | KILLED / KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 104 | `73418182fa85726be12904f6164e3ecf6fe9b86519aece34982bc6306719fa5a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 124 | 35 | 190 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 105 | `14d9afc3f3883a5228acb5af40dfb349e68f18c47d101d3c002d7381a5775a17` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/jvm/internal/Intrinsics::checkNotNull | 132 | 31 | 155 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `3658ba45` | +| 106 | `97b1be0758dead1ecc207615a95d60a8943690305890679308f2978fa69848aa` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 136 | 60 | 363 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 107 | `d8fb778015c5a217e1a7baac454ec8e153d4019cb664750a99ce924aa9fdfe05` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/jvm/internal/Intrinsics::checkNotNullExpressionValue | 147 | 54 | 297 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `ac7940cf` | +| 108 | `4a35eba5008e2026f17ec0991f08de3057f69dc2ff375d77f7d1058384de247f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 157 | 66 | 429 | KILLED / KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 109 | `350a94d49928183879837011ca2899707ec297b2f4eb9cd31dde17253bda0534` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 166 | 107 | 618 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 110 | `88dec573bedb6aaa046b85e47990e5cdeb03e8b3be04d20713037e6efd347ef3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 185 | 128 | 786 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 111 | `65778562d1a90086389a488009846170d75153ff4bf10485ef62dcbb37988fec` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 203 | 142 | 906 | KILLED / KILLED | ecadc4b5 | yes | `75b574f7` | +| 112 | `4fc4c332ed039bc77619dd96bb5ed5a7d7993c39939d3d739da525222fc67fd8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 204 | 143 | 911 | KILLED / KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 113 | `6d79865a905314b94c87f8c173cf629a32c85ac57f37157a9031ee80c30481a3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 205 | 145 | 919 | KILLED / KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 114 | `5a8ca9067f040c03972d7dceb0e2137b79b1c91d1899d2f22f8f6aaa49c2b163` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 207 | 151 | 986 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 115 | `8b266859888e6063a44fcf410f2e9e208ac54739ee2317ebc090961534afd9eb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 211 | 157 | 1068 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `75b574f7` | +| 116 | `b0e724190b292778c4e4903adeda1dfa5201ebb94ed6167d3f493069b4799698` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 214 | 165 | 1135 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 117 | `a3aa225ceecd19dec371f1904fe6b60481f3cebdeaae4dbc55f3f219ce43ad5d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 218 | 171 | 1217 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `75b574f7` | +| 118 | `1f025dec8de783d77bca435fe618c6a819fec0be91caeba7b3c6aa8e298d5d53` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 222 | 181 | 1286 | TIMED_OUT / NON_KILLED | ecadc4b5 | yes | `16f71d24`, `2331c4f6`, `43dd0cdf`, `45475202`, `6e83174c`, `c3a3a1cf`, `c8855220`, `ce65c0b7` | +| 119 | `d68d2a536541e1b4f1de71ca49b5b34aa6a7a8ce25006496cf0e2c3ad8ac82e7` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 226 | 187 | 1368 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `75b574f7` | +| 120 | `3db41731fa0393fe43aaa0288207c8ebf692b552b0c98379514bc6f9929a1551` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 53 | 15 | 110 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `5a7edac3`, `6a33ba5d`, `6dcb09ca`, `8210e97a`, `8dc069d5`, `b2d0d9fd`, `d77706fd` | +| 121 | `7d5515ebf1454998b4df3ccf0f10795dda859f3ef4fc8af24f725fafb01b4b42` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 53 | 26 | 184 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `5a7edac3`, `6a33ba5d`, `6dcb09ca`, `8210e97a`, `8dc069d5`, `b2d0d9fd`, `d77706fd` | +| 122 | `02c365c59375551744b483b46d39794b8e93491e53bfdf339a4cff1e4ea59c51` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 53 | 40 | 277 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `5a7edac3`, `6a33ba5d`, `6dcb09ca`, `8210e97a`, `8dc069d5`, `b2d0d9fd`, `d77706fd` | +| 123 | `d91f6922ebfe647243fb65df46a7557d087d830be640be56629bd4dd8b5e65f2` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 53 | 52 | 362 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `5a7edac3`, `6a33ba5d`, `6dcb09ca`, `8210e97a`, `8dc069d5`, `b2d0d9fd`, `d77706fd` | +| 124 | `5c2c8235fab20ded12284021db2aae1fcddf3144441d95dea0a9d02ed8b22e8f` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 53 | 64 | 447 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `5a7edac3`, `6a33ba5d`, `6dcb09ca`, `8210e97a`, `8dc069d5`, `b2d0d9fd`, `d77706fd` | +| 125 | `4681686f2f267cb553f68d413c5d1d0ffdda484edb42584dcaa1cf839e0fd537` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 53 | 14 | 90 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `3883b3c1`, `4dec4f43`, `5c2981ad`, `862220d0`, `8b52a478`, `de0f389d`, `e52f1c1f`, `ebf61136` | +| 126 | `46159f4c1a0f9c057292497389e25cbbe69d792126307ae5667a45a2069682e1` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 53 | 25 | 157 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `3883b3c1`, `4dec4f43`, `5c2981ad`, `862220d0`, `8b52a478`, `de0f389d`, `e52f1c1f`, `ebf61136` | +| 127 | `6e45cd86eec24bafbc7b2b6b78b0a5145167b3199d2cf62c4c9f7c6025f2720c` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 53 | 39 | 245 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `3883b3c1`, `4dec4f43`, `5c2981ad`, `862220d0`, `8b52a478`, `de0f389d`, `e52f1c1f`, `ebf61136` | +| 128 | `fefd169fd4b29701e21b49169db1d6ce9546fdcd91c132d9b2f3587d089330a0` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 53 | 51 | 330 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `3883b3c1`, `4dec4f43`, `5c2981ad`, `862220d0`, `8b52a478`, `de0f389d`, `e52f1c1f`, `ebf61136` | +| 129 | `246b6fba6aabb668aa580497138761695987f6d3d10ec41f3613842cd74abb77` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 53 | 63 | 415 | NO_COVERAGE / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `3883b3c1`, `4dec4f43`, `5c2981ad`, `862220d0`, `8b52a478`, `de0f389d`, `e52f1c1f`, `ebf61136` | +| 130 | `947f05488df35965fa51bf4673beb3e92a14e4406ec31c6f66bca3bb7e8ca4e1` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 59 | 13 | 86 | TIMED_OUT / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `0fff8866`, `2fa16c9f`, `4245a1aa`, `6f9609fd`, `8148b9d1`, `9f7ce30f`, `cda68544`, `e44e3a71`, `f5e8c455` | +| 131 | `37470da71000d7f3ffed6b39c5cb491e0c411a15cfe80d66c6f5a3ba9ed6df82` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 66 | 80 | 500 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `3883b3c1`, `4dec4f43`, `5c2981ad`, `862220d0`, `8b52a478`, `de0f389d`, `e52f1c1f`, `ebf61136` | +| 132 | `5ffadc16dab2dcfa5c8b310d7c7631633e1ca3094981733343926fff913b1731` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 67 | 24 | 153 | TIMED_OUT / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `0fff8866`, `2fa16c9f`, `4245a1aa`, `6f9609fd`, `8148b9d1`, `9f7ce30f`, `cda68544`, `e44e3a71`, `f5e8c455` | +| 133 | `e236ea3f276e38d9e26dd11dd5c20a1bdbbe4a0f52cbf7e890051d0abd095339` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 68 | 28 | 193 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `0fff8866`, `2fa16c9f`, `4245a1aa`, `6f9609fd`, `8148b9d1`, `9f7ce30f`, `cda68544`, `e44e3a71`, `f5e8c455` | +| 134 | `b91468eee9d536c3d229a8ed57ec7513ce52c450b0824e6f004dc4b7ce1318cb` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 69 | 30 | 202 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `3883b3c1`, `4dec4f43`, `5c2981ad`, `862220d0`, `8b52a478`, `de0f389d`, `e52f1c1f`, `ebf61136` | +| 135 | `afba54a83e116a798977913ddae1941d5c76f01827322f4c3af6e6f49ea6a1f5` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 72 | 38 | 241 | TIMED_OUT / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `0fff8866`, `2fa16c9f`, `4245a1aa`, `6f9609fd`, `8148b9d1`, `9f7ce30f`, `cda68544`, `e44e3a71`, `f5e8c455` | +| 136 | `a76865f870dc0c2ec6a1b4feb5a304a3374b6470104178b235273b64283d72fd` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 74 | 50 | 326 | TIMED_OUT / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `0fff8866`, `2fa16c9f`, `4245a1aa`, `6f9609fd`, `8148b9d1`, `9f7ce30f`, `cda68544`, `e44e3a71`, `f5e8c455` | +| 137 | `1bfda558c38bac3cde86dd8bc03fbf4253f2029d968311d650df2d72087c781c` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 79 | 62 | 411 | TIMED_OUT / NON_KILLED | cb09fb3a;ecadc4b5 | yes | `0fff8866`, `2fa16c9f`, `4245a1aa`, `6f9609fd`, `8148b9d1`, `9f7ce30f`, `cda68544`, `e44e3a71`, `f5e8c455` | +| 138 | `ef4a38a37da4f0accf5e3d5119d9947c209a0448d0ba7f719bcf39d88ccaa57e` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::toGatewayResult | 110 | 54 | 280 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `faf92c0f` | +| 139 | `793981f98769edc8e547086de3566bbdcafdd72785b0cbec1e38f5546ed073bd` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 114 | 6 | 30 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 140 | `83509ebce4e0058761aa64637f20762b99bcb779272d6bbacf8b8dff4721288d` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 117 | 11 | 56 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 141 | `0439bb0607903bc6f27d61149dea58b6c4e2bb8dd7688ecba4108c4234ea3fb2` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 124 | 20 | 115 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 142 | `e1fcc10b0931789c0c691229bbf439436ae323ed91ee4e3557c0214a3af20508` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 127 | 22 | 125 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 143 | `0cbf1d2df2070824efe130be900a5a4b791286d6ddede16c07c669e79451319a` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 130 | 27 | 151 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 144 | `9be7727f6dc42ae7165113c44d2c7a7bbcd080721fa941a1f0d9e36716674c75` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 133 | 32 | 180 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 145 | `a56c34f5b7ff9ed9ddb56ac2d09de44c737792ccbfd6ed140939aa72e18a4f9e` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 137 | 38 | 221 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 146 | `e62f20c11017a5691291ed7bbd2370b8c90cda0651f701eef711b5edd8636230` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 137 | 41 | 226 | KILLED / KILLED | cb09fb3a;ecadc4b5 | yes | `19c679ad`, `48829637`, `7600bbbe`, `78b86d64`, `9c806eed`, `9ce2e468`, `c944bcf6`, `d0a70d9b`, `eda193ad` | +| 147 | `b8d57f3a8cfa01752a4a2f21d17c7dd2df0e3f981a5fd0da4f15484c53cc301a` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateDecisionKind` | `NegateConditionalsMutator` | negated conditional | 82 | 11 | 41 | KILLED / KILLED | ecadc4b5 | yes | `515f8f4c`, `bcbc8d85` | +| 148 | `9d9e99ebd21cb86472bf128efbb919be11d36784987c38acb14179e5bc118e95` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateSourceComponent` | `NegateConditionalsMutator` | negated conditional | 94 | 11 | 41 | KILLED / KILLED | ecadc4b5 | yes | `0fe72584`, `e0f8f364` | +| 149 | `ca0bada2431c7ecdbd1a9ffb4c012addb0e37423e5f7aacad00d26ba2c70f9de` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 118 | 13 | 45 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 150 | `abcb69cf55a048b99298691762a51399a8baeb1eb5d3837bd35d51d9fd41d98f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 119 | 16 | 55 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 151 | `477068444b350a2e226b5084e9645910308be0e4fa0bc6659952bd90f8abe60c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 127 | 25 | 92 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 152 | `4288c9ca4e8d2eb4d9c48c96adac8d0f500bc787c2b283db6bb04dc9bba4e09f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 127 | 28 | 99 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 153 | `aff15f3550cd791ef2acbd1d2212086a16c88909fb625420e1e097208d895020` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 127 | 31 | 106 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 154 | `7165c3227c30f421f2a6c21d97fa9a75c3ff5762aa1d678ff18176daba836bf9` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 130 | 35 | 120 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 155 | `75833d2d554ecf815439ce2b859ac1edef77e21ad3437f29aef612ccb184ae1b` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 131 | 38 | 128 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 156 | `4b373fdebbcea1fb830db688fa8e6bdcbeb0b40618e3eaef1d254de9f64e44bd` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 136 | 44 | 156 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 157 | `7d907ad74364b799b4f4e25a08dabea840396230a76c160859c34163a0781df1` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 137 | 47 | 164 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 158 | `26e44096428c398823df0c11ce34b731f450bb3200975bf8b06e88d4470d3790` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 142 | 53 | 192 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 159 | `9da71de67474488109161790ab792973e3422d1bf5dfec8f2b3f1fd665470769` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 144 | 57 | 204 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `0bf3201b`, `379f2e6d`, `58751c78`, `94c1bc29` | +| 160 | `53697b2d077eb7f8a97e57a484ecc2aab4d1d3e1d9906fa0d2fdd45a8a5125c0` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 144 | 55 | 201 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 161 | `8e228ad7ea2de8b0d228514e80bb75751d4035a62b95b3a22c007c50c15be08c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 144 | 57 | 204 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 162 | `ac83ce2e9393996022e1ee3893d986230d9958ce30681a9da19cc56eaa955b98` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 144 | 60 | 212 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 163 | `7dda948fc68f908e39fa404895f1ecc40db43eb61934a37db5b64c3e686518f3` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 149 | 66 | 240 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 164 | `12dd45b419d9cea7f66740220df91700c04dd5b7bb6ef7364d059431912684e0` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 151 | 70 | 252 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `0bf3201b`, `379f2e6d`, `58751c78`, `94c1bc29` | +| 165 | `fafed97d42fd45c1506cefc9366a245205388b7a0c6161be0d4ca8b936a6168a` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 151 | 68 | 249 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 166 | `46184b1a9fabe9897daaf9a97aa03a59913d34294700f705589e3b02c20a48bf` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 151 | 70 | 252 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 167 | `d9c41d45bb176b283d0d39baf8894ae8ff967188553fd30d841fbc631e5f9a31` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 151 | 73 | 260 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 168 | `e0f8eeb27938e0d7af7efedf900468efd48365d9112db42738502eaea8e29306` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 157 | 81 | 320 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 169 | `b02d448cd873c73cf74b6b5390f54e564adab46fe09a5adc7300526bd2dad1f4` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 162 | 85 | 336 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 170 | `51abee92922e3ad7b7f87997cc2a5f853e05d0e78f6ae88e98c183dc50e4337c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 163 | 88 | 344 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 171 | `55e523c4e3033a325d53843dbdbd94f28912d108701154a7d0f6b7dae032b8ef` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 169 | 94 | 373 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 172 | `03fe978066fab6ab5d9bb78942e45cb880aa526340e0cebb71fe9bfed6d368b3` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 171 | 98 | 385 | KILLED / KILLED | ecadc4b5 | yes | `0bf3201b`, `379f2e6d`, `58751c78`, `94c1bc29` | +| 173 | `8c5fcb2064c4f0ea482a072ba706a3ea9b5be3a6b0f9eea40d88d201c2274ae0` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 171 | 96 | 382 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 174 | `e6c50edec0cc1af56b952f0ad029ee551cce5ab99993023ddab63cad67ece9d5` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 171 | 98 | 385 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 175 | `da7de7326bbe60e83053559e8084d634ba34393609fdff1d3172db8ff03804f9` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 171 | 101 | 393 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 176 | `14e6955eae1ddeab6d7d79a008799fdaadc581ca57ac4b4ec1377e7325fdd495` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 176 | 107 | 421 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 177 | `a9745066815e3b7cf055ffa8924ae4afb7faf61f59ccf2ec1085c8fe28e71b2b` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 178 | 111 | 433 | SURVIVED / NON_KILLED | ecadc4b5 | yes | `0bf3201b`, `379f2e6d`, `58751c78`, `94c1bc29` | +| 178 | `4143d82d74783fd4a1a85977e537dd881dbccc99af532924bcff5c9def7d618c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 178 | 109 | 430 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 179 | `6a5baa99db06765b80b13d14473214bb35a51ab808672dd9f26ffd797d9f91ef` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 178 | 111 | 433 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 180 | `1082270f835b3e30e90bbf29f7d5e2d3a6bccbe9b00cb762f166b87d820cf73f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 178 | 114 | 441 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 181 | `b2527301e99ae9ec14f83a75f174fa18ede119c136c8d125e32fee05b9609dea` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 183 | 120 | 469 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 182 | `d8540bd51f41b9f72ba4e0f27779a3440109b55a5d72b8e3505aec99dd47aa34` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 184 | 122 | 475 | KILLED / KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 183 | `0cfa472a9557aa62c1b1c1b54d9932cbfb05700b1e908e26c8a5f4f43bf906fc` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 191 | 128 | 507 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 184 | `d3d159737c1734ff7859ff865f2fbf00f5991c62aba4cba08fe69bc7b928dfab` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 191 | 130 | 510 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 185 | `0745e3647ebf1461f19be9d959396415cc71379a9481ff83431539fe4a3220a6` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 191 | 133 | 521 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 186 | `fa4e11a4ecb4ae8c1654cf8e53cb65e0045af41ff5bc62e6074dc9cfa355ea1d` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 191 | 136 | 529 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 187 | `b1e44ec991236dfb2e879ad8d510c923e493e5d8da377fcae62907fa2cb688e7` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 195 | 144 | 559 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 188 | `4e9834c5b54021afc27e8a86417cc3404f0bcf8d144ce757e99ce93cdef06366` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 200 | 151 | 590 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | +| 189 | `86e1f7ed67ea39312b9fae991a44369f62f4e1181c29aecf8796b82ab1139d68` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 201 | 153 | 596 | NO_COVERAGE / NON_KILLED | ecadc4b5 | yes | `070a8425`, `1016c93d`, `172c8775`, `22302136`, `2c5adc16`, `2de631d9`, `33ea83e5`, `3b9dad85`, `3f0bf1f3`, `3ffdb570`, `475fe20e`, `4d3caa9b`, `4f5a7008`, `4fafed96`, `5202e2a2`, `551fffd9`, `565b38ef`, `5b7a03ec`, `6c077df4`, `6dc5007a`, `79f55fa1`, `7b00ab5a`, `7e8d23be`, `7ea4d18e`, `89902c8d`, `9bc111bc`, `9c550417`, `aff4fff0`, `be49c6cd`, `c36b33ad`, `c8b2042a`, `cc8bf2a4`, `de500eee`, `e3d83f94`, `ee32fd4f`, `f0cb765d`, `f42105b2`, `fbb04e20` | + + +## Fresh-only population + + +349 identities exist in the fresh measurement and not in the committed authority. They are not automatically defects; they are recorded here for the later 0.7.1g proof slice. + +Grouped by family / module / raw status: + +| Family | Module | Raw status | Count | +|---|---|---|---| +| approval | `:tramai-engine` | KILLED | 114 | +| approval | `:tramai-engine` | NO_COVERAGE | 101 | +| approval | `:tramai-engine` | SURVIVED | 35 | +| approval | `:tramai-engine` | TIMED_OUT | 46 | +| evidence | `:tramai-security` | KILLED | 40 | +| evidence | `:tramai-security` | NO_COVERAGE | 8 | +| evidence | `:tramai-security` | SURVIVED | 5 | + +Grouped by canonical outcome: KILLED=154, NON_KILLED=195 + +Provenance (which merged change introduced the mutation point): + +| class | fresh-only mutants | source file | existed at baseline | introducing / changing commits since baseline | +|---|---|---|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 91 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalResumeCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 77 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/DefaultApprovalGateway.kt` | yes | cb09fb3a feat(0.7.1d1): governed identity carriage through the preview approval gateway (P3, engine half); ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 74 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | 44 | `tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalRunAttributionKt` | 20 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalRunAttribution.kt` | **no (new file)** | e4c186aa feat(sovereign-ops): carry governed identity through the transactional approval path; eb860d1f feat(0.7.1d1): reserved approval attribution codec (P1 of #418) | +| `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | 9 | `tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceAttribution.kt` | **no (new file)** | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | 6 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedContinuationContinuity.kt` | **no (new file)** | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | 5 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | 5 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalResumeCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | 5 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | 5 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.GovernedApprovalStoreKt` | 4 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedApprovalStore.kt` | **no (new file)** | a8c6131c feat(0.7.1d1): governed approval attribution on the file-backed store (P2, file); 2b0ace48 feat(0.7.1d1): governed approval attribution on the durable approval row (P2, JDBC) | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$GovernedSuspension` | 2 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | +| `dev.tramai.engine.approval.ApprovalAttributionKeys` | 1 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalRunAttribution.kt` | **no (new file)** | e4c186aa feat(sovereign-ops): carry governed identity through the transactional approval path; eb860d1f feat(0.7.1d1): reserved approval attribution codec (P1 of #418) | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$ResumePreparation` | 1 | `tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalResumeCoordinator.kt` | yes | ecadc4b5 0.7.1d: governed run attribution & identity continuity (#407) | + + +**Fresh-only identities that are NOT killed (195)** — recorded for the 0.7.1g proof slice, not repaired here: + +- SURVIVED: 40 +- NO_COVERAGE: 109 +- TIMED_OUT: 46 + +| Class | SURVIVED | NO_COVERAGE | TIMED_OUT | +|---|---|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 4 | 44 | 19 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | 1 | 2 | 1 | +| `dev.tramai.engine.approval.ApprovalRunAttributionKt` | 2 | 0 | 0 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 11 | 23 | 10 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | 2 | 2 | 1 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | 2 | 2 | 1 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | 2 | 2 | 1 | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 7 | 26 | 13 | +| `dev.tramai.engine.approval.GovernedApprovalStoreKt` | 3 | 0 | 0 | +| `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | 1 | 0 | 0 | +| `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | 1 | 0 | 0 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | 4 | 8 | 0 | + +### Complete fresh-only identity inventory + + +| # | identity | family | module | class | method | mutator | description | line | block | index | status/outcome | +|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | `4ff5292b3d07c4884cea3dca762af279d1ddd06f77c5a94e3f5c4233d377aa62` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalAttributionKeys` | `getALL` | `EmptyObjectReturnValsMutator` | replaced return value with Collections.emptyList for dev/tramai/engine/approval/ApprovalAttributionKeys::getALL | 32 | 0 | 4 | KILLED / KILLED | +| 2 | `0985c4612a4d4131fa73a0d810d2e6015ef19718cd1bfcd3f7164a1f5449598a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to dev/tramai/engine/approval/ReplayAuthorizationService::emitAuthorizationReplayed | 67 | 20 | 127 | KILLED / KILLED | +| 3 | `6ec884e732291c50e925116eb111e0e9b969f3d36260963b4067700002b157ae` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 79 | 110 | 759 | KILLED / KILLED | +| 4 | `4a64ba00a6f4869ae247a244cb37659fea1dda58eb86ddd84f0dc635638bb142` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 80 | 36 | 227 | KILLED / KILLED | +| 5 | `7b2a31152d779794c31d867ba54c7bc184b7c04fc8808c5f92a02e7587515488` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 113 | 96 | 662 | KILLED / KILLED | +| 6 | `8755e767cdb977a2caa18790575830ab891985b615779e630564310d9be81a46` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 122 | 15 | 94 | KILLED / KILLED | +| 7 | `397a17892f3ee1de90752e586982fda8c08f29dc572feb8ae3b612619f7e7847` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 122 | 17 | 98 | KILLED / KILLED | +| 8 | `84a24307a542aedc17fc0d87a91bebac6f685daa8bad081160b9c962af7be339` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 126 | 28 | 160 | KILLED / KILLED | +| 9 | `5e6fbb7de1aa99117e968dfbc3bab86b46809d81aa784f1ba13b1c05c1606d93` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 130 | 32 | 183 | KILLED / KILLED | +| 10 | `01063c9a48c58a603826c50ad15171a04fa47d94c9682c45936534e77fbde1ad` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 131 | 35 | 196 | KILLED / KILLED | +| 11 | `677df342a07260739c261e65e20f952d730e04e537e2949d612dd44350a0e39b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to dev/tramai/engine/approval/GovernedContinuationContinuityKt::requireGovernedContinuity | 132 | 48 | 289 | KILLED / KILLED | +| 12 | `6a94d34e08315d48d307968c4e60fd8acebae708ecb953ac21bf6748c025358d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 141 | 66 | 485 | KILLED / KILLED | +| 13 | `b5d40f6ddc9a4aef2fe29a0b942b4a9103d62ba7816bd06e8a9daf9671b6b90b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 150 | 15 | 97 | KILLED / KILLED | +| 14 | `cce1add95d18d90de7269c654336c241f5e34a6cd04d984a6f5b7d43508f9088` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 154 | 30 | 166 | KILLED / KILLED | +| 15 | `bd656eae4b307cd1ca26bf3bd09c2013dd7108dd1c7db293a0e942d72b858f9f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 158 | 43 | 231 | KILLED / KILLED | +| 16 | `e3f301baaa6c868c2047000649e4256829283f76e169bf5623199c989181bf79` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 162 | 57 | 297 | KILLED / KILLED | +| 17 | `d4e39f4ec53d90b040f1da0d63e5d6979ed62c8fe2ba079a18ead285c6018c53` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 209 | 58 | 507 | KILLED / KILLED | +| 18 | `1313dad69d7fd83977a61fd429530b006ab06c75d7aaa270adf41a3355924376` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NegateConditionalsMutator` | negated conditional | 222 | 23 | 131 | KILLED / KILLED | +| 19 | `2117cb76985824d3da096103c380f996bfcab41d1f371dd88d7075ac3d234d43` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::revealAndValidateReplayPayload | 226 | 37 | 223 | KILLED / KILLED | +| 20 | `30b1b3b7cd40dacc349c5cc41b0904ada42c1d359fb5d4bf9461af9486452270` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 358 | 9 | 35 | KILLED / KILLED | +| 21 | `c7018b511c68f413a1ec734ad5b83c71a26ef3849a408aa5d57217129d850955` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 361 | 17 | 59 | KILLED / KILLED | +| 22 | `01904beec657b2de30d452b59e49d37226a6844a566448a5cc5a283449b23770` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 362 | 25 | 85 | KILLED / KILLED | +| 23 | `b075932bef449ae30f0eff97e79b0977259eba7507addbaca24f13d3126e498d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 363 | 32 | 110 | KILLED / KILLED | +| 24 | `174b91ba7128aae0fa68665add6c74ffe5bc3de58c9433351c5c6bf6b0c873be` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NegateConditionalsMutator` | negated conditional | 364 | 39 | 135 | KILLED / KILLED | +| 25 | `bb6e32ec5fc3c2b1886889da57c683d594836124e7ea316fe5322cbb1342e0d1` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resolveAndValidateResumeTool` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resolveAndValidateResumeTool | 365 | 43 | 156 | KILLED / KILLED | +| 26 | `ceba69cd43f170d2b3eebe5b7e0e74e3503eab3e80ceb4eed1a7f91659d3e64e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 11 | 71 | NO_COVERAGE / NON_KILLED | +| 27 | `441d33435645798ccbb4599430cd3cd44ba0429f94eff009d3ec2bdd00d893d0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 16 | 112 | NO_COVERAGE / NON_KILLED | +| 28 | `ece7d1190f97b351afdc5e431e888fd7e24aabf29ca1ab41d356e6dd490aa787` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 28 | 177 | NO_COVERAGE / NON_KILLED | +| 29 | `92b019e1bae11d828ffdc6b850e818f5b5986b7a078a2c29defba79bc0a39791` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 44 | 303 | NO_COVERAGE / NON_KILLED | +| 30 | `5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 56 | 395 | NO_COVERAGE / NON_KILLED | +| 31 | `ef13cbec0d70fb8ec493cc6ed0709275f5ea9d9d73b2a661ef9c4d782f531af3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 69 | 501 | NO_COVERAGE / NON_KILLED | +| 32 | `ecb1e83af59393d1b5232a58ff15d691a148b163c854160584245a09ca6b31a5` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 87 | 617 | NO_COVERAGE / NON_KILLED | +| 33 | `9026b993fd997eb85a94ed6bde3b111ec4e411a59c6362b6fb0af2e99bca27bf` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 64 | 107 | 746 | NO_COVERAGE / NON_KILLED | +| 34 | `d3f812e883d41cbb8c11e3225d66974589a48842fafdd8a4f9fa41ee8008dcca` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 10 | 63 | NO_COVERAGE / NON_KILLED | +| 35 | `18ac9e9b9ded332a5d862841a5db3d9f650201fc4c749f56b11bdebe1657289d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 15 | 99 | NO_COVERAGE / NON_KILLED | +| 36 | `7cd98a90304d7964683c92622c269f113cd690e65473f8153133d8cfc08fe189` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 27 | 159 | NO_COVERAGE / NON_KILLED | +| 37 | `f0effc0ace61dc60a5f7c3950d67dd8d9dd453fe9f848d569d8f1ddd6d962625` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 43 | 268 | NO_COVERAGE / NON_KILLED | +| 38 | `13586a6301d3fa8dbe85981352dc08c0616e953c4f285810b48f4fb05904fe31` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 55 | 360 | NO_COVERAGE / NON_KILLED | +| 39 | `2d5928356b93e422214cdfb78956b5392c45e52e94cb45b91d1216ea59a40eb7` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 68 | 463 | NO_COVERAGE / NON_KILLED | +| 40 | `5170395637241867d2d38413ee0348f1030fdccb80617e0bb33b2001345f59f0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 86 | 579 | NO_COVERAGE / NON_KILLED | +| 41 | `d20dbac385cb8d5a09e313541a2dcf177b7c0f390c6ae0cf9fafe1a5c072bd02` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 64 | 106 | 708 | NO_COVERAGE / NON_KILLED | +| 42 | `f20df64ded8b98458fc68d8583afd170ebbec2fa33a9ed0db1cc349b80225d6d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 119 | 13 | 85 | NO_COVERAGE / NON_KILLED | +| 43 | `b545c2ae147a0ed5c6e743c446f179d2998dea15a0b781fd9131aa3ae18799fd` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 119 | 26 | 153 | NO_COVERAGE / NON_KILLED | +| 44 | `8510790e9203bf585125a30cf5178e0002d129f7580ddc36fd928ed8a9ea1ad5` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 119 | 41 | 251 | NO_COVERAGE / NON_KILLED | +| 45 | `613bf2d479166ff51b50e27060a2442f69fa2d20a9c103b75c5147e8264d3f7f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 119 | 55 | 369 | NO_COVERAGE / NON_KILLED | +| 46 | `2f92ef797c538ab858fdeca1c5a464a8bba2c633cf74bad2fac99d756f42ca65` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 119 | 63 | 470 | NO_COVERAGE / NON_KILLED | +| 47 | `f8bdda993a7ca9073a0fdc3ad08b52203dc497be50d67c150e7ee094066f5ece` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 119 | 12 | 72 | NO_COVERAGE / NON_KILLED | +| 48 | `8cdddbe0775a84fda14fe7bb987e3d4caff65dc0c20934acd8abf538a07afb41` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 119 | 25 | 135 | NO_COVERAGE / NON_KILLED | +| 49 | `4cca31fe707f13c869b7fa55a507661638e60a62cdb13f1b7cf5cb3899e023ae` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 119 | 40 | 228 | NO_COVERAGE / NON_KILLED | +| 50 | `e2f022edd78a3f2e65a2c14eda3793ca0fa99762aa24513a623d3c1b23cb823a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 119 | 54 | 336 | NO_COVERAGE / NON_KILLED | +| 51 | `39f37feb8af8811303e70d73fea29138e2cc3c866330e3d1f5371e94239a92e8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 119 | 62 | 427 | NO_COVERAGE / NON_KILLED | +| 52 | `eacdfd8c8a0f505292702876fa5450fdd86babd28931cb8600a5894ef07843e3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 144 | 13 | 83 | NO_COVERAGE / NON_KILLED | +| 53 | `9516ca2fa7750c16a31995eaa14e2f651d4e5798089a11f5053e01d141591f0d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 144 | 25 | 150 | NO_COVERAGE / NON_KILLED | +| 54 | `b1d24bc23a862911a495013126f2713cf5443baeee820ee8da3f66f81a54c645` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 144 | 39 | 216 | NO_COVERAGE / NON_KILLED | +| 55 | `63e5372ba37bc3b097def60c22bddd050404d168b26c2fa73bc94a99a6705c1e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 144 | 55 | 292 | NO_COVERAGE / NON_KILLED | +| 56 | `2d1f9e373009b20d4462805571e0e9435e599916a76d4bc78d3d2ccd6525f9b2` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 144 | 12 | 71 | NO_COVERAGE / NON_KILLED | +| 57 | `64b71d7ed973b9d0863a93fa15b70e0b193f58ccdfc48b2eea575f34f61956be` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 144 | 24 | 133 | NO_COVERAGE / NON_KILLED | +| 58 | `6759437252f37022e139ccaa2ca729226147768a1d7972fcd59317f185d965a4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 144 | 38 | 199 | NO_COVERAGE / NON_KILLED | +| 59 | `5cae2063c995e0938a94146e5e66b1b173eb4e205fac4339449f644c85deb22d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 144 | 54 | 275 | NO_COVERAGE / NON_KILLED | +| 60 | `b36fc3ca50a326d7924fe14e278a2c4902307649d2b15ad19aef66d7ab17ed7b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 151 | 23 | 129 | NO_COVERAGE / NON_KILLED | +| 61 | `4be603cfac01a955a396208293347fa218b4f0f364a6bea0e851e58e327678cc` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 155 | 37 | 195 | NO_COVERAGE / NON_KILLED | +| 62 | `577c07309b70f484aa98cc6b07eb93c132e82198f86397d69963a2a95399afef` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 174 | 21 | 202 | NO_COVERAGE / NON_KILLED | +| 63 | `021b3c457a5dcdc319f98c4c3154904799439c329a704355633fbbad00891eef` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 174 | 41 | 378 | NO_COVERAGE / NON_KILLED | +| 64 | `b280cd7e98348884d2ed52b40a25057122bf434410994caa0db380fb68f656a4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 174 | 56 | 500 | NO_COVERAGE / NON_KILLED | +| 65 | `ef7dc1d6b1d5c625fa2423bce4ce749fb00a5ee310efec28cf791f474c20ebac` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 174 | 20 | 171 | NO_COVERAGE / NON_KILLED | +| 66 | `04c94b48909a32fc160f6a6a6983f183656ff9dc7d4506faf675765d6df690eb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 174 | 40 | 327 | NO_COVERAGE / NON_KILLED | +| 67 | `4b1bdb9fc7c4170b0202d36c5ea3a35302209e4595ced39281591b45467fbc3e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 174 | 55 | 445 | NO_COVERAGE / NON_KILLED | +| 68 | `62911071da6c7a9fe85f76a41c5a23d56b1156e2d1447dd96ccce962aabeff3b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 212 | 33 | 205 | NO_COVERAGE / NON_KILLED | +| 69 | `20ca1a1d8d14f6b6be78f7f5d7170c216ff8f9e225bbff006c11104dca0b0411` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::revealAndValidateReplayPayload | 212 | 32 | 174 | NO_COVERAGE / NON_KILLED | +| 70 | `a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 102 | 76 | 533 | SURVIVED / NON_KILLED | +| 71 | `1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 108 | 91 | 641 | SURVIVED / NON_KILLED | +| 72 | `ab78ed8577ff2073f4690730365919999ad7f76ddb68bef5c56a08deeef15c31` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 119 | 7 | 44 | SURVIVED / NON_KILLED | +| 73 | `cb90dfa953b5b8f3e70f6fcad04d793fe9f952f4d49816dee8be9f3bebf7504d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 144 | 7 | 44 | SURVIVED / NON_KILLED | +| 74 | `4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 65 | 9 | 59 | TIMED_OUT / NON_KILLED | +| 75 | `611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 66 | 14 | 95 | TIMED_OUT / NON_KILLED | +| 76 | `8b4d579ff820c98f7f3dac5ee8c86eb942422cf1b8b192ec44992c1881695d39` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 68 | 26 | 155 | TIMED_OUT / NON_KILLED | +| 77 | `9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 81 | 42 | 264 | TIMED_OUT / NON_KILLED | +| 78 | `252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 85 | 54 | 356 | TIMED_OUT / NON_KILLED | +| 79 | `827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 90 | 67 | 459 | TIMED_OUT / NON_KILLED | +| 80 | `e78cb26e67aa7d640e9ff5e9e31d267e7aa84b1308357a7312c82c3342bb01cb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 98 | 85 | 575 | TIMED_OUT / NON_KILLED | +| 81 | `e118041e3a76ebc8af348ce6431c9cf24871161652481cfd5bdee100d0829894` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 109 | 105 | 704 | TIMED_OUT / NON_KILLED | +| 82 | `9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 121 | 11 | 68 | TIMED_OUT / NON_KILLED | +| 83 | `062ef5efb7ee873d74da6c82da726c85406b3df1c9ae0040aa715081a88a3ef8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 126 | 24 | 131 | TIMED_OUT / NON_KILLED | +| 84 | `4e3ec3205822664846f9e727b232647706a9535829fc83bb7a55dbd4fd2baf4b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 131 | 39 | 224 | TIMED_OUT / NON_KILLED | +| 85 | `c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 138 | 53 | 332 | TIMED_OUT / NON_KILLED | +| 86 | `a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 140 | 61 | 423 | TIMED_OUT / NON_KILLED | +| 87 | `f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 148 | 11 | 67 | TIMED_OUT / NON_KILLED | +| 88 | `0df4e1e91cdadf04e0f755db04b79fb465972d0f446ffbc9944aab98a8c53512` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 162 | 53 | 271 | TIMED_OUT / NON_KILLED | +| 89 | `5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 183 | 19 | 167 | TIMED_OUT / NON_KILLED | +| 90 | `f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 196 | 39 | 323 | TIMED_OUT / NON_KILLED | +| 91 | `6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 208 | 54 | 441 | TIMED_OUT / NON_KILLED | +| 92 | `10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NegateConditionalsMutator` | negated conditional | 223 | 31 | 170 | TIMED_OUT / NON_KILLED | +| 93 | `ab656bd0ffc59ac3c3fe8a024e8a15bc01796fa04073304b3ee1bb8f390f5769` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$ResumePreparation` | `getPersistedGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation::getPersistedGoverned | 168 | 0 | 5 | KILLED / KILLED | +| 94 | `c74b67afeffa669791db54ba83246a02c2bf71e7d7353b6f06057be5da32ff7d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator$resume$2::invokeSuspend | 86 | 9 | 45 | KILLED / KILLED | +| 95 | `492f9a132e141f512c5690d8b0d43c6e5925b488de13d80ef71504bcf61b62d4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 85 | 7 | 40 | NO_COVERAGE / NON_KILLED | +| 96 | `09642639c5ec0e9791214208bb1d953a203adfc7ba5bd8fde1ef175d780df17f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator$resume$2::invokeSuspend | 85 | 6 | 36 | NO_COVERAGE / NON_KILLED | +| 97 | `e0ae69141eabff4855f7acc9e498139795c71ae3aca5a096d468101f3ceca1f0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 85 | 2 | 12 | SURVIVED / NON_KILLED | +| 98 | `09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 86 | 5 | 32 | TIMED_OUT / NON_KILLED | +| 99 | `f522256dd7634e01a37b79d6fe9e4e54e78f9ea1ea24784dcfc75824bb15fd83` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `encodeApprovalAttribution` | `EmptyObjectReturnValsMutator` | replaced return value with Collections.emptyMap for dev/tramai/engine/approval/ApprovalRunAttributionKt::encodeApprovalAttribution | 70 | 20 | 77 | KILLED / KILLED | +| 100 | `715d6540e3f7c7e2880797696ed90253e29138472db10ce34ab43545bc409ca8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 95 | 14 | 79 | KILLED / KILLED | +| 101 | `970f44775706ff5a28ea4e7688d19886ee159d871b254fb8edde025b53009862` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 100 | 19 | 98 | KILLED / KILLED | +| 102 | `dcc6ad78906476b5fe8853bab515dfcf7a76ce00da0bdbf479ae6560550a6090` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 104 | 36 | 174 | KILLED / KILLED | +| 103 | `60f65e9dbbd84cd1934db0ace9331708d6b4c7ab885d86da8bbd4329116cfbf1` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 112 | 39 | 191 | KILLED / KILLED | +| 104 | `d6e0f8cb3d420e6032320f038419575ea3fdb3dc473de0370f50c9466a784d15` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `mergeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 156 | 15 | 81 | KILLED / KILLED | +| 105 | `52df81e1e81746632012c2a05b00f85384ffb97adde27708d2258d4f33c8ca86` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `mergeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 156 | 18 | 90 | KILLED / KILLED | +| 106 | `73a6aee4169469bf4f402ac87b3a2bcd110cb4aa180bcb578ef186586a8fa34d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `mergeApprovalAttribution` | `EmptyObjectReturnValsMutator` | replaced return value with Collections.emptyMap for dev/tramai/engine/approval/ApprovalRunAttributionKt::mergeApprovalAttribution | 162 | 33 | 150 | KILLED / KILLED | +| 107 | `e8fd1512b325a493c11f66c2ecb714bcb5c4c6cf5beb0cfc05ac1ef0b39857fb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `mergeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 163 | 24 | 125 | KILLED / KILLED | +| 108 | `6b4ab941fdd8e2557d7e0b1013bdd683375ef2cef1c66bdd6633f144a258d587` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `mergeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 164 | 26 | 133 | KILLED / KILLED | +| 109 | `8599902861fb3d6a739f034072acd3c205de563aaeda4432f844968d674ee656` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `component` | `NegateConditionalsMutator` | negated conditional | 169 | 1 | 8 | KILLED / KILLED | +| 110 | `08b513897d6b78ab8bab0ea44808a9206ba968802ec361db4b0339d84e3b73f4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `component` | `NegateConditionalsMutator` | negated conditional | 169 | 3 | 21 | KILLED / KILLED | +| 111 | `de7f8e227c3cae99e54984f1f1cb8f60789a23058ea6392a8a7db5e8a08ff8d5` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `component` | `NegateConditionalsMutator` | negated conditional | 169 | 6 | 33 | KILLED / KILLED | +| 112 | `cf058bda77c6f99a4069180ccb0ebde8088ae40140e142db9776bb8cd8541c93` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `component` | `NegateConditionalsMutator` | negated conditional | 169 | 10 | 48 | KILLED / KILLED | +| 113 | `86c3b7b1c194257fc084fa8775b43fc095b169989f194262ac5571b4dc1926ee` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `component` | `EmptyObjectReturnValsMutator` | replaced return value with "" for dev/tramai/engine/approval/ApprovalRunAttributionKt::component | 169 | 14 | 70 | KILLED / KILLED | +| 114 | `c15eee7b57ca02d4a48683cd1714adf14007251d6d03fe133466bfd8bf610f19` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 172 | 10 | 56 | KILLED / KILLED | +| 115 | `34cb98d303a97977878638a1163ad5819cffa574483e3cd73939dcddc74b19a0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 175 | 33 | 164 | KILLED / KILLED | +| 116 | `6951228f4a18e87d977c76de3d4c8248bb13816644e701c1b693e8f878df6e62` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `mergeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 179 | 11 | 57 | KILLED / KILLED | +| 117 | `8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 174 | 22 | 127 | SURVIVED / NON_KILLED | +| 118 | `d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 174 | 24 | 131 | SURVIVED / NON_KILLED | +| 119 | `ec57deeb04431a7dc78014c8b78dab0c44fe90a58ab009c6cfc3adcb5333d539` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 111 | 19 | 73 | KILLED / KILLED | +| 120 | `554be71f713bc4afd1eb8958e7913d49dac20ba091a63bdfa745857083c4c304` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 114 | 28 | 100 | KILLED / KILLED | +| 121 | `2a893a6a77dab5af98571dbbd59751c55311df8f3fcc9550d32ea3de629900ef` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 114 | 31 | 109 | KILLED / KILLED | +| 122 | `3be6c39483811afe8d93fccf94cf4b499d06e8d023b3cc28b186483290aabffc` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 114 | 35 | 116 | KILLED / KILLED | +| 123 | `affd482f279cbab1087b606c6a135b46313e1047348134c2c5224671ba75088b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 114 | 38 | 126 | KILLED / KILLED | +| 124 | `6a008b1f21355ae64e6e25eaa98a8e55d986f8b83e6bc3415b733fed81c76bd3` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 117 | 43 | 148 | KILLED / KILLED | +| 125 | `d4e55711d4e5596ff9f815ba527517c56443b4e2645e79ac689c9d9e67955cd8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `NegateConditionalsMutator` | negated conditional | 117 | 46 | 156 | KILLED / KILLED | +| 126 | `dd46e402176f8c8dd5e2911c2ab9f4349cd17f3bb2f26f9a811f7ded3a186656` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `ConditionalsBoundaryMutator` | changed conditional boundary | 133 | 19 | 71 | KILLED / KILLED | +| 127 | `dcf9c84eb25f35dca5d02e1b288f3b565cb9a188aae73b9d42da924881424380` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 133 | 19 | 71 | KILLED / KILLED | +| 128 | `6b2892e8c178e9e2a3097966b6dfd9ace9a186a174465461322887d51eb54b74` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 133 | 22 | 80 | KILLED / KILLED | +| 129 | `6eeabb6344fe5caa0ad1648a82f04b5fcfa6cc5f85daddf076bbdc90fa5f73c5` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 134 | 26 | 89 | KILLED / KILLED | +| 130 | `07a00f8caee1a2f9e1946addd48c0d19b46b0d1a376094d316839976aefdf544` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `ConditionalsBoundaryMutator` | changed conditional boundary | 138 | 31 | 111 | KILLED / KILLED | +| 131 | `165b7e85f51471c8b800da674b524249ac164f130211f1d1f302899aa18fbcb9` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 138 | 31 | 111 | KILLED / KILLED | +| 132 | `ce222ceb6c2af00ef3e0aa9f85f91a26e149b5acf2d6dfcc7bc93455f6d1ac1a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `NegateConditionalsMutator` | negated conditional | 138 | 34 | 119 | KILLED / KILLED | +| 133 | `79f0e7fb3744219f96c809e46ee446b290f420577573a24834360169f8abdc03` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateInitialApprovalRequirement-zjKNo-Q` | `EmptyObjectReturnValsMutator` | replaced return value with "" for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::validateInitialApprovalRequirement-zjKNo-Q | 139 | 38 | 140 | KILLED / KILLED | +| 134 | `2331c4f622c083c55732480a04c2046b02b91b8168422038cb2c38abd58c65cd` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 186 | 66 | 459 | KILLED / KILLED | +| 135 | `75b574f7fec6800d4388a860661c130a866076d2e3e1667cceecd51116c09e89` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 237 | 146 | 982 | KILLED / KILLED | +| 136 | `c88552200aa3b25441c912ac081135f7631ef2693db8476573e99570ffeaa691` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 238 | 147 | 987 | KILLED / KILLED | +| 137 | `8065d2ac8cdab4e34aded114d448f23ab8e32f87291235500cc60d8790c1d2a1` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `requireApprovalGateCoordinator` | `NegateConditionalsMutator` | negated conditional | 250 | 0 | 6 | KILLED / KILLED | +| 138 | `6c8f8bb15d260cdfda4c5bed3f9a036f2e47b457719d3e34d7fe0175b15a9b21` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `requireApprovalGateCoordinator` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::requireApprovalGateCoordinator | 251 | 3 | 20 | KILLED / KILLED | +| 139 | `f3ea5d26752f27413cdf765d81915e469f66bb57ecd02505a1630a4d755aff3d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `requireApprovalContinuationStore` | `NegateConditionalsMutator` | negated conditional | 254 | 0 | 6 | KILLED / KILLED | +| 140 | `7e059e64832f7d358b7c00c38c6d20114c080433097e64b5818d64a7203ba415` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `requireApprovalContinuationStore` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::requireApprovalContinuationStore | 255 | 3 | 20 | KILLED / KILLED | +| 141 | `fc69383c5923dc4e92fd0ca24431406be116110c76131964c4c067923dcfff64` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `resolveGovernedSuspension` | `NegateConditionalsMutator` | negated conditional | 259 | 2 | 17 | KILLED / KILLED | +| 142 | `c89c242bff8eb24801b6688a33bd01a5d9c2ee4d000c27559bfd35755b4da159` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `resolveGovernedSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::resolveGovernedSuspension | 260 | 4 | 26 | KILLED / KILLED | +| 143 | `654ee44743e5f9cde7e1c262b7b6ad59389068aaa22f76a1a93ef78096437c99` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `resolveGovernedSuspension` | `NegateConditionalsMutator` | negated conditional | 262 | 5 | 39 | KILLED / KILLED | +| 144 | `f9df56f7fe4a6b360c2f77d2482413973d03f3184f110fedb76471b16a27dfb8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `resolveGovernedSuspension` | `NegateConditionalsMutator` | negated conditional | 262 | 8 | 49 | KILLED / KILLED | +| 145 | `f3dc9f1b12b6bb5108fc7c80235709967cb31d78939d64fd2f1612b02f025425` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `resolveGovernedSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::resolveGovernedSuspension | 268 | 15 | 82 | KILLED / KILLED | +| 146 | `3ce235a3b869634e3120fa7d289bee1d4ee6065d0627c25aa716e415f7d2b07d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NegateConditionalsMutator` | negated conditional | 278 | 2 | 14 | KILLED / KILLED | +| 147 | `5ed3f9c06fd4f5d6507b9452daa8e54cd8b2591be5199369f646dc6b720a6809` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NegateConditionalsMutator` | negated conditional | 278 | 3 | 16 | KILLED / KILLED | +| 148 | `12a7469e5798d2c2289b976ae66678c4289ef36b19f69fa94bd98682c42397fb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NegateConditionalsMutator` | negated conditional | 292 | 8 | 49 | KILLED / KILLED | +| 149 | `564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 142 | 13 | 96 | NO_COVERAGE / NON_KILLED | +| 150 | `f1a19c075b2903dca52266897ef06706740455acdeed3a843dbe51de24236213` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 142 | 37 | 252 | NO_COVERAGE / NON_KILLED | +| 151 | `62897c202ffe147b902f030a11767832f6d273bbb92f9276db1e5d956e1662a6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 142 | 62 | 441 | NO_COVERAGE / NON_KILLED | +| 152 | `87fbd8e6ed4176baf39df68250f43fbabc8082b0407e4aac1f2b95953ca27782` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 142 | 111 | 751 | NO_COVERAGE / NON_KILLED | +| 153 | `04c2e1c3f1b8d7c2e9a6e9aa61c87d699cb90b432de6729730f87d302357cb28` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 142 | 134 | 936 | NO_COVERAGE / NON_KILLED | +| 154 | `30f9bb41fa82566d6965603b16558d9b8c3943309ddb3240ba6462fca25d7173` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 142 | 159 | 1118 | NO_COVERAGE / NON_KILLED | +| 155 | `66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 142 | 12 | 78 | NO_COVERAGE / NON_KILLED | +| 156 | `1843d3a31bd38b547f6615d450d411c3a1810340d4160797d653db7efd1e734a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 142 | 36 | 210 | NO_COVERAGE / NON_KILLED | +| 157 | `c2be62debe23ffe52f88f679c18778a3822f08312f112a698e418884d7c9a770` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 142 | 61 | 392 | NO_COVERAGE / NON_KILLED | +| 158 | `4f99ba33ee70bdc6f023171d803dc5438e88cb16170b27516865e56fb355cbb4` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 142 | 110 | 672 | NO_COVERAGE / NON_KILLED | +| 159 | `3b12d1a55c6b59c5906de2128d72e8df33f8df56ba26c33d432312e2e012a64a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 142 | 133 | 857 | NO_COVERAGE / NON_KILLED | +| 160 | `453e4aaf232b4d80c83653952228658084fe9e3cb56fb91b49071b115aaaa45b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 142 | 158 | 1071 | NO_COVERAGE / NON_KILLED | +| 161 | `b4bde9e3e32ec4786c9201e8ed557700e977626dd65a2e5a82893cbc2ca402bc` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 280 | 8 | 31 | NO_COVERAGE / NON_KILLED | +| 162 | `ec0280ffb25881b65754a45adb048e4e28f52996812c287c268d8b5cb1c74be0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 282 | 13 | 51 | NO_COVERAGE / NON_KILLED | +| 163 | `1b8938aaabc6e8c69890f8c7ac50b50a6d0e01f2bcb3c3d82049d3bba33735b2` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 286 | 14 | 122 | NO_COVERAGE / NON_KILLED | +| 164 | `887313eb0142a0add13da39b7d198c8bcc351ac8cd2dd1639028d47d7a2fd38e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 286 | 22 | 196 | NO_COVERAGE / NON_KILLED | +| 165 | `4fc586ff85c68cfdeca566abb58fcfc7986945efb5540844990d16a75ed127bb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 286 | 32 | 271 | NO_COVERAGE / NON_KILLED | +| 166 | `10ee55f449883c987fd7019d46d5d32380e072a8b669f78aa614058c6123864f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 286 | 13 | 94 | NO_COVERAGE / NON_KILLED | +| 167 | `e09a3c1cc38bcd7ec2214ce9aea881a3b1ffa38ba92f49e8e912e8259d0c2a66` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 286 | 21 | 168 | NO_COVERAGE / NON_KILLED | +| 168 | `af8cf7ad147d631f613f8f05cbd1a3d3ab2987fbcfb9a0b576fa5399b61639b6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 286 | 31 | 243 | NO_COVERAGE / NON_KILLED | +| 169 | `38af093087fd0acbd846859277155e9155f29f59b79c59dd9a0c8d6bf86c6355` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 299 | 12 | 77 | NO_COVERAGE / NON_KILLED | +| 170 | `95f4d3181b61682eafec19984f4bd5ebfac46cde52bacaa5a4d18f21814e78ee` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep | 299 | 11 | 67 | NO_COVERAGE / NON_KILLED | +| 171 | `c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 305 | 16 | 101 | NO_COVERAGE / NON_KILLED | +| 172 | `65f7e7cc271623407239ce9b2d935ac0a2b3ea438c87600ee86b54fa21f96425` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `ConditionalsBoundaryMutator` | changed conditional boundary | 117 | 43 | 148 | SURVIVED / NON_KILLED | +| 173 | `3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/jvm/internal/Intrinsics::checkNotNull | 160 | 31 | 168 | SURVIVED / NON_KILLED | +| 174 | `ac7940cfd29db06c06381a310e485889b22bdc156808208d2725d8d299e34027` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/jvm/internal/Intrinsics::checkNotNullExpressionValue | 176 | 54 | 319 | SURVIVED / NON_KILLED | +| 175 | `5d4ca8196b51364c881844ac42e5b155040463a57047cb308bafd4cda258af34` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NegateConditionalsMutator` | negated conditional | 280 | 7 | 30 | SURVIVED / NON_KILLED | +| 176 | `6dc734145518bb488ac0b6d202711c3d1b0c1d804489ed3d9b09453e75db96a1` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NegateConditionalsMutator` | negated conditional | 282 | 12 | 50 | SURVIVED / NON_KILLED | +| 177 | `eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 284 | 9 | 38 | SURVIVED / NON_KILLED | +| 178 | `c6784c2618c57ec3eaba846ee2dab13b696e32ebcfcb6a6312d7c550bfa23412` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 284 | 14 | 58 | SURVIVED / NON_KILLED | +| 179 | `f60db12bdb9ea3f1b61d5c01867a18b9ebe7f2a8726a8287f8129dad183a4625` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 286 | 7 | 44 | SURVIVED / NON_KILLED | +| 180 | `f56f28b7d039e3bf8f5790671b10d5202d38555f464e5270aa47c101445b082f` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 297 | 36 | 289 | SURVIVED / NON_KILLED | +| 181 | `60f7b020e009cc99a8a80e19e34a956e570614449294444db057993e433b1ac6` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 299 | 7 | 44 | SURVIVED / NON_KILLED | +| 182 | `3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep | 307 | 17 | 106 | SURVIVED / NON_KILLED | +| 183 | `6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 145 | 11 | 74 | TIMED_OUT / NON_KILLED | +| 184 | `c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 152 | 35 | 206 | TIMED_OUT / NON_KILLED | +| 185 | `45475202eecb348909e43024a9d2482a72f676b0c1ac6d42b48b482d23de3567` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 165 | 60 | 388 | TIMED_OUT / NON_KILLED | +| 186 | `43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 218 | 109 | 668 | TIMED_OUT / NON_KILLED | +| 187 | `16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 219 | 132 | 853 | TIMED_OUT / NON_KILLED | +| 188 | `ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 239 | 157 | 1067 | TIMED_OUT / NON_KILLED | +| 189 | `8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NegateConditionalsMutator` | negated conditional | 293 | 12 | 90 | TIMED_OUT / NON_KILLED | +| 190 | `3fa7e539cb12045632a1d4ed3c9ff9355785c024410c6896c2cd3882b6a0912a` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NegateConditionalsMutator` | negated conditional | 294 | 20 | 164 | TIMED_OUT / NON_KILLED | +| 191 | `7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NegateConditionalsMutator` | negated conditional | 295 | 30 | 239 | TIMED_OUT / NON_KILLED | +| 192 | `2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `NegateConditionalsMutator` | negated conditional | 301 | 10 | 63 | TIMED_OUT / NON_KILLED | +| 193 | `561f0b9e61fd17f293fe4ed73665d28b821266723d807df8694c9ece426957d8` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$GovernedSuspension` | `getIdentity` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension::getIdentity | 310 | 0 | 5 | KILLED / KILLED | +| 194 | `2b4ee52cf4177e49051754eca68668bc5bda13006784bea7bbc09f1dcebaee1e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$GovernedSuspension` | `getStore` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension::getStore | 311 | 0 | 5 | KILLED / KILLED | +| 195 | `ddf6092edbf61789c9cb31c2d9ce6d98a0a817dc56b587a269ac36125f2ef4d9` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 293 | 7 | 36 | NO_COVERAGE / NON_KILLED | +| 196 | `8abddce868884ec7da02fd97518d43894ab3009ec7433721ad4580505f09b4fb` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend | 293 | 6 | 32 | NO_COVERAGE / NON_KILLED | +| 197 | `ebc96b3e8599f671eb60c470b2a259a474aa994c2599547731b9944800e60b57` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 293 | 2 | 12 | SURVIVED / NON_KILLED | +| 198 | `f802165800d8737a082118c89e9c5eae4e7d8470cd15c54c57bc19476be9a15c` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend | 293 | 9 | 43 | SURVIVED / NON_KILLED | +| 199 | `8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 293 | 5 | 28 | TIMED_OUT / NON_KILLED | +| 200 | `1c4ca901fd8d56ace18e167d294242db6cfdd8a6f551c7860dfaa934ea26ce54` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 294 | 6 | 37 | NO_COVERAGE / NON_KILLED | +| 201 | `980c70e5ac83cda9c9fd96339e82d200255c2e12afe527b95f998666c1249806` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend | 294 | 5 | 33 | NO_COVERAGE / NON_KILLED | +| 202 | `6cef3fd2e23d3df1571d390564b8274596c70c97965bc5719603d39116b74b8c` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 294 | 2 | 12 | SURVIVED / NON_KILLED | +| 203 | `f175e8698db5459d85e11e56b04155c7b963d148d906f57908c6440dd533fead` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend | 294 | 8 | 44 | SURVIVED / NON_KILLED | +| 204 | `e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 294 | 4 | 29 | TIMED_OUT / NON_KILLED | +| 205 | `a15f9807befc68bd1e00747e77a09e147656195cac0833b68904cb326ccf0a5b` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 295 | 6 | 37 | NO_COVERAGE / NON_KILLED | +| 206 | `ceab2162714ae5159800ad6e1dde34ed859dd7afeb6e64f90f83f48206ea302e` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend | 295 | 5 | 33 | NO_COVERAGE / NON_KILLED | +| 207 | `eec481d97a434f85884bdc18877d8a54801c8ba289658e0fb49d92e286cad2f7` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 295 | 2 | 12 | SURVIVED / NON_KILLED | +| 208 | `70d345f72064e7cea4ebb9cff97f74946f00d582ba38a4e62c5498261494b0a0` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend | 295 | 8 | 44 | SURVIVED / NON_KILLED | +| 209 | `080c1a247bb10d6724a005dc6a718e8566dea770b07383aff5f3f04c0c6abdb5` | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 295 | 4 | 29 | TIMED_OUT / NON_KILLED | +| 210 | `cda68544ab8345f3d29251352dc026d3a299ff72a388b8aaadad84702ed50e19` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 87 | 13 | 101 | KILLED / KILLED | +| 211 | `4ead5386c74009baa6edbbd05ab4ef8deeffb7fdb9f974d2fd3b89cd36509ddf` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to dev/tramai/engine/approval/DefaultApprovalGateway::requireFactoryBindingMatches | 102 | 27 | 202 | KILLED / KILLED | +| 212 | `de0f389d31a2348665d82ea003fc3f6e4a70144087770f2b3db3761d8ac8a17d` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 104 | 91 | 611 | KILLED / KILLED | +| 213 | `0fff88666a318810f1265a44c70e89fe4f7a208d3ab61799b08f15b7db4f271a` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 106 | 39 | 288 | KILLED / KILLED | +| 214 | `ebf61136449e23919d797ab18277f2293d978c303db90bfca50795b65364bf94` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 108 | 51 | 381 | KILLED / KILLED | +| 215 | `8148b9d15853a631fd02e9528b853a8051cf2b69e4b77b58538f96eb0ac12318` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 111 | 52 | 386 | KILLED / KILLED | +| 216 | `24dce21b0c53fd67abfc79cd671c5cf64c7ef9da147e6b5bf70f16031af4a476` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `resolveGovernedIdentity-jFE5hGw` | `NegateConditionalsMutator` | negated conditional | 137 | 2 | 17 | KILLED / KILLED | +| 217 | `59325fc5d9faa6f2ab8469ff4964906d71672ac67affdac7ccd5a721c2c14be0` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `resolveGovernedIdentity-jFE5hGw` | `NegateConditionalsMutator` | negated conditional | 138 | 4 | 27 | KILLED / KILLED | +| 218 | `ac5d6aee36efbae7a459a6e2af6c9166ac1638764328babd16356db12db3df45` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `resolveGovernedIdentity-jFE5hGw` | `NegateConditionalsMutator` | negated conditional | 138 | 8 | 33 | KILLED / KILLED | +| 219 | `309a62ce4d02f41631c9966056de0bdc7fbc059cc33f6ae952745badc5996c11` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `resolveGovernedIdentity-jFE5hGw` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::resolveGovernedIdentity-jFE5hGw | 145 | 13 | 55 | KILLED / KILLED | +| 220 | `ac8b1236db6b6d6a218e8aa0f999ee9c69c8ecd36b69f4afc25561ec942c0c76` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireGovernedApprovalStore` | `NegateConditionalsMutator` | negated conditional | 154 | 0 | 8 | KILLED / KILLED | +| 221 | `4874ce7e420c567692e7cd8a629a2c196ca41ddcc10171bdb2a35af137d04c5d` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireGovernedApprovalStore` | `NegateConditionalsMutator` | negated conditional | 154 | 3 | 18 | KILLED / KILLED | +| 222 | `fac6a26f8c9b9a439c95191f8b699b17791fe8c10177f3be515878be9ba902cc` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireGovernedApprovalStore` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireGovernedApprovalStore | 159 | 9 | 42 | KILLED / KILLED | +| 223 | `c7e192654c2c329a055da7d25726ea46a74df2293c21c80c60eccadcf79f282e` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireGovernedSuspensionStore` | `NegateConditionalsMutator` | negated conditional | 162 | 0 | 8 | KILLED / KILLED | +| 224 | `c0cb3c21feb06dffdb8ae8f7494ac13c912ecd56426d2cc6e4cc10ae8ef179c5` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireGovernedSuspensionStore` | `NegateConditionalsMutator` | negated conditional | 162 | 3 | 18 | KILLED / KILLED | +| 225 | `07cb19dcbd4c14b016cc5f3c6a8a1b5a98649ee55f0bc583e54e32e0740b6875` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireGovernedSuspensionStore` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireGovernedSuspensionStore | 167 | 9 | 42 | KILLED / KILLED | +| 226 | `3354ed149a1aad2724a2cde2251702f44ac76f5cc4cb554a227655d58c5ade09` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `effectiveWorkflowRunId-fMes3AE` | `NegateConditionalsMutator` | negated conditional | 176 | 0 | 5 | KILLED / KILLED | +| 227 | `cf373ceedd5a1d313e339e49302c781c083e26a0a82d2c3cc4f541f7632cf50d` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `effectiveWorkflowRunId-fMes3AE` | `EmptyObjectReturnValsMutator` | replaced return value with "" for dev/tramai/engine/approval/DefaultApprovalGateway::effectiveWorkflowRunId-fMes3AE | 176 | 6 | 29 | KILLED / KILLED | +| 228 | `f364a42ee98a2e6f1f010e22ea80cd2806078d05307cb1fef695dd073d13b5f6` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireFactoryBindingMatches` | `NegateConditionalsMutator` | negated conditional | 182 | 0 | 4 | KILLED / KILLED | +| 229 | `1a00335770eb1cd13f1cf86480e4c4fa23fa8cc6a73a6df092e297220b35f491` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireFactoryBindingMatches` | `NegateConditionalsMutator` | negated conditional | 184 | 8 | 21 | KILLED / KILLED | +| 230 | `4dc9e05d77c9ab3beaa69b73fc879e31a4be52d1e0068490f474bb8a3aea3d5d` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NegateConditionalsMutator` | negated conditional | 205 | 8 | 48 | KILLED / KILLED | +| 231 | `e8a1c0445e432d9d7d684ca9a94755388bc321e1d122962261f2453946484834` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NegateConditionalsMutator` | negated conditional | 212 | 20 | 118 | KILLED / KILLED | +| 232 | `faf92c0f7da0fad331f776634977abf83309685b12c2ac86de990bca394ab7f9` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::toGatewayResult | 282 | 54 | 282 | KILLED / KILLED | +| 233 | `eda193adad4cd1fcf38b3b406b41a89e247ffcb68c5f217ad5adeaa15f662601` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 289 | 6 | 31 | KILLED / KILLED | +| 234 | `7600bbbe5e445685a007f2ea9d1fa967941a1e4ac4b4224824f13d0ef72cd5bd` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 293 | 11 | 57 | KILLED / KILLED | +| 235 | `d0a70d9b28edf8ee2f38781ab73d6ba0a343918edfa3115649b5a3f1f8330f6e` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 301 | 20 | 116 | KILLED / KILLED | +| 236 | `9ce2e4684da9a4fe96b32627edc1c9b16ff7cad7790a1a086c5653f7672ca2fb` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 307 | 22 | 127 | KILLED / KILLED | +| 237 | `9c806eed6a5e839c2d5c19cc29829512757d79b7cb3f0c3f899a32ee738b9b58` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 311 | 27 | 153 | KILLED / KILLED | +| 238 | `78b86d6402675e27e04edf4254eb35e2e81941f3ba2c9fd7e39b73066b17d10a` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 314 | 32 | 182 | KILLED / KILLED | +| 239 | `4882963721aab507ca904553cad5ad0f0598d07f98dcf9aa37e24338d35aac2f` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 319 | 38 | 223 | KILLED / KILLED | +| 240 | `c944bcf61656d770c4871ab7aec5bee74bf6a0d37d643d76f951e8e28a1a74c0` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `toGatewayResult` | `NegateConditionalsMutator` | negated conditional | 319 | 41 | 228 | KILLED / KILLED | +| 241 | `5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 73 | 11 | 92 | NO_COVERAGE / NON_KILLED | +| 242 | `6a33ba5d985ae4e67fc8f6f06b740880318078558c3973bf2c30f67c37a692c6` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 73 | 25 | 189 | NO_COVERAGE / NON_KILLED | +| 243 | `d77706fde05eb954a18854bdc62e90721dca630d7edd3b681c1ca599046c0eb9` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 73 | 37 | 279 | NO_COVERAGE / NON_KILLED | +| 244 | `8210e97a204f70645bc2df52a4af2859a972a5dd3cbd24400c32f61284c72dc8` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 73 | 48 | 369 | NO_COVERAGE / NON_KILLED | +| 245 | `6dcb09ca1cc8e2bb404aa0b4e8345aa185d10b00f019b81a268355d7c2ee83ef` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 73 | 62 | 467 | NO_COVERAGE / NON_KILLED | +| 246 | `8dc069d5a0f38543a188b36d41cc8d112c332188626cd816eb4a7c1c7cd28c82` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 73 | 74 | 556 | NO_COVERAGE / NON_KILLED | +| 247 | `e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 73 | 10 | 72 | NO_COVERAGE / NON_KILLED | +| 248 | `3883b3c127aed30401c7b826e0e5131aca1167c49145f1dc5e9706f319b4ed3e` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 73 | 24 | 164 | NO_COVERAGE / NON_KILLED | +| 249 | `8b52a478cb33035b2af3c59f099290ab6fa8c3812888c33b9893160ce0fa8d22` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 73 | 36 | 247 | NO_COVERAGE / NON_KILLED | +| 250 | `4dec4f43d11411f42c02624208840d47be3c1e1059e83d7505776e4ce4df70d8` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 73 | 47 | 332 | NO_COVERAGE / NON_KILLED | +| 251 | `5c2981ad80f33214fb0c6b720cfd3b854c42277f40cd86d2041ea4f36baf2b2b` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 73 | 61 | 430 | NO_COVERAGE / NON_KILLED | +| 252 | `862220d0ea233a543c5b0ddc516ea15f0d492b868cde8f97ceb44e7608067d36` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 73 | 73 | 519 | NO_COVERAGE / NON_KILLED | +| 253 | `5ac56e0a701ab6fcde19f86e640b34609b0ebeb64437e7ae185479282e7a6c7d` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 201 | 16 | 100 | NO_COVERAGE / NON_KILLED | +| 254 | `f5c807b43f9fe724ccbed38be0c2939820c115361446f18e236fa6e66fb46ac6` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches | 201 | 15 | 83 | NO_COVERAGE / NON_KILLED | +| 255 | `1b309f9a702a4e6ceecf930f61718bb260539696cfa516ce53ad1897291bb436` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 222 | 12 | 73 | NO_COVERAGE / NON_KILLED | +| 256 | `84224c763190937a1428a51988461c3f8669d81c7cb2e8476e35aa8e03b15cf9` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 222 | 19 | 113 | NO_COVERAGE / NON_KILLED | +| 257 | `15c7def4f5c98cbdea1a90b5b4fd44978a3d90d13c30d2beafccf83997250120` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 222 | 27 | 154 | NO_COVERAGE / NON_KILLED | +| 258 | `7b156f3a3c6265cf6e474c7001a4e6f06d54cbae3d042616889451e5629ba0a6` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 222 | 11 | 65 | NO_COVERAGE / NON_KILLED | +| 259 | `a4dbacf1299871940f52d31fa1bad147b674028106db00cf0d32545eafb93303` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 222 | 18 | 105 | NO_COVERAGE / NON_KILLED | +| 260 | `e8276b8096040f41b037f371b6ca328f710a43c2a1042e37532446a5dcd2be50` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 222 | 26 | 146 | NO_COVERAGE / NON_KILLED | +| 261 | `9c4c3454c7f772591a2a5b5f468c2d0f281ddbd5a5d8945aa0577a366d2be1a8` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 244 | 14 | 90 | NO_COVERAGE / NON_KILLED | +| 262 | `d8e1290f30007d7296e5509916277dde18d7bf84b9ed568cbebe927dd96a66cb` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 244 | 24 | 148 | NO_COVERAGE / NON_KILLED | +| 263 | `75ec22b0e703bb8a54ddbde9d6edab92e1e3fbbff3318021b9a6d11eccca21ec` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 244 | 33 | 197 | NO_COVERAGE / NON_KILLED | +| 264 | `1e85193edcd55874f3bdb58865290bcb1dc9353f0175bbe17ba1aced419ad902` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 244 | 13 | 78 | NO_COVERAGE / NON_KILLED | +| 265 | `7e799e77e8c7396e770465175fde071720cab896296f61dab7fca18d7df87572` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 244 | 23 | 136 | NO_COVERAGE / NON_KILLED | +| 266 | `7d3fc3ab2af8aff687f6b242abb14d98d96af1914e515dda317bb7c38de55767` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 244 | 32 | 185 | NO_COVERAGE / NON_KILLED | +| 267 | `eb8827c4f4ca1d6b1f90686e5335ffb189d78b4694548a6843f7ebc5d1d95a79` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 201 | 7 | 44 | SURVIVED / NON_KILLED | +| 268 | `56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches | 205 | 9 | 50 | SURVIVED / NON_KILLED | +| 269 | `6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches | 219 | 26 | 141 | SURVIVED / NON_KILLED | +| 270 | `098258d70a2379a58e39d24f5f85824705998bb6d3e20cf0bec54ffecc4db0a6` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 222 | 7 | 44 | SURVIVED / NON_KILLED | +| 271 | `f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 234 | 29 | 161 | SURVIVED / NON_KILLED | +| 272 | `d5b70d305cec04c7aca39a5a9c629221c88509f4a8ee07d9643b2f8e37d1277f` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 244 | 7 | 44 | SURVIVED / NON_KILLED | +| 273 | `a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 265 | 35 | 204 | SURVIVED / NON_KILLED | +| 274 | `f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 82 | 9 | 68 | TIMED_OUT / NON_KILLED | +| 275 | `6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 93 | 23 | 160 | TIMED_OUT / NON_KILLED | +| 276 | `4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 105 | 35 | 243 | TIMED_OUT / NON_KILLED | +| 277 | `e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 107 | 46 | 328 | TIMED_OUT / NON_KILLED | +| 278 | `9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 112 | 60 | 426 | TIMED_OUT / NON_KILLED | +| 279 | `2fa16c9fd834bab14481c3dd937b1bb150988ee0cd86b7ecb457ffa7c7108181` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 114 | 72 | 515 | TIMED_OUT / NON_KILLED | +| 280 | `1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NegateConditionalsMutator` | negated conditional | 210 | 14 | 79 | TIMED_OUT / NON_KILLED | +| 281 | `d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NegateConditionalsMutator` | negated conditional | 223 | 10 | 61 | TIMED_OUT / NON_KILLED | +| 282 | `2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NegateConditionalsMutator` | negated conditional | 225 | 17 | 101 | TIMED_OUT / NON_KILLED | +| 283 | `ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NegateConditionalsMutator` | negated conditional | 230 | 25 | 142 | TIMED_OUT / NON_KILLED | +| 284 | `050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NegateConditionalsMutator` | negated conditional | 248 | 12 | 74 | TIMED_OUT / NON_KILLED | +| 285 | `c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NegateConditionalsMutator` | negated conditional | 253 | 22 | 132 | TIMED_OUT / NON_KILLED | +| 286 | `e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610` | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NegateConditionalsMutator` | negated conditional | 261 | 31 | 181 | TIMED_OUT / NON_KILLED | +| 287 | `202f2f1c71174a04e51f5d592e277ef0fb48fac3d4f454fb38279f25dd09a635` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedApprovalStoreKt` | `requireAttributionMatchesBinding` | `NegateConditionalsMutator` | negated conditional | 57 | 14 | 42 | KILLED / KILLED | +| 288 | `c67230adf1097aeef5217c95976e0d6f85dd129a70f205e98ee97acb15bd383e` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedApprovalStoreKt` | `requireAttributionMatchesBinding` | `NegateConditionalsMutator` | negated conditional | 56 | 2 | 12 | SURVIVED / NON_KILLED | +| 289 | `e28c35edba409d362d08cd122805137f43a325c9c9a335db1e045b9aca435dfe` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedApprovalStoreKt` | `requireAttributionMatchesBinding` | `NegateConditionalsMutator` | negated conditional | 56 | 5 | 22 | SURVIVED / NON_KILLED | +| 290 | `04ed49ac905fc2abdf7cfdd761e7582bd7ac491aa223a084e13e742d0d09d33f` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedApprovalStoreKt` | `requireAttributionMatchesBinding` | `NegateConditionalsMutator` | negated conditional | 56 | 7 | 25 | SURVIVED / NON_KILLED | +| 291 | `df99f2088cc73e87283bff188b06b21e976f220cb27373d490c85104491d80d5` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | `requireGovernedContinuity` | `NegateConditionalsMutator` | negated conditional | 31 | 1 | 11 | KILLED / KILLED | +| 292 | `2369879d4d7e42df22b87327bd6f01ad5907d32215470644f4e04d370872c5ca` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | `requireGovernedContinuity` | `NegateConditionalsMutator` | negated conditional | 31 | 2 | 13 | KILLED / KILLED | +| 293 | `bddb45e4734edccae44984f16ff5a93494f10d3f84f46350235c27c620acf93f` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | `requireGovernedContinuity` | `NegateConditionalsMutator` | negated conditional | 33 | 3 | 18 | KILLED / KILLED | +| 294 | `257dd62ee629185fda53ede750ca4b62ce16473d6322e1e5f00327c24ce2357e` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | `requireGovernedContinuity` | `NegateConditionalsMutator` | negated conditional | 38 | 10 | 47 | KILLED / KILLED | +| 295 | `cf475c6b1c1d20001a2df1476e9ba59ac527b7ef7ec5cc1bc5988093e75a87bf` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | `requireGovernedContinuity` | `NegateConditionalsMutator` | negated conditional | 40 | 12 | 53 | KILLED / KILLED | +| 296 | `1a9e9e35471793c09bcb21e205b59c2a76215adcb1c069794669ab52a0b65f56` | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | `requireGovernedContinuity` | `NegateConditionalsMutator` | negated conditional | 35 | 4 | 29 | SURVIVED / NON_KILLED | +| 297 | `0ad157005da4ee83ef6f5aec6b1eb7be6f128b751cd2334e7e1f9b3ad7c2f010` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `merge` | `NegateConditionalsMutator` | negated conditional | 45 | 5 | 20 | KILLED / KILLED | +| 298 | `f2a73cb55e5277e558146a004df535adad193ef5eda3ecf29a4a3542c72781dd` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `merge` | `EmptyObjectReturnValsMutator` | replaced return value with Collections.emptyMap for dev/tramai/security/evidence/RuntimeEvidenceAttribution::merge | 48 | 10 | 42 | KILLED / KILLED | +| 299 | `a2b9f5bdf3b7b8e9aaf9003d10736dffb5e422f9f772444b2172f14eca38053a` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `validate` | `NegateConditionalsMutator` | negated conditional | 61 | 13 | 81 | KILLED / KILLED | +| 300 | `ef7308a08eb620ed0d0f68c5335a6c0624b123bc6bd70e9110123fc6a96a358b` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `validate` | `NegateConditionalsMutator` | negated conditional | 65 | 17 | 92 | KILLED / KILLED | +| 301 | `aa549804b41de64c635f47c69f78e3d9ba761dc4b8925626c1ae89105907bb98` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `validate` | `NegateConditionalsMutator` | negated conditional | 69 | 22 | 113 | KILLED / KILLED | +| 302 | `a2bfbc629eccfccc5904748676a4a9712f44df379b735e7ef82bc91d143aac05` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `validate` | `NegateConditionalsMutator` | negated conditional | 69 | 24 | 116 | KILLED / KILLED | +| 303 | `13412baee948d4dccf881e3c0d38ebdf58b6adf090c633e9be201ed80b87411d` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `validate` | `NegateConditionalsMutator` | negated conditional | 69 | 27 | 127 | KILLED / KILLED | +| 304 | `3aa582be2b7d010316616e94ae36bf7f641aee3c8557af39316d76b4a4245810` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `validate` | `NegateConditionalsMutator` | negated conditional | 82 | 9 | 55 | KILLED / KILLED | +| 305 | `a14d63a582a3626d45aae6a2c10f651e5507b6bb2a1c0a3ac38ebace47045f0c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `getMetadataKeys` | `EmptyObjectReturnValsMutator` | replaced return value with Collections.emptySet for dev/tramai/security/evidence/RuntimeEvidenceAttribution::getMetadataKeys | 24 | 0 | 4 | SURVIVED / NON_KILLED | +| 306 | `bcbc8d85e9294bdcd92eec67a5daa354d77e3682a3b4d68152330e3591c3465f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateDecisionKind` | `NegateConditionalsMutator` | negated conditional | 92 | 11 | 43 | KILLED / KILLED | +| 307 | `e0f8f364fdd22a040e75fd9d09ad5f2b90d92be75b144504c6c176848a9cfb30` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateSourceComponent` | `NegateConditionalsMutator` | negated conditional | 105 | 11 | 43 | KILLED / KILLED | +| 308 | `2de631d9befe9e7138194b7b83c9c1fafdd11e3168c2e2bed550ba01fb062600` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 137 | 18 | 63 | KILLED / KILLED | +| 309 | `f0cb765d0e27e03fc37238efff01b4c84a95e81cdd5dd37fd3050531b3f8e59c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 138 | 21 | 73 | KILLED / KILLED | +| 310 | `f42105b208a12e61f3713eff6870e23b840cb41b65a9f77fa1b6c17ba60c0592` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 146 | 33 | 117 | KILLED / KILLED | +| 311 | `79f55fa15a98d9daffe7882a88ba94db24f70a0c1955d7c534246cb13919d77d` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 146 | 36 | 124 | KILLED / KILLED | +| 312 | `172c8775d18a71e84072ec69d556450dc5fe07cd4b38c8e2c5c73922d14603ae` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 149 | 40 | 138 | KILLED / KILLED | +| 313 | `223021368f722273689f62cd9002b4c9ffe1aa2e8dcd208a6b1d6980152c82bf` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 150 | 43 | 146 | KILLED / KILLED | +| 314 | `6dc5007a8178cd6494d17a5dbc8c0c40229157ab5b3ab148676f32d04f98afe2` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 155 | 49 | 174 | KILLED / KILLED | +| 315 | `c36b33ad778e218ead7580bc41727d27e017232d8ee9c18dfd4c563aef60dcca` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 156 | 52 | 182 | KILLED / KILLED | +| 316 | `7b00ab5ac28d04395c201a92b654972f58ec82c97c15d70996dcd25beb214691` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 161 | 58 | 210 | KILLED / KILLED | +| 317 | `3f0bf1f38cd4764d9062568ddeae8feacb5bd011ef5b48c794aee3aa668a6ae1` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 163 | 60 | 219 | KILLED / KILLED | +| 318 | `cc8bf2a429839335ec8c0f46f81b07de2f3a02faaeeacd067b858725563aec0b` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 163 | 62 | 222 | KILLED / KILLED | +| 319 | `c8b2042aac4d11cef064099631a01f56d7554d371b36f41826e87cad366f1422` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 163 | 65 | 230 | KILLED / KILLED | +| 320 | `5b7a03ec25279a10a1e1a6931e6187e2e24c1e04acaef5573b99092bd28b233d` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 168 | 71 | 258 | KILLED / KILLED | +| 321 | `89902c8d429ad779434ca7bffe67b4eeb9f83341e5fbf028e778b36fb6bcc8a0` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 170 | 73 | 267 | KILLED / KILLED | +| 322 | `4f5a70084d2fd1c94dc590806faf5681f1fa2b4b87c5e502a72f94973fff0b7f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 170 | 75 | 270 | KILLED / KILLED | +| 323 | `7e8d23be04e4218e33c73b0849bc0b8c4edc2f7192dbf175c1e04c8c164ae10f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 170 | 78 | 278 | KILLED / KILLED | +| 324 | `4fafed964a76f445b65c54dd7778fabbbd911002316aec78c2f3ed86b401b4bf` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 177 | 86 | 346 | KILLED / KILLED | +| 325 | `3b9dad8554bd9e6a86e68827f1420f4a697d6e8d1f60b74352aaf0a724cf23fd` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 185 | 90 | 362 | KILLED / KILLED | +| 326 | `475fe20e01c5b3300a64486921e859221b04696dfd2af969e91d222823f15831` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 186 | 93 | 370 | KILLED / KILLED | +| 327 | `ee32fd4fa4f8dc92a29c27c31b42858ac9c186bf39da336120edbf1763b37a3a` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 192 | 99 | 399 | KILLED / KILLED | +| 328 | `94c1bc295681c254387b391e194fccdd366e62684d4c739e7352a4995ab23cfc` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 194 | 103 | 411 | KILLED / KILLED | +| 329 | `aff4fff03a8cabe79e987254ec598563c79e0abaa91514fbeb2b3960ca863156` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 194 | 101 | 408 | KILLED / KILLED | +| 330 | `3ffdb570847320f7b09fc7359ef81129886ecf23db724a7f696b3459d5573d3e` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 194 | 103 | 411 | KILLED / KILLED | +| 331 | `4d3caa9be571ed6b22e42a60e3627c85657e1646f160c4247ca507df03c08eef` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 194 | 106 | 419 | KILLED / KILLED | +| 332 | `9c55041744ffc5d2eaba7eb0018aa373f4f7d5c413fc7b45b563aa1239148ee1` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 199 | 112 | 447 | KILLED / KILLED | +| 333 | `de500eee41be4ad82b46f913cc2ae18c3652c2e20ab1eb4a6c4280ae1c1b118f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 201 | 114 | 456 | KILLED / KILLED | +| 334 | `551fffd9a0546be4ff03f1dbc0854f2f6437c26668c09cac16c6d8647a09759c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 201 | 116 | 459 | KILLED / KILLED | +| 335 | `7ea4d18e26c2c30bb34b4abdff6fc34027060a469288134c04734c900ceb0cca` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 201 | 119 | 467 | KILLED / KILLED | +| 336 | `be49c6cddf686e6ab8103c9e7d0f0906d3bd4847270f19dcbe40224e0473dcf6` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 206 | 125 | 495 | KILLED / KILLED | +| 337 | `5202e2a2b28b8e26fddbb3e19a4588d3150276ebe1787e578f4548e94146319f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 207 | 127 | 501 | KILLED / KILLED | +| 338 | `565b38ef596a44ef3c8a1f9666276dfee5d8f04e233c0ed6ebeea58dd21b0d9c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 146 | 30 | 110 | NO_COVERAGE / NON_KILLED | +| 339 | `1016c93dbbc9b196b9a4a530989b2410096601826884b69cf63f9445547f97e8` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 215 | 133 | 533 | NO_COVERAGE / NON_KILLED | +| 340 | `33ea83e53a8418523ccc33aa5bea657b6d4d2bbd4aed708d8dcf89b7f53decf1` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 215 | 135 | 536 | NO_COVERAGE / NON_KILLED | +| 341 | `6c077df4e378bce13bd76dac8c0f46b4fdae2c7cd81108157512b61cced8de20` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 215 | 138 | 547 | NO_COVERAGE / NON_KILLED | +| 342 | `e3d83f9421a20ebe9993912caa7c52d98e0e157efd276c1a43915b5b1ee15724` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 215 | 141 | 555 | NO_COVERAGE / NON_KILLED | +| 343 | `9bc111bcf8e78ae2fe81a4ff243faf5d3b0185fd352d472f6e7fc722b696f19c` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 219 | 149 | 585 | NO_COVERAGE / NON_KILLED | +| 344 | `2c5adc165ef6fb1d3199ff178a0e14f3b95289c6aae29d322a54a1f1d57f667f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 224 | 156 | 616 | NO_COVERAGE / NON_KILLED | +| 345 | `fbb04e20399512619fdafc983bfd38f4239bd266d9bd2b8323ba8541450cd17f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 225 | 158 | 622 | NO_COVERAGE / NON_KILLED | +| 346 | `57a4c81569e7d75271f721a4f689845a31ca3569b00af2fd980d76a0ae1a2393` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `VoidMethodCallMutator` | removed call to dev/tramai/security/evidence/RuntimeEvidenceAttribution::validate | 135 | 12 | 51 | SURVIVED / NON_KILLED | +| 347 | `58751c78068df6ef25ed6c64c5eeda6e6600f67f72351812073397c2ba203c6f` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 163 | 62 | 222 | SURVIVED / NON_KILLED | +| 348 | `379f2e6d296b1693899e84e358c73328a9679c4cb6c023d16bddb50bdb5a9bdc` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 170 | 75 | 270 | SURVIVED / NON_KILLED | +| 349 | `0bf3201b50780da27f483eb575458877d047d34c6152438bf8f27e99e68eaeaa` | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 201 | 116 | 459 | SURVIVED / NON_KILLED | + + +## Status changes on shared identities + + +2 shared identities changed raw PIT status; 1 changed canonical outcome. A status change is not a disappearance. + +| identity | family | module | class | method | mutator | line | block | index | committed status | Run A status | Run B status | A/B agree | committed outcome | Run A outcome | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `20b3d3021af5ab883d3ec3ad280df4e973a0ac638384961344ad23198e4903a9` | policy | :tramai-engine | `dev.tramai.engine.PolicyEnforcementHelper` | `evaluate` | `NullReturnValsMutator` | 39 | 11 | 68 | NO_COVERAGE | KILLED | KILLED | yes | NON_KILLED | KILLED | +| `49f02d5ba71e77b022dde06f7f75e865b0a41a9f12a22d1c6e3968c382a0c959` | policy | :tramai-engine | `dev.tramai.engine.PolicyEnforcementHelper` | `evaluate` | `VoidMethodCallMutator` | 39 | 12 | 76 | NO_COVERAGE | SURVIVED | SURVIVED | yes | NON_KILLED | NON_KILLED | + +Likely cause (evidenced): both identities mutate `dev.tramai.engine.PolicyEnforcementHelper#evaluate` at unchanged `block`/`index`. The production file `tramai-engine/src/main/kotlin/dev/tramai/engine/PolicyEnforcementHelper.kt` has **no commit** in `5856530e..0f36ab87`. The test file `tramai-engine/src/test/kotlin/dev/tramai/engine/PolicyEnforcementHelperCoroutineTest.kt` is **new** in that range (+51 lines, added by `cae069b6` / `e5496ed0`) and drives a genuinely suspending `PolicyEngine` through `helper.evaluate(...)`. The status change is therefore a coverage/kill change produced by a test-side change on unchanged production source; it is not a population change and not a regression. + +## Classification audit + + +| Measure | Count | +|---|---| +| classification ids in `config/quality/mutation-classifications.yml` | 23 | +| ids whose mutant is still present in the fresh population | 23 | +| ids whose mutant disappeared (orphaned / stale) | 0 | +| ids referencing a mutant absent from both populations (fabricated) | 0 | + +Every retained classification still resolves to a mutant in the fresh measurement, and every one of them keeps the same raw status as in the committed authority: `0ed9ef51` ModelRegistryEnforcer SURVIVED; `b5cb0f1e` ModelRegistryEnforcer SURVIVED; `1923c2a1` ModelRegistryEnforcer TIMED_OUT; `ccffeec2` CanonicalMessageEncoder NO_COVERAGE; `9ff77360` CanonicalMessageEncoder NO_COVERAGE; `c7b88638` CanonicalMessageEncoder NO_COVERAGE; `6a76fdf0` ProviderFailuresKt SURVIVED; `439bc80e` ProviderFailuresKt NO_COVERAGE; `1d73326d` ProviderFailuresKt NO_COVERAGE; `5a1b5ba8` ProviderFailuresKt SURVIVED; `8d733cb9` ProviderFailuresKt NO_COVERAGE; `2afde9ec` ProviderFailuresKt NO_COVERAGE; `5433810b` ProviderFailuresKt TIMED_OUT; `e511f53a` ProviderFailuresKt TIMED_OUT; `275330ca` ProviderFailuresKt TIMED_OUT; `0b852f2d` ProviderFailuresKt SURVIVED; `fcd8be9f` ProviderFailuresKt TIMED_OUT; `a59e42c8` ProviderFailuresKt TIMED_OUT; `4859d548` ProviderFailuresKt SURVIVED; `d2faed86` ProviderFailuresKt SURVIVED; `262a8088` ProviderFailuresKt SURVIVED; `08f42a0f` ProviderCircuitBreaker NO_COVERAGE; `2adc3f2f` ProviderCircuitBreaker NO_COVERAGE + +Fresh residual mutants with no classification record: + +| Measure | Count | +|---|---| +| fresh NON_KILLED mutants (SURVIVED + NO_COVERAGE + TIMED_OUT) | 877 | +| of which already present in the committed authority (pre-existing residual) | 682 | +| of which fresh-only | 195 | +| of which carry a classification record | 23 | + +Fresh residual by family and raw status: + +| Family | KILLED | SURVIVED | NO_COVERAGE | TIMED_OUT | +|---|---|---|---|---| +| approval | 508 | 141 | 193 | 76 | +| evidence | 539 | 194 | 75 | 0 | +| policy | 154 | 30 | 23 | 2 | +| retry | 138 | 6 | 6 | 5 | +| routing | 12 | 2 | 0 | 1 | +| structuredOutput | 262 | 90 | 30 | 0 | +| tools | 54 | 0 | 3 | 0 | + +No classification was added, removed or rewritten by this task; the file is unchanged (§ Quality impact). + +## Ratchet evidence + + +Command run inside the detached worktree while the fresh Campaign A population was in place: + +```bash +./gradlew verifyMutationRatchet \ + -PtramaiMutationBaseSha="0f36ab8744c14ebbdb3b10964311d0416e012ab2" \ + --no-configuration-cache \ + --rerun-tasks +``` + +Default evolution mode (`FORBID`, no `-PtramaiMutationEvolution` supplied). Log: `/tmp/tramai-071g1a-ratchet.log`. + +| Diagnostic class | Verifier output | Independent audit count | Agreement | +|---|---|---|---| +| M21 disappearance (base identity absent from candidate) | 189 | 189 | AGREE | +| M06 new NON_KILLED identity | 195 | 195 | AGREE | +| M10 orphaned classification | 0 | 0 | AGREE | +| M01 killed -> non-killed regression | 0 | 0 | AGREE | +| other failures | 0 | — | — | +| warnings | 0 | — | — | + +Verifier verdict: the task fails closed, as `FORBID` mode requires. The M21 set is exactly the independently computed disappearance set (189 identities), the M06 set is exactly the independently computed new-`NON_KILLED` set (195), the M10 set is exactly the independently computed orphaned-classification set (0), and M01 is exactly the independently computed killed-to-non-killed regression set (0). The single canonical-outcome change on a shared identity (1) is an improvement (NON_KILLED -> KILLED) and is correctly not reported as a regression. No disagreement between the verifier and the independent analysis was found. + +Exact verifier failure message (first line) and total failure count: + +``` +M06: :tramai-engine dev.tramai.engine.approval.ApprovalResumeCoordinator#executeClaimedResume(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; [org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator] family 'approval' (021b3c4 +... (384 FAIL lines in total) +``` + +Representative M21 line as emitted by the verifier: + +``` +M21: :tramai-engine dev.tramai.engine.approval.ApprovalResumeCoordinator#resume(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; [org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator] family 'approval' (0110d7f7) exists in the base authority but is absent from the candidate population. Absence alone cannot distinguish a legitimate +``` + +## Hard-stop condition evaluation + + +| Condition | Observed | Tripped | +|---|---|---| +| Run A and Run B differ semantically | byte-identical, canonical projection equal | NO | +| A configured family/module produces no required PIT XML | 14/14 `mutations.xml` present in both campaigns | NO | +| Configured family/module produces zero population | minimum populated pair = routing/:tramai-core with 4 mutants; none zero | NO | +| Analyzer semantics differ unexpectedly | identical in both campaigns and equal to the committed analyzer block | NO | +| Mutation target topology differs from the committed authority unexpectedly | same 7 families, same 14 family/module pairs, same `test-quality.yml` bytes | NO | +| Ratchet disagrees with the independently observed disappearance set | M21 set == independent disappearance set (%d) | NO | +| A baseline-only identity remains unexplained | 0 unexplained | NO | +| Correct diagnosis requires editing build-logic / mutation targets / production code / tests / a quality gate | not required | NO | + +## Verdict + + +**READY FOR 0.7.1g1B AUTHORITY EVOLUTION** + + +Justification, limited to what was measured: + +- the canonical population is exactly deterministic across two independent campaigns at `0f36ab87` (byte-identical output); +- every one of the 189 committed identities absent from the fresh measurement is attributed (category B, `SOURCE_REFACTORED`, with the bytecode-identity mechanism of the v2 schema demonstrated per identity); no identity is `UNEXPLAINED`; +- the 2 status changes are explained by a test-side change on unchanged production source; +- the repository ratchet independently reproduces the disappearance, new-survivor, orphaned-classification and outcome-change sets; +- no quality gate, baseline, classification, target configuration or production/test source was modified by this audit. + +This verdict authorizes a later, separately scoped 0.7.1g1B slice to consider a population-evolution ceremony. It does not itself authorize: + +- replacing `config/quality/mutation-baseline.json`; +- populating `config/quality/mutation-evolution.yml`; +- applying the `mutation-population-evolution` label; +- enrolling `:tramai-control-plane`; +- repairing the fresh residual survivors / no-coverage / timed-out mutants; +- merging the Epic to `release/0.7.0`. + +## Remaining risks + + +1. The fresh population carries 877 NON_KILLED mutants (463 SURVIVED, 330 NO_COVERAGE, 84 TIMED_OUT), of which 195 are fresh-only. None is classified; a future evolution PR must not silently treat them as accepted. +2. The attribution pairing is on `(module, class, method, JVM descriptor, mutator, description)`. It proves the source-level mutation point is preserved; it does not prove per-line identity, and it makes no behavioural-equivalence claim. A 1:1 bytecode-site mapping does not hold for every pair (several old identities can map to one new identity where a method's bytecode layout collapsed). +3. This audit measured only the default local environment (Temurin 21.0.7 Gradle JVM, Linux). The committed authority was also measured locally; CI does not generate the population, so no CI/local measurement divergence could be measured here. +4. `measuredCommit 5856530e` is a pre-squash PR commit and not an ancestor of the audited Epic head; the authority's provenance field therefore cannot be resolved by ancestry in future audits. + + +## Verification (task §19) + + + +Commands run after the audit document became the only tracked change: + + + +```bash + +git diff --check # clean + +git status --short # only the task document + + + +./gradlew verifyChangePolicy \ + + -PchangeClass=documentation -PchangePolicyBase=0f36ab8744c14ebbdb3b10964311d0416e012ab2 --no-configuration-cache + +# verifyChangePolicy PASSED — 1 changed file(s), change class: documentation, no policy violations. + + + +./gradlew verifyPr \ + + -PchangeClass=documentation -PchangePolicyBase=0f36ab8744c14ebbdb3b10964311d0416e012ab2 \ + + -PtramaiCancellationBaseSha=0f36ab8744c14ebbdb3b10964311d0416e012ab2 --no-configuration-cache + +# BUILD FAILED — see below + +``` + + + +`verifyPr` is **red**, and the red is not attributable to this change. It fails on three + +pre-existing `:build-logic:test` failures: + + + +```text + +TramaiDocsGuardsPluginTest > verifyVersionAlignment fails on a stale snapshot reference() FAILED + +TramaiDocsGuardsPluginTest > verifyVersionAlignment passes on real version surfaces() FAILED + +ResidualQualityVerifierTasksTest > java consumer smoke compiles real fixture and writes marker() FAILED + +29 tests completed, 3 failed + +``` + + + +Attribution (rerun with and without the change, as the repository's failure protocol requires): + + + +| Run | Tree | Change present | Failing tests | + +|---|---|---|---| + +| A | fresh shared clone at `0f36ab8744c14ebbdb3b10964311d0416e012ab2` | no | the same three | + +| B | task branch (this document committed) | yes | the same three | + + + +Failure causes as reported by the tests themselves: + + + +- `TramaiDocsGuardsPluginTest`: the TestKit fixture build fails with + + `build.gradle.kts fallback must be 0.7.0-SNAPSHOT`, i.e. the fixture environment, + + not the repository's version surfaces (`tramaiVersion=0.7.0-SNAPSHOT` at the audited SHA). + +- `ResidualQualityVerifierTasksTest > java consumer smoke`: the generated fixture build reports + + `"ok" : false, "exitCode" : 1` while compiling its consumer-smoke fixture. + + + +Classification per `AGENTS.md` (CI failure protocol): `environment-mismatch`, pre-existing at the + +audited SHA. Nothing was modified to make it pass: the change under audit is a documentation file + +and cannot influence either fixture, and both failures reproduce byte-for-byte with the change absent. + + + +Two earlier full `verifyPr` runs were killed by the local process supervisor (SIGTERM) before they + +could finish; one of them surfaced as `Gradle build daemon disappeared unexpectedly`, which is the + +kill of the parent process group, not a repository defect. + + + +`verifyMutationRatchet` inside `verifyPr` PASSED (base resolved to `41ac9151`, the merge-base with + +`origin/master`, because `tramaiMutationBaseSha` is not set by `verifyPr`): the committed population + +is unchanged by this task, so the ratchet has nothing to reject. The discriminating run remains the + +one in the *Ratchet evidence* section above, where the fresh population was the candidate. + + + +The branch was **not pushed**: `AGENTS.md` forbids pushing while a required local check is failing, + +and the local `verifyPr` red is a pre-existing environment red that this task is not authorized to + +repair. + +## Post-audit addendum + +Recorded after this document was committed, for any reader who reaches it later. Nothing above is +superseded: every measurement here was taken at `0f36ab8744c14ebbdb3b10964311d0416e012ab2`, and that +evidence is what the verdict rests on. + +- The pre-existing `verifyPr` red described above was repaired outside this task, which is what the + task required: `#431` fixed the stale TestKit version and identity fixtures and wired the + snapshot-aware version gate, and `#432` bounded and made fail-loud the in-process MCP test-server + teardown that could stall the `tests` lane. Both merged into + `epic/0.7.1-control-plane-authority` (`6ac4c8e2`, then `feae94f7`). +- The "not pushed" paragraph above records the state at the time of writing. This branch was pushed + and opened as `#430` on explicit instruction after the report was reviewed, then rebased onto + `feae94f7` and re-certified. +- The audit findings are unchanged: determinism PASS (`cmp` byte-identical between Campaign A and + Campaign B, `sha256 06549e8a21e199b3f4c94fed359a3ae783e7796a29b27d6189c0dd261bc8a457`), 189/189 + disappearances attributed `SOURCE_REFACTORED`, `UNEXPLAINED` 0, ratchet agreement. Verdict stands: + **READY FOR 0.7.1g1B AUTHORITY EVOLUTION**. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1B-M06-EVOLUTION-PREFLIGHT.md b/docs/roadmap/0.7.0/TASK-0.7.1g1B-M06-EVOLUTION-PREFLIGHT.md new file mode 100644 index 000000000..0a8a771a2 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1B-M06-EVOLUTION-PREFLIGHT.md @@ -0,0 +1,1392 @@ +# TASK-0.7.1g1B — M06 Evolution Preflight + +Stop-and-report compatibility gate for 0.7.1g1B *Mutation Authority Evolution*. No mutation +authority was modified: this document is the only artifact created. + +## 1. Base, scope, and a defect in the base SHA as stated + +| Item | Value | +|---|---| +| Repository | `GionaGranchelli/tramAI` | +| Base branch | `epic/0.7.1-control-plane-authority` | +| Base SHA as given in the task | `e38ce9f9dbb3519947ffe692932c934eb3f17` (37 characters) | +| Base SHA actually resolved on the Epic branch | `e38ce9f9dbb3519947ffe3ea692932c934eb3f17` (40 characters) | +| Task branch created from it | `task/0.7.1g1b-mutation-authority-evolution` | +| Epic tip subject | `docs(0.7.1g1a): canonical mutation population drift audit (#430)` | + +**The SHA in the task description is not a valid git object.** It has 37 characters, and +`git rev-parse e38ce9f9dbb3519947ffe692932c934eb3f17^{commit}` resolves to nothing. The Epic branch +resolves to the 40-character SHA above. The two strings are identical except that the resolvable one +carries three extra characters (`3ea`) after the 20-character prefix: + +``` +as given: e38ce9f9dbb3519947ffe 692932c934eb3f17 +resolved: e38ce9f9dbb3519947ffe 3ea692932c934eb3f17 +``` + +The base has **not moved**: the branch tip is the audit merge commit from the previous stage of this +same workstream. I proceeded on the reading that the task means the current Epic head, and I state it +here because a SHA is the one thing in a task like this that must not be repaired silently. If the +intent was a different commit, every conclusion below is void. I did not substitute a different SHA: +the branch was created from the resolved tip, and that tip is what every artifact was read at. + +Verification of the preconditions: + +``` +$ git fetch origin && git rev-parse origin/epic/0.7.1-control-plane-authority +e38ce9f9dbb3519947ffe3ea692932c934eb3f17 +$ git status --porcelain # empty +$ git checkout -b task/0.7.1g1b-mutation-authority-evolution e38ce9f9dbb3519947ffe3ea692932c934eb3f17 +$ git rev-parse HEAD # e38ce9f9dbb3519947ffe3ea692932c934eb3f17 +``` + +### 1.1 Sources of truth used + +- `config/quality/mutation-baseline.json` at the base SHA (2384 rows) — the base authority. +- `/tmp/tramai-071g1a-population-A.json` and `/tmp/tramai-071g1a-population-B.json` — the raw canonical + populations from the g1A campaigns (each 2544 rows). Both present; no campaign was re-run. +- `docs/roadmap/0.7.0/TASK-0.7.1g1A-MUTATION-POPULATION-DRIFT-AUDIT.md` — the merged audit, used only + for cross-checking, never as the partition input. +- `MutationRatchetVerifier.kt`, `MutationPopulationEvolution.kt`, `MutationRatchetAuthority.kt`, + `MutationIdentity.kt`, `.github/workflows/ci.yml` at the base SHA. + +## 2. Current verifier semantics (Phase 1) + +Read from source at the base SHA. Line references are to +`build-logic/src/main/kotlin/dev/tramai/build/quality/MutationRatchetVerifier.kt` unless stated. + +### 2.1 `RECORDED_EVOLUTION` changes M21 handling only + +- `MutationPopulationEvolution.kt:11-31` — the enum and its entry point: property + `tramaiMutationEvolution`, and `fromProperty` maps `""`/`forbid` to `FORBID`, `recorded-evolution` to + `RECORDED_EVOLUTION`, and **throws** on any other non-blank value. Absent means `FORBID`. +- `MutationRatchetVerifier.kt:69` — `evolution: MutationPopulationEvolution = MutationPopulationEvolution.FORBID`. +- `MutationRatchetVerifier.kt:319-324` — M21 is delegated to `populationEvolutionDiagnostics(...)`, which + is the only place the evolution mode is consulted for removals. +- `MutationRatchetVerifier.kt:893-947` — for each `baseById.keys - candidateIds`: `FORBID` emits a + **failure** (L914-925); `RECORDED_EVOLUTION` with no record for that identity emits a **failure** + (L927-935); `RECORDED_EVOLUTION` with a record emits a **warning** (L937-944). So recorded evolution + converts exactly one class of diagnostic — M21 disappearance — from failure to warning. +- `MutationRatchetVerifier.kt:950-996` — even then it is conditioned: a proof from an exact fresh + measurement must exist and match the committed candidate (L958-972), every record must name an identity + that actually disappears in this transition (L973-983), and every record's `fromBaseSha` must equal the + transition base (L984-993). + +### 2.2 `outcomeRatchet` still applies M06 to every candidate-only NON_KILLED identity + +`MutationRatchetVerifier.kt:299-313`: + +```kotlin +for (id in candidateIds - baseIds) { + val candidate = candidateById.getValue(id) + if (candidate.outcome == NON_KILLED) { + diagnostics += VerificationDiagnostic.failure( + DiagnosticCode.MUTATION_RATCHET_NEW_SURVIVOR, + "M06: ${describe(candidate)} (${short(id)}) is a NEW NON_KILLED identity absent from the " + + "base authority. New mutants must be killed; a PR cannot certify its own survivors.", + findingId = id, modulePath = candidate.module) + } +} +``` + +The set is exactly `candidateIds - baseIds` with `outcome == NON_KILLED` — no family filter, no module +filter, no budget, no score threshold. `outcomeRatchet` receives the evolution context +(`MutationRatchetVerifier.kt:262-266`) but passes it only to `populationEvolutionDiagnostics` at L320; +the M06 loop itself never reads it. `AUTHORITY_EXCLUDED_IDENTITIES` — the only hard-coded exemption set +(`MutationRatchetVerifier.kt:530-533`, two identities) — is consulted for M01 at L278 and for M21 at +L905/L973, never for M06. + +### 2.3 No record, label, or property exempts M06 + +`MutationEvolutionRecord` (`MutationPopulationEvolution.kt:34-42`) carries `id`, `fromBaseSha`, `reason`, +`issue`, `targetPhase`, `authorizedAt`, `authorizedBy`. `recordsById` is built at +`MutationRatchetVerifier.kt:902` and read only inside `populationEvolutionDiagnostics` (L906) and +`evolutionEvidenceDiagnostics` (L974/L984). No field of a record is read anywhere in the M06 path, and +no record shape includes anything but an identity — a record cannot describe a rekey, an inheritance, or +a replacement. + +### 2.4 Candidate-added classifications cannot be a shortcut + +`MutationRatchetVerifier.kt:376-430`: **every** classification a candidate adds is a failure. +`addedClassificationDiagnostic` (L400-430) returns M09 (fabricated, no mutant anywhere), M08 (mutant +absent from the base authority — explicitly *"A PR cannot approve its own new survivors"*), or M09 +(mutant exists in base but was never classified). Retained classifications must stay byte-identical +(M03, L361-372); removing one while the survivor remains is M11 (L384-394). There is no path in which a +candidate's own classification record resolves an M06 finding: the added set is computed from the same +`candidateIds` that M06 rejects. + +### 2.5 CI label wiring + +`.github/workflows/ci.yml:215-221` — the pull-request gate passes exactly one property: + +```yaml +./gradlew verifyMutationRatchet \ + -PtramaiMutationEvolution=${{ contains(github.event.pull_request.labels.*.name, 'mutation-population-evolution') && 'recorded-evolution' || 'forbid' }} \ + -PtramaiMutationBaseSha="${{ github.event.pull_request.base.sha }}" +``` + +The label only selects `recorded-evolution` over `forbid`; it cannot express anything else, and +`.github/workflows/ci.yml:223-242` (push path) auto-selects `recorded-evolution` only when the pushed +range changed both `config/quality/mutation-baseline.json` and `config/quality/mutation-evolution.yml`. + +**Phase 1 conclusion:** recorded evolution is a strictly M21-scoped affordance. M06 is unconditional, +independent of evolution mode, and cannot be discharged by an evolution record or a candidate +classification. + +## 3. A/B raw-artifact equality (Phase 2 gate) + +| Check | A | B | Equal | +|---|---|---|---| +| rows | 2544 | 2544 | yes | +| sha256 (bytes) | `06549e8a21e199b3f4c94fed…` | `06549e8a21e199b3f4c94fed…` | True | +| canonical projection sha256 | `ced818dd63cd5050dd5a219f…` | `ced818dd63cd5050dd5a219f…` | True | +| full row-object equality | — | — | True | + +Identity self-consistency (SHA-256 recomputed over the eight serialized fields per +`MutationIdentity.kt:26-41`, including `block`/`index`) was re-derived for every row: + +| Population | rows | rows whose stored identity ≠ recomputed identity | +|---|---|---| +| committed authority | 2384 | 0 | +| Campaign A | 2544 | 0 | +| Campaign B | 2544 | 0 | + +No duplicate identities were found in any population (the analysis fails closed on duplicates). A and B +are byte-identical, so the descriptor-aware partition is computed once and holds for both. + +## 4. The exact M06 set + +Computed from the raw JSON only (identity sets, no Markdown tables): + +| Quantity | Value | Matches stated expectation | +|---|---|---| +| committed authority total | 2384 | yes (2384) | +| fresh population total | 2544 | yes (2544) | +| shared identities | 2195 | yes (2195) | +| base-only identities (M21 set) | 189 | yes (189) | +| candidate-only identities | 349 | yes (349) | +| candidate-only KILLED | 154 | — | +| **candidate-only NON_KILLED (M06 set)** | **195** | **yes (195)** | +| candidate-only with any other outcome | 0 | — | + +- sha256 over the sorted 195 identities: `1b066deb78c9a1a7fe25fc5a0e7813867102732f7ef575b18673bce6409cbcf0` +- sha256 over the sorted 189 base-only identities: `8caaed09b1d660c0d5604465f8b3ea794f7d1ba086d158022ad0eb4526140d66` + +M06 total by raw PIT status: + +| raw status | count | +|---|---| +| NO_COVERAGE | 109 | +| SURVIVED | 40 | +| TIMED_OUT | 46 | + +### 3.1 Artifact validity across the audited SHA → current base + +The A/B campaigns were measured at `0f36ab87…`; this preflight reasons about the Epic tip `e38ce9f9…`. Three +commits landed in between, so the measurements are inherited evidence only if none of the inputs that +determine a mutation population changed. That is verified here rather than assumed. + +``` +$ git diff --name-only 0f36ab8744c14ebbdb3b10964311d0416e012ab2 e38ce9f9dbb3519947ffe3ea692932c934eb3f17 +build-logic/src/main/kotlin/dev/tramai/build/docs/DocsContractVerifierTask.kt +build-logic/src/main/kotlin/dev/tramai/build/docs/PromotedReleaseVerifier.kt +build-logic/src/main/kotlin/dev/tramai/build/docs/VersionAlignmentVerifier.kt +build-logic/src/test/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPluginTest.kt +build-logic/src/test/kotlin/dev/tramai/build/quality/CanonicalProbeFunctionalTest.kt +build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt +docs/roadmap/0.7.0/TASK-0.7.1g1A-MUTATION-POPULATION-DRIFT-AUDIT.md +tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowMcpStepTest.kt +``` + +Eight files. Four checks decide whether they can move a measurement: + +1. **No mutation-target input changed.** `config/quality/test-quality.yml:19-77` configures seven families + over five modules — `:tramai-engine`, `:tramai-security`, `:tramai-sovereign`, `:tramai-core`, + `:tramai-structured`. Filtering the diff above by those five module prefixes returns **nothing**. The + only test change in the range is `WorkflowMcpStepTest.kt` (#432, the MCP fixture teardown fix), and + `:tramai-orchestration` is not a configured mutation target module: it contributes neither a target + class nor a test input to any family's PIT run. The `routing`, `retry` and `tools` families additionally + restrict themselves with `targetTests` (`:40-44`, `:52-58`, `:65-68`); none of those test classes is in + the diff either. +2. **The analyzer, configuration and authority are byte-identical.** `git diff --exit-code` over + `config/quality/test-quality.yml`, `config/quality/mutation-baseline.json`, + `config/quality/mutation-classifications.yml`, `config/quality/mutation-evolution.yml` and + `build-logic/src/main/kotlin/dev/tramai/build/quality` exits **0 with empty output**. The one + build-logic package the range does change is `dev.tramai.build.docs` (release/version doc guards), and + no file in the mutation path under `build/quality` references it — `git grep dev.tramai.build.docs -- + build-logic/src/main/kotlin/dev/tramai/build/quality` returns nothing. +3. **Analyzer semantics, identity schema and topology all match**, measured vs committed: `pluginVersion` + 1.19.0, `engineVersion` 1.22.1, the same eleven mutators, `timeoutConst` 4000, `timeoutFactor` 1.25, + `identitySchemaVersion` 2, and identical family→module topology. +4. **The delta is confined to the two families the Epic work touched.** Family totals, committed → fresh: + `approval` 768 → 918, `evidence` 798 → 808, and `policy`, `retry`, `routing`, `structuredOutput`, `tools` + unchanged. That is exactly the growth the g1A audit attributed to `SOURCE_REFACTORED` + (`ApprovalResumeCoordinator`, `ApprovalSuspensionCoordinator`, `DefaultApprovalGateway`, + `RuntimeEvidenceContractValidator`) — not a measurement artifact. + +Recorded caveat, not a defect: the committed authority's `measuredCommit` is `5856530e…`, older than the +audited `0f36ab87…`. That gap is the pre-existing drift the g1A audit exists to measure; it is why the fresh +population is 2544 rather than 2384. The claim made here is narrower and is what #433 needs: **the 2544-row +A/B population measured at `0f36ab87…` is valid evidence at `e38ce9f9…`, because no configured mutation +target's production source, tests, analyzer, or configuration changed between them.** A test change inside a +target module *would* have invalidated it; none exists in this range. + +No fresh campaign is required for this conclusion, and none was run. + +## 5. Descriptor-aware source-point partition (Phase 3) + +### 5.1 Key definition + +``` +source-level key = (module, className, method, methodDescription, mutator, description) +block and index are ignored +``` + +Classification rule, per candidate-only NON_KILLED identity: **A** if base-only identities exist at that +key and every one of them is NON_KILLED; **B** if base-only identities exist at that key and they contain +both KILLED and NON_KILLED; **C** if no base-only identity carries that key. + +### 5.2 Result + +| Category | Meaning | Total | +|---|---|---| +| A | UNAMBIGUOUS_INHERITED_NON_KILLED | 37 | +| B | AMBIGUOUS_INHERITED (mixed predecessor outcomes) | 51 | +| C | GENUINELY_NEW_NON_KILLED | 107 | +| | **total** | **195** | + +| Set | sha256 of sorted identities | +|---|---| +| A | `e6c6868d802db9f75327d7733a97358ca871296244e716f78da761858f35e788` | +| B | `93cdb79cd5f91fa0779bc03ea8fb5ea3c397fc0f6ab59a968220834ce68b81ff` | +| C | `e2db016d87cad1d8eb8cadd84c671f99871e88b640bbe96018ccfa47ec04baf9` | + +The audit Markdown suggested A≈37, B≈51, C≈107 (A+B=88). The raw JSON gives exactly that, so the +audit's approximation is confirmed rather than superseded — but the numbers above are the authoritative +ones, and every claim in this document rests on them. + +### 5.3 Per-category summaries + +#### Category A — 37 identities + +| raw status | count | +|---|---| +| NO_COVERAGE | 30 | +| SURVIVED | 4 | +| TIMED_OUT | 3 | + +| family | count | | module | count | +|---|---|---|---|---| +| approval | 37 | | :tramai-engine | 37 | + +| class | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 16 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 15 | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 6 | + +| class#method | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#executeClaimedResume` | 6 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#resume` | 9 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#revealAndValidateReplayPayload` | 1 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#suspendToolExecution` | 14 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#validateRenewedApprovalRequirement` | 1 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requestApproval-Atj0Sqo` | 6 | + +#### Category B — 51 identities + +| raw status | count | +|---|---| +| NO_COVERAGE | 26 | +| SURVIVED | 4 | +| TIMED_OUT | 21 | + +| family | count | | module | count | +|---|---|---|---|---| +| approval | 40 | | :tramai-engine | 40 | +| evidence | 11 | | :tramai-security | 11 | + +| class | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 22 | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 12 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | 11 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 6 | + +| class#method | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#executeClaimedResume` | 3 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#resume` | 17 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#revealAndValidateReplayPayload` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#suspendToolExecution` | 6 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requestApproval-Atj0Sqo` | 12 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator#validateMetadata` | 11 | + +#### Category C — 107 identities + +| raw status | count | +|---|---| +| NO_COVERAGE | 53 | +| SURVIVED | 32 | +| TIMED_OUT | 22 | + +| family | count | | module | count | +|---|---|---|---|---| +| approval | 105 | | :tramai-engine | 105 | +| evidence | 2 | | :tramai-security | 2 | + +| class | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 29 | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 28 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 23 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | 5 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | 4 | +| `dev.tramai.engine.approval.GovernedApprovalStoreKt` | 3 | +| `dev.tramai.engine.approval.ApprovalRunAttributionKt` | 2 | +| `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | 1 | + +| class#method | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#authorizeResume` | 13 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#prepareResume` | 16 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2#invokeSuspend` | 4 | +| `dev.tramai.engine.approval.ApprovalRunAttributionKt#decodeApprovalAttribution` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateStep` | 6 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateSuspension` | 11 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#persistSuspendedInvocation` | 6 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1#invokeSuspend` | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2#invokeSuspend` | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3#invokeSuspend` | 5 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistGoverned` | 11 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistUngoverned` | 11 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requireExistingAttributionMatches` | 6 | +| `dev.tramai.engine.approval.GovernedApprovalStoreKt#requireAttributionMatchesBinding` | 3 | +| `dev.tramai.engine.approval.GovernedContinuationContinuityKt#requireGovernedContinuity` | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceAttribution#getMetadataKeys` | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator#validateMetadata` | 1 | + +### 5.4 The key is not an identity: fan-in and fan-out + +This is the finding that decides Phase 4. Descriptor keys are not a bijection between old and new +identities. Over the 195 M06 identities: + +| old identities at key | new identities at key | keys | +|---|---|---| +| 0 | 1 | 11 | +| 0 | 2 | 12 | +| 0 | 3 | 5 | +| 0 | 4 | 9 | +| 0 | 5 | 3 | +| 0 | 6 | 1 | +| 1 | 1 | **5** | +| 2 | 1 | 2 | +| 3 | 3 | 2 | +| 4 | 3 | 2 | +| 5 | 6 | 1 | +| 6 | 6 | 1 | +| 7 | 6 | 3 | +| 12 | 6 | 1 | +| 13 | 8 | 1 | +| 14 | 8 | 1 | +| 21 | 9 | 1 | +| 37 | 8 | 1 | + +- Base-only descriptor keys: 33. Candidate-only NON_KILLED descriptor keys: 62. Keys present on both + sides: 21. +- **Only 5 of the 195 new identities are a clean one-old-to-one-new rekey.** +- 83 of the 195 sit at a key occupied by two or more base-only identities; the extreme case is a single + descriptor key with 37 base-only identities collapsing onto 8 new ones. +- 44 keys carry more than one new identity, so a key can expand as well as collapse. + +Category B is unanimous in the way that matters: all 51 of its identities have predecessors containing +**both** a KILLED and a NON_KILLED outcome at the same descriptor key. Every B predecessor group is +mixed. That is a property of the data, not of a threshold: there is no B identity whose key could be +read as unambiguously inherited. + +### 5.5 Cross-check: does the enclosing method exist in the base authority? + +Independent of the descriptor key, and computed against the *whole* base population (not just the +base-only subset): + +| Category | new identities whose (module, className, method) the base authority already measured | +|---|---| +| A | 37 / 37 | +| B | 51 / 51 | +| C | 1 / 107 | + +106 of the 107 category-C identities live in methods the authority never measured at all — 16 distinct +methods, including `ApprovalResumeCoordinator#authorizeResume`, `#prepareResume`, four +`invokeSuspend` synthetic-lambda bodies, `ApprovalSuspensionCoordinator#compensateSuspension`, +`#compensateStep`, `#persistSuspendedInvocation`, `DefaultApprovalGateway#persistGoverned`, +`#persistUngoverned`, `#requireExistingAttributionMatches`, and `ApprovalRunAttributionKt#decodeApprovalAttribution`. +Category C is therefore new source, not a shifted coordinate — the descriptor key and the method-level +check agree there. + +For A and B the two readings disagree in the way that makes inheritance undecidable from the descriptor +key alone: the enclosing methods were measured before, but the identities are new and the key that would +justify inheritance is shared with up to 36 other old identities, including killed ones. + +### 5.6 Category A against the whole base authority, not just the disappearing rows + +Category A is defined over *base-only* predecessors — the identities that disappeared. That is the task's +definition, and it leaves a question open that matters for any future lineage design: a key could carry a +**retained** base identity with a KILLED outcome and still look "unambiguous" under the disappearing-only +rule, because a retained row is not a base-only row. So the check was repeated against all 2384 +base-authority identities. + +Result: **all 11 keys are uniform NON_KILLED across the whole authority**, covering 51 base-authority +identities (43 that disappeared plus 8 retained siblings), and every one of the 37 category-A identities +sits at such a key. + +| # | class#method | mutator | description | base-only (disappearing) | whole authority at key | outcomes | raw statuses | uniform NON_KILLED | +|---|---|---|---|---|---|---|---|---| +| 1 | `ApprovalResumeCoordinator#executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 3 | 5 | NON_KILLED=5 | NO_COVERAGE=4 / SURVIVED=1 | yes | +| 2 | `ApprovalSuspensionCoordinator#suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 8 | NON_KILLED=8 | NO_COVERAGE=7 / SURVIVED=1 | yes | +| 3 | `ApprovalSuspensionCoordinator#suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null | 7 | 7 | NON_KILLED=7 | NO_COVERAGE=7 | yes | +| 4 | `ApprovalResumeCoordinator#resume` | `VoidMethodCallMutator` | removed call to `CancellationKt::rethrowIfCancellation` | 1 | 1 | NON_KILLED=1 | SURVIVED=1 | yes | +| 5 | `ApprovalSuspensionCoordinator#suspendToolExecution` | `VoidMethodCallMutator` | removed call to `Intrinsics::checkNotNull` | 1 | 1 | NON_KILLED=1 | SURVIVED=1 | yes | +| 6 | `ApprovalResumeCoordinator#resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 13 | 14 | NON_KILLED=14 | NO_COVERAGE=13 / SURVIVED=1 | yes | +| 7 | `DefaultApprovalGateway#requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 5 | 6 | NON_KILLED=6 | NO_COVERAGE=5 / SURVIVED=1 | yes | +| 8 | `ApprovalResumeCoordinator#executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 3 | 4 | NON_KILLED=4 | TIMED_OUT=4 | yes | +| 9 | `ApprovalResumeCoordinator#revealAndValidateReplayPayload` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 1 | 3 | NON_KILLED=3 | NO_COVERAGE=2 / SURVIVED=1 | yes | +| 10 | `ApprovalSuspensionCoordinator#validateRenewedApprovalRequirement` | `ConditionalsBoundaryMutator` | changed conditional boundary | 1 | 1 | NON_KILLED=1 | SURVIVED=1 | yes | +| 11 | `ApprovalSuspensionCoordinator#suspendToolExecution` | `VoidMethodCallMutator` | removed call to `Intrinsics::checkNotNullExpressionValue` | 1 | 1 | NON_KILLED=1 | SURVIVED=1 | yes | + +The 11 descriptor keys in full: + +``` +1. :tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure +2. :tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure +3. :tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution +4. :tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation +5. :tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/jvm/internal/Intrinsics::checkNotNull +6. :tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure +7. :tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure +8. :tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional +9. :tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure +10. :tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|validateRenewedApprovalRequirement|(Ldev/tramai/engine/tool/ToolExecutionRequest;Ldev/tramai/core/policy/PolicyDecision$RequireApproval;Ljava/lang/String;)V|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary +11. :tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/jvm/internal/Intrinsics::checkNotNullExpressionValue +``` + +So the name `UNAMBIGUOUS_INHERITED_NON_KILLED` survives the stronger test and does not need weakening: at +every category-A key, *no* base-authority identity — disappearing or retained — is KILLED. The word +"unambiguous" is scoped to one thing only: the outcomes at that key. It is **not** a claim of lineage, and +Section 9.1 shows why the key still cannot carry one. Categories A and B remain separated by exactly the +property that matters: B's keys carry KILLED predecessors, A's do not. + +## 6. Complete inventories + +### 6.1 Base-only predecessor groups by descriptor key (all 189 identities) + +Every base-only identity, grouped by the descriptor key it occupies. The categories in 6.2–6.4 reference +these keys. + +**P1** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 3 base-only identities, uniform (NON_KILLED=3) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `949c8ad8dfd437397bb1938ed16ced78b35521bc1e81770a35d3f0b91a03a245` | NON_KILLED | TIMED_OUT | 19 | 159 | +| 2 | `062c0e53f177bc67178766900107469b2751614ddbbdc486618320ac6746b6d3` | NON_KILLED | TIMED_OUT | 39 | 313 | +| 3 | `dc0810d0cc145bf0e3745796d66a9175a03676369e161f328dc8cd87fb3512a7` | NON_KILLED | TIMED_OUT | 54 | 430 | + +**P2** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` — 3 base-only identities, uniform (NON_KILLED=3) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `6371b23fe46996857beba836eb604bbad25d658c8b84c3be0ed90554c9b6f381` | NON_KILLED | NO_COVERAGE | 21 | 194 | +| 2 | `4e1e2df1c11eae41ce0876811804019e367164d932eb485d4eee2734d245f3e2` | NON_KILLED | NO_COVERAGE | 41 | 368 | +| 3 | `527a99cc5db7370b85b52164d27928486931a7f3c5247b7a67004fbf9f1ef5f9` | NON_KILLED | NO_COVERAGE | 56 | 489 | + +**P3** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume` — 4 base-only identities, MIXED (KILLED=1, NON_KILLED=3) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `950b0b5ac5a964b4a53962a1e439caca8a7943474f1919be4eaf164159577778` | NON_KILLED | NO_COVERAGE | 20 | 163 | +| 2 | `be663ad795d6885d7609aca076dfc7e1502fe28e063cd7ff2c21d7f5d281f3e6` | NON_KILLED | NO_COVERAGE | 40 | 317 | +| 3 | `4948fdbaf262d7c72c4f3f2c6a29ed7219c62cb0f1d820a9d8f0be6a3e7e49c1` | NON_KILLED | NO_COVERAGE | 55 | 434 | +| 4 | `56b12a836fef73fbc3184febbffbd2824b0357c966758422fb2d7c042052dac8` | KILLED | KILLED | 58 | 496 | + +**P4** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resolveAndValidateResumeTool|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/approval/ApprovalContinuation;)Ldev/tramai/core/model/ResolvedTool;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 5 base-only identities, uniform (KILLED=5) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `e3c33ca72888706f68d059899580e3275695bb1ed3ead44e0cc1469d7a5afd08` | KILLED | KILLED | 9 | 34 | +| 2 | `e21521614f27a6f3c0cce2315a108667da6ff1bac8d9a29bd085ef6e7b21a96e` | KILLED | KILLED | 17 | 58 | +| 3 | `057b79621cabf73bf2880c446d83ddf31daba59eaa5bfa4a6d128683f273c632` | KILLED | KILLED | 25 | 84 | +| 4 | `f5c7739fa85d69bddd1f37d79efba20426d4f721499383d5ff64006dc9c0606e` | KILLED | KILLED | 32 | 109 | +| 5 | `80aacc22c2e7948c37e5ba8474219e0aa24f18acef8e19daf7b3ff3575354763` | KILLED | KILLED | 39 | 134 | + +**P5** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resolveAndValidateResumeTool|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/approval/ApprovalContinuation;)Ldev/tramai/core/model/ResolvedTool;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resolveAndValidateResumeTool` — 1 base-only identities, uniform (KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `6ced1d5fb8f4a1d8376837e4722f24b7032a4f1ddce619325992ad743c337a40` | KILLED | KILLED | 43 | 155 | + +**P6** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 21 base-only identities, MIXED (KILLED=7, NON_KILLED=14) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `bd46cba9bba932b8ccd3c2559fb736144c4a0ec61c301c69cba3fd573f14a6be` | NON_KILLED | TIMED_OUT | 11 | 68 | +| 2 | `130b60fa7f074f598b0bf133ef3eb4107a6508e011a1abd4bf5fef96aea13c34` | KILLED | KILLED | 15 | 95 | +| 3 | `59bbf214663690ff40abf6695e8508b450b1ba104299445761c6dd3945e91102` | KILLED | KILLED | 17 | 101 | +| 4 | `85ae74cb2ffc3e0a9b39a47a55f090adbdc94804bfd3a14000e8a6e8dcd2f711` | NON_KILLED | TIMED_OUT | 24 | 134 | +| 5 | `2155f6a06a7837e9d35bc4d2318d244639ff8f6095564243149e0b8918557325` | KILLED | KILLED | 28 | 163 | +| 6 | `e200bda99ea856d765bdebe57b8fd5b0371f9d948c31eed3b4b3fb39000be406` | NON_KILLED | TIMED_OUT | 36 | 216 | +| 7 | `319df689ce2b58ca09f69673e63be5d46ca210a4306f5ca057d6780dacd6768c` | NON_KILLED | TIMED_OUT | 43 | 298 | +| 8 | `8c909e76359d54e15ed4f892d5fd3763402b9a2c967c4a38d8d0612954358dfc` | NON_KILLED | TIMED_OUT | 49 | 380 | +| 9 | `f43a1391235fa3af0911ecc49adf3d9556deb3c4d273a74c4d23cd2b9b001403` | KILLED | KILLED | 53 | 432 | +| 10 | `9e05d5b73798f8e90a50de3460dfb21a4fb853fec8bd1bededcbab6f42df19da` | NON_KILLED | NO_COVERAGE | 64 | 480 | +| 11 | `216bc036a8ad39df78010e2a47317c344aeed1eceb26a9f02baa86bd8efcd628` | KILLED | KILLED | 71 | 543 | +| 12 | `0c6a0f5da99ccc30abfb217561fae68d69dc66fbc9ecb83af70a25b4ccc4d554` | NON_KILLED | NO_COVERAGE | 81 | 588 | +| 13 | `cfa7fa1e346a7cec464cfb674699b00f1ad7bfc4be02815700f5656fd6f6148a` | KILLED | KILLED | 87 | 650 | +| 14 | `05abfbc4e98099ec67da3968eac4760f3d3e3d450951a03bed5a3c680ae61774` | NON_KILLED | TIMED_OUT | 93 | 695 | +| 15 | `bb7c63ab70f627db63573fb75cab2e3f899ae3eb1ba8cbbd8699c65b6d737b51` | NON_KILLED | TIMED_OUT | 106 | 796 | +| 16 | `a9e240548004e6069badf22c734284702c95dbc463696a949eadc578f8fab267` | NON_KILLED | TIMED_OUT | 121 | 927 | +| 17 | `e52fd6ae714d4313ce487f557d2a39b7423478038f3ffc1e7755eea81ccce667` | NON_KILLED | TIMED_OUT | 138 | 1064 | +| 18 | `692b201e3aa17a96282b2540feca325a10e9f599a825bf6646eab69a41bdfcc6` | NON_KILLED | SURVIVED | 146 | 1154 | +| 19 | `1fb81d5d3133a74314590ac5dca4d3e3c9d5648a7e33d3f64420ccf9a3676e55` | NON_KILLED | TIMED_OUT | 160 | 1214 | +| 20 | `995ddfdf0973289bd2aaae3c7ac6d5d193bddb254942c68a214532601f15ad38` | KILLED | KILLED | 170 | 1317 | +| 21 | `f2f3de5d9785c42b69876a9d63a22a7cbfcb84a7b2412ff6e16d65fcfc6db83d` | NON_KILLED | TIMED_OUT | 184 | 1377 | + +**P7** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation` — 1 base-only identities, uniform (NON_KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `0a1c3c1c58bbd3fbc81a661140302ed7924b88eabc3f4cb13ba994b81ec09691` | NON_KILLED | SURVIVED | 166 | 1305 | + +**P8** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/engine/approval/ReplayAuthorizationService::emitAuthorizationReplayed` — 1 base-only identities, uniform (KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `665f7e153ec5c9a5183074ecce43b947aa673ecc7ea783888fcebffe8a692533` | KILLED | KILLED | 98 | 751 | + +**P9** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` — 13 base-only identities, uniform (NON_KILLED=13) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `82fe089c156d3cab018d2c369c1d9a579b3437dcb4f60ad52a00c2e7b5395fcd` | NON_KILLED | NO_COVERAGE | 13 | 85 | +| 2 | `546a615f4436ae3c4551bac83587debf19f41acb30f77ddf50ac1a032a908b39` | NON_KILLED | NO_COVERAGE | 26 | 156 | +| 3 | `ef0c447154578eb65b0eab3c9a5eb7d6dae5fdb9a973000c141659b3fc7fee12` | NON_KILLED | NO_COVERAGE | 38 | 248 | +| 4 | `1d9dc7a8c9b27ecacc1ce9a793dd858006fa2756f9af04459e7f0ee5344bb5e4` | NON_KILLED | NO_COVERAGE | 45 | 340 | +| 5 | `ac0be306eb26093e449960234cc23ae4360f2a9f24ce7755548d91c41a8cf0e2` | NON_KILLED | NO_COVERAGE | 51 | 422 | +| 6 | `30fe02f32b41cb86a7cc26fc2c9ca8daae09d0f876a1640261c3bcfe8b8d1985` | NON_KILLED | NO_COVERAGE | 66 | 527 | +| 7 | `c4c37ffff62b410b50781206c508dd2116afe304d0b6352711342bbdfb8a5b6a` | NON_KILLED | NO_COVERAGE | 83 | 635 | +| 8 | `ba6ebc3e7076aad611c1b8b8e1a2c8bb32fb8e9023083786e0cb9aa11b10683d` | NON_KILLED | NO_COVERAGE | 95 | 737 | +| 9 | `ec7d1ab598d682932fc840658003cc3de493213cb7f7bec10ef1e9fa2b57043f` | NON_KILLED | NO_COVERAGE | 108 | 843 | +| 10 | `ebf31b386086133eb9870daff2229bd15e1d1950dfade56d84ce97ccd2a1008f` | NON_KILLED | NO_COVERAGE | 123 | 991 | +| 11 | `f2d34adda4b651689c1314670d1c4c24f767f4e0a4e5f25c69d0f27519203a9c` | NON_KILLED | NO_COVERAGE | 140 | 1131 | +| 12 | `cf1441ce7d467171fecd593d5b3396dae9e746cca078d995de70022c95c6d024` | NON_KILLED | NO_COVERAGE | 162 | 1281 | +| 13 | `f05dac1eeae60233c8063f5e5d6d51936c4160d819cd58b1bc072e03793e685f` | NON_KILLED | NO_COVERAGE | 186 | 1444 | + +**P10** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` — 14 base-only identities, MIXED (KILLED=1, NON_KILLED=13) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `083b220893a987e60174fe419f93fed1a68054923ea384effe2dc1fb5fd3fa1d` | NON_KILLED | NO_COVERAGE | 12 | 72 | +| 2 | `b7b2dfec2fa09b6eb182fea85d233cc87faac535cc65c56da85825d1a80b68c7` | NON_KILLED | NO_COVERAGE | 25 | 138 | +| 3 | `efacd2b1e0f485e273560d796700fa9827d03c93e51328b758068d34f4ace86a` | NON_KILLED | NO_COVERAGE | 37 | 220 | +| 4 | `c52b419c90e9aeaf63ea6a1d8c7cba7eea201ad5aca14a2fad28107c219f3de9` | NON_KILLED | NO_COVERAGE | 44 | 302 | +| 5 | `3555704187bd3a2f436216918a2c486828d59193985ee03b9de3f58638a3d9d7` | NON_KILLED | NO_COVERAGE | 50 | 384 | +| 6 | `246350bcbd765bab30f01bd098a4ac4343dca32a12b62313fb86886269547f11` | NON_KILLED | NO_COVERAGE | 65 | 484 | +| 7 | `976f65569f36d2e3edd8a235b9ff9ebfaaa571be0c3acd960b1df205ef0b7197` | NON_KILLED | NO_COVERAGE | 82 | 592 | +| 8 | `32df7c11c787780f1f4858c17032291bca4c707c06e677736a5d1b7f47231811` | NON_KILLED | NO_COVERAGE | 94 | 699 | +| 9 | `0e8a40e624e101b8387b3e5657c69280b6b6b5c5f9c395a1129aed7c23fb8dd5` | NON_KILLED | NO_COVERAGE | 107 | 800 | +| 10 | `b1e34f861946bca679216e4b049070608e98c66fab5f766f229d3c3671b640bb` | NON_KILLED | NO_COVERAGE | 122 | 931 | +| 11 | `319b7bee9207f52fc24858a6c755ed014c79ab39a252c7c3292f1e8799c49d3a` | NON_KILLED | NO_COVERAGE | 139 | 1068 | +| 12 | `d64b7292b499228c8682560ed181e1067cff585542f5d1af25caf413e37f232d` | NON_KILLED | NO_COVERAGE | 161 | 1218 | +| 13 | `f0cf0add6920df8153ee3ae07249fb86b8f771a5879482a900addd12af1faf96` | NON_KILLED | NO_COVERAGE | 185 | 1381 | +| 14 | `0110d7f7665b47ebc3bf1cea5a362b545205a50795ab96652968a21ae4677f22` | KILLED | KILLED | 189 | 1457 | + +**P11** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 2 base-only identities, MIXED (KILLED=1, NON_KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `c479cc1c69a1d68182be1c0c3a69714d5700105821024efb7ef1419fe1a87808` | KILLED | KILLED | 23 | 130 | +| 2 | `26ff0a87eb1ba6c06a77256c3a16d075d9db7d7fb107ed4077380a3f5686dc56` | NON_KILLED | TIMED_OUT | 31 | 169 | + +**P12** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` — 1 base-only identities, uniform (NON_KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `189b579940ae63f9aba4a4be8f21e4c9e6e043e4186d838cb76bce1a8410494e` | NON_KILLED | NO_COVERAGE | 33 | 204 | + +**P13** — `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::revealAndValidateReplayPayload` — 2 base-only identities, MIXED (KILLED=1, NON_KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `83cb636cae4b11deeaf924238c4227f9df48436ece99b3b35337cdab42a897ef` | NON_KILLED | NO_COVERAGE | 32 | 173 | +| 2 | `e2ccc920c43c3a0337efc61a8ba030edac2840deb59ea80ebeaa0f38c0fe6796` | KILLED | KILLED | 37 | 222 | + +**P14** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 12 base-only identities, MIXED (KILLED=5, NON_KILLED=7) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `5e13cf15e29cb717376bada0008b5a705dcee0a70341f96df2b19f625de33678` | KILLED | KILLED | 8 | 50 | +| 2 | `acb3b3ec95bc51217b7214b5893cb46dc2a9982d6b88789f86ab2fd5d5db0a42` | KILLED | KILLED | 11 | 71 | +| 3 | `73418182fa85726be12904f6164e3ecf6fe9b86519aece34982bc6306719fa5a` | NON_KILLED | TIMED_OUT | 35 | 190 | +| 4 | `97b1be0758dead1ecc207615a95d60a8943690305890679308f2978fa69848aa` | NON_KILLED | TIMED_OUT | 60 | 363 | +| 5 | `4a35eba5008e2026f17ec0991f08de3057f69dc2ff375d77f7d1058384de247f` | KILLED | KILLED | 66 | 429 | +| 6 | `350a94d49928183879837011ca2899707ec297b2f4eb9cd31dde17253bda0534` | NON_KILLED | TIMED_OUT | 107 | 618 | +| 7 | `88dec573bedb6aaa046b85e47990e5cdeb03e8b3be04d20713037e6efd347ef3` | NON_KILLED | TIMED_OUT | 128 | 786 | +| 8 | `4fc4c332ed039bc77619dd96bb5ed5a7d7993c39939d3d739da525222fc67fd8` | KILLED | KILLED | 143 | 911 | +| 9 | `6d79865a905314b94c87f8c173cf629a32c85ac57f37157a9031ee80c30481a3` | KILLED | KILLED | 145 | 919 | +| 10 | `5a8ca9067f040c03972d7dceb0e2137b79b1c91d1899d2f22f8f6aaa49c2b163` | NON_KILLED | TIMED_OUT | 151 | 986 | +| 11 | `b0e724190b292778c4e4903adeda1dfa5201ebb94ed6167d3f493069b4799698` | NON_KILLED | TIMED_OUT | 165 | 1135 | +| 12 | `1f025dec8de783d77bca435fe618c6a819fec0be91caeba7b3c6aa8e298d5d53` | NON_KILLED | TIMED_OUT | 181 | 1286 | + +**P15** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation` — 4 base-only identities, MIXED (KILLED=1, NON_KILLED=3) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `65778562d1a90086389a488009846170d75153ff4bf10485ef62dcbb37988fec` | KILLED | KILLED | 142 | 906 | +| 2 | `8b266859888e6063a44fcf410f2e9e208ac54739ee2317ebc090961534afd9eb` | NON_KILLED | NO_COVERAGE | 157 | 1068 | +| 3 | `a3aa225ceecd19dec371f1904fe6b60481f3cebdeaae4dbc55f3f219ce43ad5d` | NON_KILLED | NO_COVERAGE | 171 | 1217 | +| 4 | `d68d2a536541e1b4f1de71ca49b5b34aa6a7a8ce25006496cf0e2c3ad8ac82e7` | NON_KILLED | NO_COVERAGE | 187 | 1368 | + +**P16** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` — 7 base-only identities, uniform (NON_KILLED=7) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `0aacdd75008dc597dfc796387bc7138d3efdde59a54f7abce241ee5013ce4581` | NON_KILLED | NO_COVERAGE | 37 | 232 | +| 2 | `d0bb3df5d0eafe76663fb617ea2bcaacad24af2b38bccfd85a961180039d5672` | NON_KILLED | NO_COVERAGE | 62 | 412 | +| 3 | `5afb3c677c23a861ed8eafdbcaf88cb0bd467c2bcf57f49a2af1026b770556ef` | NON_KILLED | NO_COVERAGE | 109 | 692 | +| 4 | `5a41f3ffc662fa55d35dba1909b94c1848dedc1146268ab889f7df98ac9abd34` | NON_KILLED | NO_COVERAGE | 130 | 860 | +| 5 | `364e06b64a99a1b4a086b6d003d62eae024d6c38e42ab21c25129df101fd635d` | NON_KILLED | NO_COVERAGE | 153 | 1044 | +| 6 | `e0f4a6a950eb63f0639eec3c497921fa703d01b12def70603dbc0d4b56fbd110` | NON_KILLED | NO_COVERAGE | 167 | 1193 | +| 7 | `261e4a81cca1c5cd532e953501c99d467fed7993b8b35f4bc27eb046bf809640` | NON_KILLED | NO_COVERAGE | 183 | 1344 | + +**P17** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/jvm/internal/Intrinsics::checkNotNull` — 1 base-only identities, uniform (NON_KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `14d9afc3f3883a5228acb5af40dfb349e68f18c47d101d3c002d7381a5775a17` | NON_KILLED | SURVIVED | 31 | 155 | + +**P18** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/jvm/internal/Intrinsics::checkNotNullExpressionValue` — 1 base-only identities, uniform (NON_KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `d8fb778015c5a217e1a7baac454ec8e153d4019cb664750a99ce924aa9fdfe05` | NON_KILLED | SURVIVED | 54 | 297 | + +**P19** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution` — 7 base-only identities, uniform (NON_KILLED=7) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `5ecf004235db47c42fbba875f40c8bada66e1714a0c4cf3804787bfd94fd2b82` | NON_KILLED | NO_COVERAGE | 36 | 194 | +| 2 | `8d7e1fa01d045eeba98fd6f90840468e02875757e3c7270f150d812a47298895` | NON_KILLED | NO_COVERAGE | 61 | 367 | +| 3 | `72555a80f9468d15109776a23d4b48105afd7dae4c688b774353706a3dd8f7fb` | NON_KILLED | NO_COVERAGE | 108 | 622 | +| 4 | `b7ddd5aaa42259755c26e7d4d36f66abfebc82de7108278f0e5ac5e85c13acd3` | NON_KILLED | NO_COVERAGE | 129 | 790 | +| 5 | `cfe2ccb893ca48be3894ee6d61957c9b732a16a8830edb66f4aac1ae41d308cc` | NON_KILLED | NO_COVERAGE | 152 | 990 | +| 6 | `3aa7e614aa6ac84aaecf1a4fb0d54e670bddc881b07f59a35b50f1cc9f1b62cd` | NON_KILLED | NO_COVERAGE | 166 | 1139 | +| 7 | `35c0b8afec49c52d4f4e939cbb0fb5bbd9e4aeb65a06ebabf3c432bb03d2e36a` | NON_KILLED | NO_COVERAGE | 182 | 1290 | + +**P20** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|validateInitialApprovalRequirement-zjKNo-Q|(Ldev/tramai/engine/tool/ToolExecutionRequest;Ldev/tramai/core/policy/PolicyDecision$RequireApproval;Ljava/lang/String;)Ljava/lang/String;|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary` — 2 base-only identities, uniform (KILLED=2) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `f7d77ffa48e3b779d4b3bdd325816b513aa52f37f44361d0daf5aac4abff5b46` | KILLED | KILLED | 19 | 70 | +| 2 | `35a85692107c57e4ca7bb4ae19e3824dabcf2276a70a70e01afb72e0ed061de2` | KILLED | KILLED | 31 | 110 | + +**P21** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|validateInitialApprovalRequirement-zjKNo-Q|(Ldev/tramai/engine/tool/ToolExecutionRequest;Ldev/tramai/core/policy/PolicyDecision$RequireApproval;Ljava/lang/String;)Ljava/lang/String;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 5 base-only identities, uniform (KILLED=5) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `8e8f88d468af58cc0a5533980fb74d476432d9e90cd71aa826714ca6e769828c` | KILLED | KILLED | 19 | 70 | +| 2 | `38958541295a62e3daa09e3781f9eb466a913b8a68c0cc09d58c2e033e25cb6b` | KILLED | KILLED | 22 | 79 | +| 3 | `87dcca678e077eb7f49987f8bd0ec806a643f6bbba7f203b2d3a8326772adcd3` | KILLED | KILLED | 26 | 88 | +| 4 | `47b75ca5bb946fce6a09beebbebf9912d83d4946a9d3a6e313ebdf462222d93a` | KILLED | KILLED | 31 | 110 | +| 5 | `909339da34dde55b76a7b1043465b145c79b0889d2e360bde0756791c58ae253` | KILLED | KILLED | 34 | 118 | + +**P22** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|validateInitialApprovalRequirement-zjKNo-Q|(Ldev/tramai/engine/tool/ToolExecutionRequest;Ldev/tramai/core/policy/PolicyDecision$RequireApproval;Ljava/lang/String;)Ljava/lang/String;|org.pitest.mutationtest.engine.gregor.mutators.returns.EmptyObjectReturnValsMutator|replaced return value with "" for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::validateInitialApprovalRequirement-zjKNo-Q` — 1 base-only identities, uniform (KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `a5a4c02eda08ced6c11f295e09b7a01670bdd737d853db22248198667e3c354c` | KILLED | KILLED | 38 | 139 | + +**P23** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|validateRenewedApprovalRequirement|(Ldev/tramai/engine/tool/ToolExecutionRequest;Ldev/tramai/core/policy/PolicyDecision$RequireApproval;Ljava/lang/String;)V|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary` — 1 base-only identities, uniform (NON_KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `b5217c93437c82fa7f6516f742eb044f23d19f9f5f6fd022d09d15afb66d2eb2` | NON_KILLED | SURVIVED | 43 | 147 | + +**P24** — `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|validateRenewedApprovalRequirement|(Ldev/tramai/engine/tool/ToolExecutionRequest;Ldev/tramai/core/policy/PolicyDecision$RequireApproval;Ljava/lang/String;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 7 base-only identities, uniform (KILLED=7) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `780c644a8598c145eb32f407bd7bfe9cff29be63b081db0752103dd7f9a9a718` | KILLED | KILLED | 19 | 72 | +| 2 | `83d1ef42f35ca1df37706e5b40916522be7b46303e0b77452bb0c2f671dace71` | KILLED | KILLED | 28 | 99 | +| 3 | `b33d3e8cc0a1eeba34b1b4220ffe8cf736ab882bfc7816046a303c46654e0bf6` | KILLED | KILLED | 31 | 108 | +| 4 | `3523d4890c1d86eb5799b72af98aa2b10e732db3877faaacd8af5236a68772b5` | KILLED | KILLED | 35 | 115 | +| 5 | `c869df82a567053d34ba7a512a4418c6a8ac0cfcfe0caf8303badc0924ecace8` | KILLED | KILLED | 38 | 125 | +| 6 | `142d3419d52f38a97243c03b42ab2f3fdb463f282d2e4ff1978fdbed1284fdfa` | KILLED | KILLED | 43 | 147 | +| 7 | `56fb3e2cb039aab55a0c0cd6348b13dac467e1f9de7ef1a526edd6f85fae9738` | KILLED | KILLED | 46 | 155 | + +**P25** — `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 6 base-only identities, MIXED (KILLED=1, NON_KILLED=5) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `947f05488df35965fa51bf4673beb3e92a14e4406ec31c6f66bca3bb7e8ca4e1` | NON_KILLED | TIMED_OUT | 13 | 86 | +| 2 | `5ffadc16dab2dcfa5c8b310d7c7631633e1ca3094981733343926fff913b1731` | NON_KILLED | TIMED_OUT | 24 | 153 | +| 3 | `e236ea3f276e38d9e26dd11dd5c20a1bdbbe4a0f52cbf7e890051d0abd095339` | KILLED | KILLED | 28 | 193 | +| 4 | `afba54a83e116a798977913ddae1941d5c76f01827322f4c3af6e6f49ea6a1f5` | NON_KILLED | TIMED_OUT | 38 | 241 | +| 5 | `a76865f870dc0c2ec6a1b4feb5a304a3374b6470104178b235273b64283d72fd` | NON_KILLED | TIMED_OUT | 50 | 326 | +| 6 | `1bfda558c38bac3cde86dd8bc03fbf4253f2029d968311d650df2d72087c781c` | NON_KILLED | TIMED_OUT | 62 | 411 | + +**P26** — `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` — 5 base-only identities, uniform (NON_KILLED=5) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `3db41731fa0393fe43aaa0288207c8ebf692b552b0c98379514bc6f9929a1551` | NON_KILLED | NO_COVERAGE | 15 | 110 | +| 2 | `7d5515ebf1454998b4df3ccf0f10795dda859f3ef4fc8af24f725fafb01b4b42` | NON_KILLED | NO_COVERAGE | 26 | 184 | +| 3 | `02c365c59375551744b483b46d39794b8e93491e53bfdf339a4cff1e4ea59c51` | NON_KILLED | NO_COVERAGE | 40 | 277 | +| 4 | `d91f6922ebfe647243fb65df46a7557d087d830be640be56629bd4dd8b5e65f2` | NON_KILLED | NO_COVERAGE | 52 | 362 | +| 5 | `5c2c8235fab20ded12284021db2aae1fcddf3144441d95dea0a9d02ed8b22e8f` | NON_KILLED | NO_COVERAGE | 64 | 447 | + +**P27** — `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo` — 7 base-only identities, MIXED (KILLED=2, NON_KILLED=5) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `4681686f2f267cb553f68d413c5d1d0ffdda484edb42584dcaa1cf839e0fd537` | NON_KILLED | NO_COVERAGE | 14 | 90 | +| 2 | `46159f4c1a0f9c057292497389e25cbbe69d792126307ae5667a45a2069682e1` | NON_KILLED | NO_COVERAGE | 25 | 157 | +| 3 | `b91468eee9d536c3d229a8ed57ec7513ce52c450b0824e6f004dc4b7ce1318cb` | KILLED | KILLED | 30 | 202 | +| 4 | `6e45cd86eec24bafbc7b2b6b78b0a5145167b3199d2cf62c4c9f7c6025f2720c` | NON_KILLED | NO_COVERAGE | 39 | 245 | +| 5 | `fefd169fd4b29701e21b49169db1d6ce9546fdcd91c132d9b2f3587d089330a0` | NON_KILLED | NO_COVERAGE | 51 | 330 | +| 6 | `246b6fba6aabb668aa580497138761695987f6d3d10ec41f3613842cd74abb77` | NON_KILLED | NO_COVERAGE | 63 | 415 | +| 7 | `37470da71000d7f3ffed6b39c5cb491e0c411a15cfe80d66c6f5a3ba9ed6df82` | KILLED | KILLED | 80 | 500 | + +**P28** — `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|toGatewayResult|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ljava/time/Clock;)Ldev/tramai/core/approval/gateway/ApprovalRequestResult;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 8 base-only identities, uniform (KILLED=8) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `793981f98769edc8e547086de3566bbdcafdd72785b0cbec1e38f5546ed073bd` | KILLED | KILLED | 6 | 30 | +| 2 | `83509ebce4e0058761aa64637f20762b99bcb779272d6bbacf8b8dff4721288d` | KILLED | KILLED | 11 | 56 | +| 3 | `0439bb0607903bc6f27d61149dea58b6c4e2bb8dd7688ecba4108c4234ea3fb2` | KILLED | KILLED | 20 | 115 | +| 4 | `e1fcc10b0931789c0c691229bbf439436ae323ed91ee4e3557c0214a3af20508` | KILLED | KILLED | 22 | 125 | +| 5 | `0cbf1d2df2070824efe130be900a5a4b791286d6ddede16c07c669e79451319a` | KILLED | KILLED | 27 | 151 | +| 6 | `9be7727f6dc42ae7165113c44d2c7a7bbcd080721fa941a1f0d9e36716674c75` | KILLED | KILLED | 32 | 180 | +| 7 | `a56c34f5b7ff9ed9ddb56ac2d09de44c737792ccbfd6ed140939aa72e18a4f9e` | KILLED | KILLED | 38 | 221 | +| 8 | `e62f20c11017a5691291ed7bbd2370b8c90cda0651f701eef711b5edd8636230` | KILLED | KILLED | 41 | 226 | + +**P29** — `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|toGatewayResult|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ljava/time/Clock;)Ldev/tramai/core/approval/gateway/ApprovalRequestResult;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::toGatewayResult` — 1 base-only identities, uniform (KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `ef4a38a37da4f0accf5e3d5119d9947c209a0448d0ba7f719bcf39d88ccaa57e` | KILLED | KILLED | 54 | 280 | + +**P30** — `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateDecisionKind|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 1 base-only identities, uniform (KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `b8d57f3a8cfa01752a4a2f21d17c7dd2df0e3f981a5fd0da4f15484c53cc301a` | KILLED | KILLED | 11 | 41 | + +**P31** — `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary` — 4 base-only identities, MIXED (KILLED=1, NON_KILLED=3) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `9da71de67474488109161790ab792973e3422d1bf5dfec8f2b3f1fd665470769` | NON_KILLED | SURVIVED | 57 | 204 | +| 2 | `12dd45b419d9cea7f66740220df91700c04dd5b7bb6ef7364d059431912684e0` | NON_KILLED | SURVIVED | 70 | 252 | +| 3 | `03fe978066fab6ab5d9bb78942e45cb880aa526340e0cebb71fe9bfed6d368b3` | KILLED | KILLED | 98 | 385 | +| 4 | `a9745066815e3b7cf055ffa8924ae4afb7faf61f59ccf2ec1085c8fe28e71b2b` | NON_KILLED | SURVIVED | 111 | 433 | + +**P32** — `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 37 base-only identities, MIXED (KILLED=29, NON_KILLED=8) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `ca0bada2431c7ecdbd1a9ffb4c012addb0e37423e5f7aacad00d26ba2c70f9de` | KILLED | KILLED | 13 | 45 | +| 2 | `abcb69cf55a048b99298691762a51399a8baeb1eb5d3837bd35d51d9fd41d98f` | KILLED | KILLED | 16 | 55 | +| 3 | `477068444b350a2e226b5084e9645910308be0e4fa0bc6659952bd90f8abe60c` | NON_KILLED | NO_COVERAGE | 25 | 92 | +| 4 | `4288c9ca4e8d2eb4d9c48c96adac8d0f500bc787c2b283db6bb04dc9bba4e09f` | KILLED | KILLED | 28 | 99 | +| 5 | `aff15f3550cd791ef2acbd1d2212086a16c88909fb625420e1e097208d895020` | KILLED | KILLED | 31 | 106 | +| 6 | `7165c3227c30f421f2a6c21d97fa9a75c3ff5762aa1d678ff18176daba836bf9` | KILLED | KILLED | 35 | 120 | +| 7 | `75833d2d554ecf815439ce2b859ac1edef77e21ad3437f29aef612ccb184ae1b` | KILLED | KILLED | 38 | 128 | +| 8 | `4b373fdebbcea1fb830db688fa8e6bdcbeb0b40618e3eaef1d254de9f64e44bd` | KILLED | KILLED | 44 | 156 | +| 9 | `7d907ad74364b799b4f4e25a08dabea840396230a76c160859c34163a0781df1` | KILLED | KILLED | 47 | 164 | +| 10 | `26e44096428c398823df0c11ce34b731f450bb3200975bf8b06e88d4470d3790` | KILLED | KILLED | 53 | 192 | +| 11 | `53697b2d077eb7f8a97e57a484ecc2aab4d1d3e1d9906fa0d2fdd45a8a5125c0` | KILLED | KILLED | 55 | 201 | +| 12 | `8e228ad7ea2de8b0d228514e80bb75751d4035a62b95b3a22c007c50c15be08c` | KILLED | KILLED | 57 | 204 | +| 13 | `ac83ce2e9393996022e1ee3893d986230d9958ce30681a9da19cc56eaa955b98` | KILLED | KILLED | 60 | 212 | +| 14 | `7dda948fc68f908e39fa404895f1ecc40db43eb61934a37db5b64c3e686518f3` | KILLED | KILLED | 66 | 240 | +| 15 | `fafed97d42fd45c1506cefc9366a245205388b7a0c6161be0d4ca8b936a6168a` | KILLED | KILLED | 68 | 249 | +| 16 | `46184b1a9fabe9897daaf9a97aa03a59913d34294700f705589e3b02c20a48bf` | KILLED | KILLED | 70 | 252 | +| 17 | `d9c41d45bb176b283d0d39baf8894ae8ff967188553fd30d841fbc631e5f9a31` | KILLED | KILLED | 73 | 260 | +| 18 | `e0f8eeb27938e0d7af7efedf900468efd48365d9112db42738502eaea8e29306` | KILLED | KILLED | 81 | 320 | +| 19 | `b02d448cd873c73cf74b6b5390f54e564adab46fe09a5adc7300526bd2dad1f4` | KILLED | KILLED | 85 | 336 | +| 20 | `51abee92922e3ad7b7f87997cc2a5f853e05d0e78f6ae88e98c183dc50e4337c` | KILLED | KILLED | 88 | 344 | +| 21 | `55e523c4e3033a325d53843dbdbd94f28912d108701154a7d0f6b7dae032b8ef` | KILLED | KILLED | 94 | 373 | +| 22 | `8c5fcb2064c4f0ea482a072ba706a3ea9b5be3a6b0f9eea40d88d201c2274ae0` | KILLED | KILLED | 96 | 382 | +| 23 | `e6c50edec0cc1af56b952f0ad029ee551cce5ab99993023ddab63cad67ece9d5` | KILLED | KILLED | 98 | 385 | +| 24 | `da7de7326bbe60e83053559e8084d634ba34393609fdff1d3172db8ff03804f9` | KILLED | KILLED | 101 | 393 | +| 25 | `14e6955eae1ddeab6d7d79a008799fdaadc581ca57ac4b4ec1377e7325fdd495` | KILLED | KILLED | 107 | 421 | +| 26 | `4143d82d74783fd4a1a85977e537dd881dbccc99af532924bcff5c9def7d618c` | KILLED | KILLED | 109 | 430 | +| 27 | `6a5baa99db06765b80b13d14473214bb35a51ab808672dd9f26ffd797d9f91ef` | KILLED | KILLED | 111 | 433 | +| 28 | `1082270f835b3e30e90bbf29f7d5e2d3a6bccbe9b00cb762f166b87d820cf73f` | KILLED | KILLED | 114 | 441 | +| 29 | `b2527301e99ae9ec14f83a75f174fa18ede119c136c8d125e32fee05b9609dea` | KILLED | KILLED | 120 | 469 | +| 30 | `d8540bd51f41b9f72ba4e0f27779a3440109b55a5d72b8e3505aec99dd47aa34` | KILLED | KILLED | 122 | 475 | +| 31 | `0cfa472a9557aa62c1b1c1b54d9932cbfb05700b1e908e26c8a5f4f43bf906fc` | NON_KILLED | NO_COVERAGE | 128 | 507 | +| 32 | `d3d159737c1734ff7859ff865f2fbf00f5991c62aba4cba08fe69bc7b928dfab` | NON_KILLED | NO_COVERAGE | 130 | 510 | +| 33 | `0745e3647ebf1461f19be9d959396415cc71379a9481ff83431539fe4a3220a6` | NON_KILLED | NO_COVERAGE | 133 | 521 | +| 34 | `fa4e11a4ecb4ae8c1654cf8e53cb65e0045af41ff5bc62e6074dc9cfa355ea1d` | NON_KILLED | NO_COVERAGE | 136 | 529 | +| 35 | `b1e44ec991236dfb2e879ad8d510c923e493e5d8da377fcae62907fa2cb688e7` | NON_KILLED | NO_COVERAGE | 144 | 559 | +| 36 | `4e9834c5b54021afc27e8a86417cc3404f0bcf8d144ce757e99ce93cdef06366` | NON_KILLED | NO_COVERAGE | 151 | 590 | +| 37 | `86e1f7ed67ea39312b9fae991a44369f62f4e1181c29aecf8796b82ab1139d68` | NON_KILLED | NO_COVERAGE | 153 | 596 | + +**P33** — `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateSourceComponent|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` — 1 base-only identities, uniform (KILLED=1) + +| # | base-only identity | outcome | raw status | block | index | +|---|---|---|---|---|---| +| 1 | `9d9e99ebd21cb86472bf128efbb919be11d36784987c38acb14179e5bc118e95` | KILLED | KILLED | 11 | 41 | + +### 6.2 Category A — UNAMBIGUOUS_INHERITED_NON_KILLED (37 identities) + +| # | candidate identity | raw status | family | module | class | method | mutator | description | block | index | source-level key | predecessors at key | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | `021b3c457a5dcdc319f98c4c3154904799439c329a704355633fbbad00891eef` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 41 | 378 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `6371b23fe469`(NON_KILLED/NO_COVERAGE), `4e1e2df1c11e`(NON_KILLED/NO_COVERAGE), `527a99cc5db7`(NON_KILLED/NO_COVERAGE) | +| 2 | `04c2e1c3f1b8d7c2e9a6e9aa61c87d699cb90b432de6729730f87d302357cb28` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 134 | 936 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `0aacdd75008d`(NON_KILLED/NO_COVERAGE), `d0bb3df5d0ea`(NON_KILLED/NO_COVERAGE), `5afb3c677c23`(NON_KILLED/NO_COVERAGE), `5a41f3ffc662`(NON_KILLED/NO_COVERAGE), `364e06b64a99`(NON_KILLED/NO_COVERAGE), `e0f4a6a950eb`(NON_KILLED/NO_COVERAGE), `261e4a81cca1`(NON_KILLED/NO_COVERAGE) | +| 3 | `1843d3a31bd38b547f6615d450d411c3a1810340d4160797d653db7efd1e734a` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 36 | 210 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution` | `5ecf004235db`(NON_KILLED/NO_COVERAGE), `8d7e1fa01d04`(NON_KILLED/NO_COVERAGE), `72555a80f946`(NON_KILLED/NO_COVERAGE), `b7ddd5aaa422`(NON_KILLED/NO_COVERAGE), `cfe2ccb893ca`(NON_KILLED/NO_COVERAGE), `3aa7e614aa6a`(NON_KILLED/NO_COVERAGE), `35c0b8afec49`(NON_KILLED/NO_COVERAGE) | +| 4 | `1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 91 | 641 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation` | `0a1c3c1c58bb`(NON_KILLED/SURVIVED) | +| 5 | `30f9bb41fa82566d6965603b16558d9b8c3943309ddb3240ba6462fca25d7173` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 159 | 1118 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `0aacdd75008d`(NON_KILLED/NO_COVERAGE), `d0bb3df5d0ea`(NON_KILLED/NO_COVERAGE), `5afb3c677c23`(NON_KILLED/NO_COVERAGE), `5a41f3ffc662`(NON_KILLED/NO_COVERAGE), `364e06b64a99`(NON_KILLED/NO_COVERAGE), `e0f4a6a950eb`(NON_KILLED/NO_COVERAGE), `261e4a81cca1`(NON_KILLED/NO_COVERAGE) | +| 6 | `3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/jvm/internal/Intrinsics::checkNotNull | 31 | 168 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/jvm/internal/Intrinsics::checkNotNull` | `14d9afc3f388`(NON_KILLED/SURVIVED) | +| 7 | `3b12d1a55c6b59c5906de2128d72e8df33f8df56ba26c33d432312e2e012a64a` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 133 | 857 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution` | `5ecf004235db`(NON_KILLED/NO_COVERAGE), `8d7e1fa01d04`(NON_KILLED/NO_COVERAGE), `72555a80f946`(NON_KILLED/NO_COVERAGE), `b7ddd5aaa422`(NON_KILLED/NO_COVERAGE), `cfe2ccb893ca`(NON_KILLED/NO_COVERAGE), `3aa7e614aa6a`(NON_KILLED/NO_COVERAGE), `35c0b8afec49`(NON_KILLED/NO_COVERAGE) | +| 8 | `441d33435645798ccbb4599430cd3cd44ba0429f94eff009d3ec2bdd00d893d0` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 16 | 112 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 9 | `453e4aaf232b4d80c83653952228658084fe9e3cb56fb91b49071b115aaaa45b` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 158 | 1071 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution` | `5ecf004235db`(NON_KILLED/NO_COVERAGE), `8d7e1fa01d04`(NON_KILLED/NO_COVERAGE), `72555a80f946`(NON_KILLED/NO_COVERAGE), `b7ddd5aaa422`(NON_KILLED/NO_COVERAGE), `cfe2ccb893ca`(NON_KILLED/NO_COVERAGE), `3aa7e614aa6a`(NON_KILLED/NO_COVERAGE), `35c0b8afec49`(NON_KILLED/NO_COVERAGE) | +| 10 | `4f99ba33ee70bdc6f023171d803dc5438e88cb16170b27516865e56fb355cbb4` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 110 | 672 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution` | `5ecf004235db`(NON_KILLED/NO_COVERAGE), `8d7e1fa01d04`(NON_KILLED/NO_COVERAGE), `72555a80f946`(NON_KILLED/NO_COVERAGE), `b7ddd5aaa422`(NON_KILLED/NO_COVERAGE), `cfe2ccb893ca`(NON_KILLED/NO_COVERAGE), `3aa7e614aa6a`(NON_KILLED/NO_COVERAGE), `35c0b8afec49`(NON_KILLED/NO_COVERAGE) | +| 11 | `5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 56 | 395 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 12 | `564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 13 | 96 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `0aacdd75008d`(NON_KILLED/NO_COVERAGE), `d0bb3df5d0ea`(NON_KILLED/NO_COVERAGE), `5afb3c677c23`(NON_KILLED/NO_COVERAGE), `5a41f3ffc662`(NON_KILLED/NO_COVERAGE), `364e06b64a99`(NON_KILLED/NO_COVERAGE), `e0f4a6a950eb`(NON_KILLED/NO_COVERAGE), `261e4a81cca1`(NON_KILLED/NO_COVERAGE) | +| 13 | `577c07309b70f484aa98cc6b07eb93c132e82198f86397d69963a2a95399afef` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 21 | 202 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `6371b23fe469`(NON_KILLED/NO_COVERAGE), `4e1e2df1c11e`(NON_KILLED/NO_COVERAGE), `527a99cc5db7`(NON_KILLED/NO_COVERAGE) | +| 14 | `5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 11 | 92 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `3db41731fa03`(NON_KILLED/NO_COVERAGE), `7d5515ebf145`(NON_KILLED/NO_COVERAGE), `02c365c59375`(NON_KILLED/NO_COVERAGE), `d91f6922ebfe`(NON_KILLED/NO_COVERAGE), `5c2c8235fab2`(NON_KILLED/NO_COVERAGE) | +| 15 | `5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 19 | 167 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `949c8ad8dfd4`(NON_KILLED/TIMED_OUT), `062c0e53f177`(NON_KILLED/TIMED_OUT), `dc0810d0cc14`(NON_KILLED/TIMED_OUT) | +| 16 | `62897c202ffe147b902f030a11767832f6d273bbb92f9276db1e5d956e1662a6` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 62 | 441 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `0aacdd75008d`(NON_KILLED/NO_COVERAGE), `d0bb3df5d0ea`(NON_KILLED/NO_COVERAGE), `5afb3c677c23`(NON_KILLED/NO_COVERAGE), `5a41f3ffc662`(NON_KILLED/NO_COVERAGE), `364e06b64a99`(NON_KILLED/NO_COVERAGE), `e0f4a6a950eb`(NON_KILLED/NO_COVERAGE), `261e4a81cca1`(NON_KILLED/NO_COVERAGE) | +| 17 | `62911071da6c7a9fe85f76a41c5a23d56b1156e2d1447dd96ccce962aabeff3b` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 33 | 205 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `189b579940ae`(NON_KILLED/NO_COVERAGE) | +| 18 | `65f7e7cc271623407239ce9b2d935ac0a2b3ea438c87600ee86b54fa21f96425` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `validateRenewedApprovalRequirement` | `ConditionalsBoundaryMutator` | changed conditional boundary | 43 | 148 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|validateRenewedApprovalRequirement|(Ldev/tramai/engine/tool/ToolExecutionRequest;Ldev/tramai/core/policy/PolicyDecision$RequireApproval;Ljava/lang/String;)V|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary` | `b5217c93437c`(NON_KILLED/SURVIVED) | +| 19 | `66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 12 | 78 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution` | `5ecf004235db`(NON_KILLED/NO_COVERAGE), `8d7e1fa01d04`(NON_KILLED/NO_COVERAGE), `72555a80f946`(NON_KILLED/NO_COVERAGE), `b7ddd5aaa422`(NON_KILLED/NO_COVERAGE), `cfe2ccb893ca`(NON_KILLED/NO_COVERAGE), `3aa7e614aa6a`(NON_KILLED/NO_COVERAGE), `35c0b8afec49`(NON_KILLED/NO_COVERAGE) | +| 20 | `6a33ba5d985ae4e67fc8f6f06b740880318078558c3973bf2c30f67c37a692c6` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 25 | 189 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `3db41731fa03`(NON_KILLED/NO_COVERAGE), `7d5515ebf145`(NON_KILLED/NO_COVERAGE), `02c365c59375`(NON_KILLED/NO_COVERAGE), `d91f6922ebfe`(NON_KILLED/NO_COVERAGE), `5c2c8235fab2`(NON_KILLED/NO_COVERAGE) | +| 21 | `6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 54 | 441 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `949c8ad8dfd4`(NON_KILLED/TIMED_OUT), `062c0e53f177`(NON_KILLED/TIMED_OUT), `dc0810d0cc14`(NON_KILLED/TIMED_OUT) | +| 22 | `6dcb09ca1cc8e2bb404aa0b4e8345aa185d10b00f019b81a268355d7c2ee83ef` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 62 | 467 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `3db41731fa03`(NON_KILLED/NO_COVERAGE), `7d5515ebf145`(NON_KILLED/NO_COVERAGE), `02c365c59375`(NON_KILLED/NO_COVERAGE), `d91f6922ebfe`(NON_KILLED/NO_COVERAGE), `5c2c8235fab2`(NON_KILLED/NO_COVERAGE) | +| 23 | `8210e97a204f70645bc2df52a4af2859a972a5dd3cbd24400c32f61284c72dc8` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 48 | 369 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `3db41731fa03`(NON_KILLED/NO_COVERAGE), `7d5515ebf145`(NON_KILLED/NO_COVERAGE), `02c365c59375`(NON_KILLED/NO_COVERAGE), `d91f6922ebfe`(NON_KILLED/NO_COVERAGE), `5c2c8235fab2`(NON_KILLED/NO_COVERAGE) | +| 24 | `87fbd8e6ed4176baf39df68250f43fbabc8082b0407e4aac1f2b95953ca27782` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 111 | 751 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `0aacdd75008d`(NON_KILLED/NO_COVERAGE), `d0bb3df5d0ea`(NON_KILLED/NO_COVERAGE), `5afb3c677c23`(NON_KILLED/NO_COVERAGE), `5a41f3ffc662`(NON_KILLED/NO_COVERAGE), `364e06b64a99`(NON_KILLED/NO_COVERAGE), `e0f4a6a950eb`(NON_KILLED/NO_COVERAGE), `261e4a81cca1`(NON_KILLED/NO_COVERAGE) | +| 25 | `8dc069d5a0f38543a188b36d41cc8d112c332188626cd816eb4a7c1c7cd28c82` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 74 | 556 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `3db41731fa03`(NON_KILLED/NO_COVERAGE), `7d5515ebf145`(NON_KILLED/NO_COVERAGE), `02c365c59375`(NON_KILLED/NO_COVERAGE), `d91f6922ebfe`(NON_KILLED/NO_COVERAGE), `5c2c8235fab2`(NON_KILLED/NO_COVERAGE) | +| 26 | `9026b993fd997eb85a94ed6bde3b111ec4e411a59c6362b6fb0af2e99bca27bf` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 107 | 746 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 27 | `92b019e1bae11d828ffdc6b850e818f5b5986b7a078a2c29defba79bc0a39791` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 44 | 303 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 28 | `ac7940cfd29db06c06381a310e485889b22bdc156808208d2725d8d299e34027` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/jvm/internal/Intrinsics::checkNotNullExpressionValue | 54 | 319 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/jvm/internal/Intrinsics::checkNotNullExpressionValue` | `d8fb778015c5`(NON_KILLED/SURVIVED) | +| 29 | `b280cd7e98348884d2ed52b40a25057122bf434410994caa0db380fb68f656a4` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 56 | 500 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `6371b23fe469`(NON_KILLED/NO_COVERAGE), `4e1e2df1c11e`(NON_KILLED/NO_COVERAGE), `527a99cc5db7`(NON_KILLED/NO_COVERAGE) | +| 30 | `c2be62debe23ffe52f88f679c18778a3822f08312f112a698e418884d7c9a770` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution | 61 | 392 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution` | `5ecf004235db`(NON_KILLED/NO_COVERAGE), `8d7e1fa01d04`(NON_KILLED/NO_COVERAGE), `72555a80f946`(NON_KILLED/NO_COVERAGE), `b7ddd5aaa422`(NON_KILLED/NO_COVERAGE), `cfe2ccb893ca`(NON_KILLED/NO_COVERAGE), `3aa7e614aa6a`(NON_KILLED/NO_COVERAGE), `35c0b8afec49`(NON_KILLED/NO_COVERAGE) | +| 31 | `ceba69cd43f170d2b3eebe5b7e0e74e3503eab3e80ceb4eed1a7f91659d3e64e` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 11 | 71 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 32 | `d77706fde05eb954a18854bdc62e90721dca630d7edd3b681c1ca599046c0eb9` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 37 | 279 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `3db41731fa03`(NON_KILLED/NO_COVERAGE), `7d5515ebf145`(NON_KILLED/NO_COVERAGE), `02c365c59375`(NON_KILLED/NO_COVERAGE), `d91f6922ebfe`(NON_KILLED/NO_COVERAGE), `5c2c8235fab2`(NON_KILLED/NO_COVERAGE) | +| 33 | `ecb1e83af59393d1b5232a58ff15d691a148b163c854160584245a09ca6b31a5` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 87 | 617 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 34 | `ece7d1190f97b351afdc5e431e888fd7e24aabf29ca1ab41d356e6dd490aa787` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 28 | 177 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 35 | `ef13cbec0d70fb8ec493cc6ed0709275f5ea9d9d73b2a661ef9c4d782f531af3` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 69 | 501 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `82fe089c156d`(NON_KILLED/NO_COVERAGE), `546a615f4436`(NON_KILLED/NO_COVERAGE), `ef0c44715457`(NON_KILLED/NO_COVERAGE), `1d9dc7a8c9b2`(NON_KILLED/NO_COVERAGE), `ac0be306eb26`(NON_KILLED/NO_COVERAGE), `30fe02f32b41`(NON_KILLED/NO_COVERAGE), `c4c37ffff62b`(NON_KILLED/NO_COVERAGE), `ba6ebc3e7076`(NON_KILLED/NO_COVERAGE), `ec7d1ab598d6`(NON_KILLED/NO_COVERAGE), `ebf31b386086`(NON_KILLED/NO_COVERAGE), `f2d34adda4b6`(NON_KILLED/NO_COVERAGE), `cf1441ce7d46`(NON_KILLED/NO_COVERAGE), `f05dac1eeae6`(NON_KILLED/NO_COVERAGE) | +| 36 | `f1a19c075b2903dca52266897ef06706740455acdeed3a843dbe51de24236213` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 37 | 252 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | `0aacdd75008d`(NON_KILLED/NO_COVERAGE), `d0bb3df5d0ea`(NON_KILLED/NO_COVERAGE), `5afb3c677c23`(NON_KILLED/NO_COVERAGE), `5a41f3ffc662`(NON_KILLED/NO_COVERAGE), `364e06b64a99`(NON_KILLED/NO_COVERAGE), `e0f4a6a950eb`(NON_KILLED/NO_COVERAGE), `261e4a81cca1`(NON_KILLED/NO_COVERAGE) | +| 37 | `f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NegateConditionalsMutator` | negated conditional | 39 | 323 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `949c8ad8dfd4`(NON_KILLED/TIMED_OUT), `062c0e53f177`(NON_KILLED/TIMED_OUT), `dc0810d0cc14`(NON_KILLED/TIMED_OUT) | + +### 6.3 Category B — AMBIGUOUS_INHERITED (51 identities) + +| # | candidate identity | raw status | family | module | class | method | mutator | description | block | index | source-level key | predecessors at key | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | `04c94b48909a32fc160f6a6a6983f183656ff9dc7d4506faf675765d6df690eb` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 40 | 327 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume` | `950b0b5ac5a9`(NON_KILLED/NO_COVERAGE), `be663ad795d6`(NON_KILLED/NO_COVERAGE), `4948fdbaf262`(NON_KILLED/NO_COVERAGE), `56b12a836fef`(KILLED/KILLED) | +| 2 | `0bf3201b50780da27f483eb575458877d047d34c6152438bf8f27e99e68eaeaa` | SURVIVED | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 116 | 459 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary` | `9da71de67474`(NON_KILLED/SURVIVED), `12dd45b419d9`(NON_KILLED/SURVIVED), `03fe978066fa`(KILLED/KILLED), `a9745066815e`(NON_KILLED/SURVIVED) | +| 3 | `1016c93dbbc9b196b9a4a530989b2410096601826884b69cf63f9445547f97e8` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 133 | 533 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | +| 4 | `10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NegateConditionalsMutator` | negated conditional | 31 | 170 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `c479cc1c69a1`(KILLED/KILLED), `26ff0a87eb1b`(NON_KILLED/TIMED_OUT) | +| 5 | `13586a6301d3fa8dbe85981352dc08c0616e953c4f285810b48f4fb05904fe31` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 55 | 360 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 6 | `16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 132 | 853 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `5e13cf15e29c`(KILLED/KILLED), `acb3b3ec95bc`(KILLED/KILLED), `73418182fa85`(NON_KILLED/TIMED_OUT), `97b1be0758de`(NON_KILLED/TIMED_OUT), `4a35eba5008e`(KILLED/KILLED), `350a94d49928`(NON_KILLED/TIMED_OUT), `88dec573bedb`(NON_KILLED/TIMED_OUT), `4fc4c332ed03`(KILLED/KILLED), `6d79865a9053`(KILLED/KILLED), `5a8ca9067f04`(NON_KILLED/TIMED_OUT), `b0e724190b29`(NON_KILLED/TIMED_OUT), `1f025dec8de7`(NON_KILLED/TIMED_OUT) | +| 7 | `18ac9e9b9ded332a5d862841a5db3d9f650201fc4c749f56b11bdebe1657289d` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 15 | 99 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 8 | `20ca1a1d8d14f6b6be78f7f5d7170c216ff8f9e225bbff006c11104dca0b0411` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `revealAndValidateReplayPayload` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::revealAndValidateReplayPayload | 32 | 174 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::revealAndValidateReplayPayload` | `83cb636cae4b`(NON_KILLED/NO_COVERAGE), `e2ccc920c43c`(KILLED/KILLED) | +| 9 | `252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 54 | 356 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 10 | `2c5adc165ef6fb1d3199ff178a0e14f3b95289c6aae29d322a54a1f1d57f667f` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 156 | 616 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | +| 11 | `2d5928356b93e422214cdfb78956b5392c45e52e94cb45b91d1216ea59a40eb7` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 68 | 463 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 12 | `2fa16c9fd834bab14481c3dd937b1bb150988ee0cd86b7ecb457ffa7c7108181` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 72 | 515 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `947f05488df3`(NON_KILLED/TIMED_OUT), `5ffadc16dab2`(NON_KILLED/TIMED_OUT), `e236ea3f276e`(KILLED/KILLED), `afba54a83e11`(NON_KILLED/TIMED_OUT), `a76865f870dc`(NON_KILLED/TIMED_OUT), `1bfda558c38b`(NON_KILLED/TIMED_OUT) | +| 13 | `33ea83e53a8418523ccc33aa5bea657b6d4d2bbd4aed708d8dcf89b7f53decf1` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 135 | 536 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | +| 14 | `379f2e6d296b1693899e84e358c73328a9679c4cb6c023d16bddb50bdb5a9bdc` | SURVIVED | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 75 | 270 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary` | `9da71de67474`(NON_KILLED/SURVIVED), `12dd45b419d9`(NON_KILLED/SURVIVED), `03fe978066fa`(KILLED/KILLED), `a9745066815e`(NON_KILLED/SURVIVED) | +| 15 | `3883b3c127aed30401c7b826e0e5131aca1167c49145f1dc5e9706f319b4ed3e` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 24 | 164 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo` | `4681686f2f26`(NON_KILLED/NO_COVERAGE), `46159f4c1a0f`(NON_KILLED/NO_COVERAGE), `b91468eee9d5`(KILLED/KILLED), `6e45cd86eec2`(NON_KILLED/NO_COVERAGE), `fefd169fd4b2`(NON_KILLED/NO_COVERAGE), `246b6fba6aab`(NON_KILLED/NO_COVERAGE), `37470da71000`(KILLED/KILLED) | +| 16 | `4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 35 | 243 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `947f05488df3`(NON_KILLED/TIMED_OUT), `5ffadc16dab2`(NON_KILLED/TIMED_OUT), `e236ea3f276e`(KILLED/KILLED), `afba54a83e11`(NON_KILLED/TIMED_OUT), `a76865f870dc`(NON_KILLED/TIMED_OUT), `1bfda558c38b`(NON_KILLED/TIMED_OUT) | +| 17 | `43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 109 | 668 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `5e13cf15e29c`(KILLED/KILLED), `acb3b3ec95bc`(KILLED/KILLED), `73418182fa85`(NON_KILLED/TIMED_OUT), `97b1be0758de`(NON_KILLED/TIMED_OUT), `4a35eba5008e`(KILLED/KILLED), `350a94d49928`(NON_KILLED/TIMED_OUT), `88dec573bedb`(NON_KILLED/TIMED_OUT), `4fc4c332ed03`(KILLED/KILLED), `6d79865a9053`(KILLED/KILLED), `5a8ca9067f04`(NON_KILLED/TIMED_OUT), `b0e724190b29`(NON_KILLED/TIMED_OUT), `1f025dec8de7`(NON_KILLED/TIMED_OUT) | +| 18 | `45475202eecb348909e43024a9d2482a72f676b0c1ac6d42b48b482d23de3567` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 60 | 388 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `5e13cf15e29c`(KILLED/KILLED), `acb3b3ec95bc`(KILLED/KILLED), `73418182fa85`(NON_KILLED/TIMED_OUT), `97b1be0758de`(NON_KILLED/TIMED_OUT), `4a35eba5008e`(KILLED/KILLED), `350a94d49928`(NON_KILLED/TIMED_OUT), `88dec573bedb`(NON_KILLED/TIMED_OUT), `4fc4c332ed03`(KILLED/KILLED), `6d79865a9053`(KILLED/KILLED), `5a8ca9067f04`(NON_KILLED/TIMED_OUT), `b0e724190b29`(NON_KILLED/TIMED_OUT), `1f025dec8de7`(NON_KILLED/TIMED_OUT) | +| 19 | `4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 9 | 59 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 20 | `4b1bdb9fc7c4170b0202d36c5ea3a35302209e4595ced39281591b45467fbc3e` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 55 | 445 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume` | `950b0b5ac5a9`(NON_KILLED/NO_COVERAGE), `be663ad795d6`(NON_KILLED/NO_COVERAGE), `4948fdbaf262`(NON_KILLED/NO_COVERAGE), `56b12a836fef`(KILLED/KILLED) | +| 21 | `4dec4f43d11411f42c02624208840d47be3c1e1059e83d7505776e4ce4df70d8` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 47 | 332 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo` | `4681686f2f26`(NON_KILLED/NO_COVERAGE), `46159f4c1a0f`(NON_KILLED/NO_COVERAGE), `b91468eee9d5`(KILLED/KILLED), `6e45cd86eec2`(NON_KILLED/NO_COVERAGE), `fefd169fd4b2`(NON_KILLED/NO_COVERAGE), `246b6fba6aab`(NON_KILLED/NO_COVERAGE), `37470da71000`(KILLED/KILLED) | +| 22 | `5170395637241867d2d38413ee0348f1030fdccb80617e0bb33b2001345f59f0` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 86 | 579 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 23 | `565b38ef596a44ef3c8a1f9666276dfee5d8f04e233c0ed6ebeea58dd21b0d9c` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 30 | 110 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | +| 24 | `58751c78068df6ef25ed6c64c5eeda6e6600f67f72351812073397c2ba203c6f` | SURVIVED | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `ConditionalsBoundaryMutator` | changed conditional boundary | 62 | 222 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator|changed conditional boundary` | `9da71de67474`(NON_KILLED/SURVIVED), `12dd45b419d9`(NON_KILLED/SURVIVED), `03fe978066fa`(KILLED/KILLED), `a9745066815e`(NON_KILLED/SURVIVED) | +| 25 | `5c2981ad80f33214fb0c6b720cfd3b854c42277f40cd86d2041ea4f36baf2b2b` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 61 | 430 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo` | `4681686f2f26`(NON_KILLED/NO_COVERAGE), `46159f4c1a0f`(NON_KILLED/NO_COVERAGE), `b91468eee9d5`(KILLED/KILLED), `6e45cd86eec2`(NON_KILLED/NO_COVERAGE), `fefd169fd4b2`(NON_KILLED/NO_COVERAGE), `246b6fba6aab`(NON_KILLED/NO_COVERAGE), `37470da71000`(KILLED/KILLED) | +| 26 | `611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 14 | 95 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 27 | `6c077df4e378bce13bd76dac8c0f46b4fdae2c7cd81108157512b61cced8de20` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 138 | 547 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | +| 28 | `6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 11 | 74 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `5e13cf15e29c`(KILLED/KILLED), `acb3b3ec95bc`(KILLED/KILLED), `73418182fa85`(NON_KILLED/TIMED_OUT), `97b1be0758de`(NON_KILLED/TIMED_OUT), `4a35eba5008e`(KILLED/KILLED), `350a94d49928`(NON_KILLED/TIMED_OUT), `88dec573bedb`(NON_KILLED/TIMED_OUT), `4fc4c332ed03`(KILLED/KILLED), `6d79865a9053`(KILLED/KILLED), `5a8ca9067f04`(NON_KILLED/TIMED_OUT), `b0e724190b29`(NON_KILLED/TIMED_OUT), `1f025dec8de7`(NON_KILLED/TIMED_OUT) | +| 29 | `6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 23 | 160 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `947f05488df3`(NON_KILLED/TIMED_OUT), `5ffadc16dab2`(NON_KILLED/TIMED_OUT), `e236ea3f276e`(KILLED/KILLED), `afba54a83e11`(NON_KILLED/TIMED_OUT), `a76865f870dc`(NON_KILLED/TIMED_OUT), `1bfda558c38b`(NON_KILLED/TIMED_OUT) | +| 30 | `7cd98a90304d7964683c92622c269f113cd690e65473f8153133d8cfc08fe189` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 27 | 159 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 31 | `827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 67 | 459 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 32 | `862220d0ea233a543c5b0ddc516ea15f0d492b868cde8f97ceb44e7608067d36` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 73 | 519 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo` | `4681686f2f26`(NON_KILLED/NO_COVERAGE), `46159f4c1a0f`(NON_KILLED/NO_COVERAGE), `b91468eee9d5`(KILLED/KILLED), `6e45cd86eec2`(NON_KILLED/NO_COVERAGE), `fefd169fd4b2`(NON_KILLED/NO_COVERAGE), `246b6fba6aab`(NON_KILLED/NO_COVERAGE), `37470da71000`(KILLED/KILLED) | +| 33 | `8b4d579ff820c98f7f3dac5ee8c86eb942422cf1b8b192ec44992c1881695d39` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 26 | 155 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 34 | `8b52a478cb33035b2af3c59f099290ab6fa8c3812888c33b9893160ce0fa8d22` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 36 | 247 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo` | `4681686f2f26`(NON_KILLED/NO_COVERAGE), `46159f4c1a0f`(NON_KILLED/NO_COVERAGE), `b91468eee9d5`(KILLED/KILLED), `6e45cd86eec2`(NON_KILLED/NO_COVERAGE), `fefd169fd4b2`(NON_KILLED/NO_COVERAGE), `246b6fba6aab`(NON_KILLED/NO_COVERAGE), `37470da71000`(KILLED/KILLED) | +| 35 | `9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 42 | 264 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 36 | `9bc111bcf8e78ae2fe81a4ff243faf5d3b0185fd352d472f6e7fc722b696f19c` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 149 | 585 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | +| 37 | `9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 60 | 426 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `947f05488df3`(NON_KILLED/TIMED_OUT), `5ffadc16dab2`(NON_KILLED/TIMED_OUT), `e236ea3f276e`(KILLED/KILLED), `afba54a83e11`(NON_KILLED/TIMED_OUT), `a76865f870dc`(NON_KILLED/TIMED_OUT), `1bfda558c38b`(NON_KILLED/TIMED_OUT) | +| 38 | `a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 76 | 533 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 39 | `c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 35 | 206 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `5e13cf15e29c`(KILLED/KILLED), `acb3b3ec95bc`(KILLED/KILLED), `73418182fa85`(NON_KILLED/TIMED_OUT), `97b1be0758de`(NON_KILLED/TIMED_OUT), `4a35eba5008e`(KILLED/KILLED), `350a94d49928`(NON_KILLED/TIMED_OUT), `88dec573bedb`(NON_KILLED/TIMED_OUT), `4fc4c332ed03`(KILLED/KILLED), `6d79865a9053`(KILLED/KILLED), `5a8ca9067f04`(NON_KILLED/TIMED_OUT), `b0e724190b29`(NON_KILLED/TIMED_OUT), `1f025dec8de7`(NON_KILLED/TIMED_OUT) | +| 40 | `ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `suspendToolExecution` | `NegateConditionalsMutator` | negated conditional | 157 | 1067 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `5e13cf15e29c`(KILLED/KILLED), `acb3b3ec95bc`(KILLED/KILLED), `73418182fa85`(NON_KILLED/TIMED_OUT), `97b1be0758de`(NON_KILLED/TIMED_OUT), `4a35eba5008e`(KILLED/KILLED), `350a94d49928`(NON_KILLED/TIMED_OUT), `88dec573bedb`(NON_KILLED/TIMED_OUT), `4fc4c332ed03`(KILLED/KILLED), `6d79865a9053`(KILLED/KILLED), `5a8ca9067f04`(NON_KILLED/TIMED_OUT), `b0e724190b29`(NON_KILLED/TIMED_OUT), `1f025dec8de7`(NON_KILLED/TIMED_OUT) | +| 41 | `d20dbac385cb8d5a09e313541a2dcf177b7c0f390c6ae0cf9fafe1a5c072bd02` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 106 | 708 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 42 | `d3f812e883d41cbb8c11e3225d66974589a48842fafdd8a4f9fa41ee8008dcca` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 10 | 63 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 43 | `e118041e3a76ebc8af348ce6431c9cf24871161652481cfd5bdee100d0829894` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 105 | 704 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 44 | `e3d83f9421a20ebe9993912caa7c52d98e0e157efd276c1a43915b5b1ee15724` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 141 | 555 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | +| 45 | `e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 46 | 328 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `947f05488df3`(NON_KILLED/TIMED_OUT), `5ffadc16dab2`(NON_KILLED/TIMED_OUT), `e236ea3f276e`(KILLED/KILLED), `afba54a83e11`(NON_KILLED/TIMED_OUT), `a76865f870dc`(NON_KILLED/TIMED_OUT), `1bfda558c38b`(NON_KILLED/TIMED_OUT) | +| 46 | `e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo | 10 | 72 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo` | `4681686f2f26`(NON_KILLED/NO_COVERAGE), `46159f4c1a0f`(NON_KILLED/NO_COVERAGE), `b91468eee9d5`(KILLED/KILLED), `6e45cd86eec2`(NON_KILLED/NO_COVERAGE), `fefd169fd4b2`(NON_KILLED/NO_COVERAGE), `246b6fba6aab`(NON_KILLED/NO_COVERAGE), `37470da71000`(KILLED/KILLED) | +| 47 | `e78cb26e67aa7d640e9ff5e9e31d267e7aa84b1308357a7312c82c3342bb01cb` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NegateConditionalsMutator` | negated conditional | 85 | 575 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `bd46cba9bba9`(NON_KILLED/TIMED_OUT), `130b60fa7f07`(KILLED/KILLED), `59bbf2146636`(KILLED/KILLED), `85ae74cb2ffc`(NON_KILLED/TIMED_OUT), `2155f6a06a78`(KILLED/KILLED), `e200bda99ea8`(NON_KILLED/TIMED_OUT), `319df689ce2b`(NON_KILLED/TIMED_OUT), `8c909e76359d`(NON_KILLED/TIMED_OUT), `f43a1391235f`(KILLED/KILLED), `9e05d5b73798`(NON_KILLED/NO_COVERAGE), `216bc036a8ad`(KILLED/KILLED), `0c6a0f5da99c`(NON_KILLED/NO_COVERAGE), `cfa7fa1e346a`(KILLED/KILLED), `05abfbc4e980`(NON_KILLED/TIMED_OUT), `bb7c63ab70f6`(NON_KILLED/TIMED_OUT), `a9e240548004`(NON_KILLED/TIMED_OUT), `e52fd6ae714d`(NON_KILLED/TIMED_OUT), `692b201e3aa1`(NON_KILLED/SURVIVED), `1fb81d5d3133`(NON_KILLED/TIMED_OUT), `995ddfdf0973`(KILLED/KILLED), `f2f3de5d9785`(NON_KILLED/TIMED_OUT) | +| 48 | `ef7dc1d6b1d5c625fa2423bce4ce749fb00a5ee310efec28cf791f474c20ebac` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `executeClaimedResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume | 20 | 171 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::executeClaimedResume` | `950b0b5ac5a9`(NON_KILLED/NO_COVERAGE), `be663ad795d6`(NON_KILLED/NO_COVERAGE), `4948fdbaf262`(NON_KILLED/NO_COVERAGE), `56b12a836fef`(KILLED/KILLED) | +| 49 | `f0effc0ace61dc60a5f7c3950d67dd8d9dd453fe9f848d569d8f1ddd6d962625` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `resume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume | 43 | 268 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::resume` | `083b220893a9`(NON_KILLED/NO_COVERAGE), `b7b2dfec2fa0`(NON_KILLED/NO_COVERAGE), `efacd2b1e0f4`(NON_KILLED/NO_COVERAGE), `c52b419c90e9`(NON_KILLED/NO_COVERAGE), `3555704187bd`(NON_KILLED/NO_COVERAGE), `246350bcbd76`(NON_KILLED/NO_COVERAGE), `976f65569f36`(NON_KILLED/NO_COVERAGE), `32df7c11c787`(NON_KILLED/NO_COVERAGE), `0e8a40e624e1`(NON_KILLED/NO_COVERAGE), `b1e34f861946`(NON_KILLED/NO_COVERAGE), `319b7bee9207`(NON_KILLED/NO_COVERAGE), `d64b7292b499`(NON_KILLED/NO_COVERAGE), `f0cf0add6920`(NON_KILLED/NO_COVERAGE), `0110d7f7665b`(KILLED/KILLED) | +| 50 | `f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requestApproval-Atj0Sqo` | `NegateConditionalsMutator` | negated conditional | 9 | 68 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `947f05488df3`(NON_KILLED/TIMED_OUT), `5ffadc16dab2`(NON_KILLED/TIMED_OUT), `e236ea3f276e`(KILLED/KILLED), `afba54a83e11`(NON_KILLED/TIMED_OUT), `a76865f870dc`(NON_KILLED/TIMED_OUT), `1bfda558c38b`(NON_KILLED/TIMED_OUT) | +| 51 | `fbb04e20399512619fdafc983bfd38f4239bd266d9bd2b8323ba8541450cd17f` | NO_COVERAGE | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `NegateConditionalsMutator` | negated conditional | 158 | 622 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | `ca0bada2431c`(KILLED/KILLED), `abcb69cf55a0`(KILLED/KILLED), `477068444b35`(NON_KILLED/NO_COVERAGE), `4288c9ca4e8d`(KILLED/KILLED), `aff15f3550cd`(KILLED/KILLED), `7165c3227c30`(KILLED/KILLED), `75833d2d554e`(KILLED/KILLED), `4b373fdebbce`(KILLED/KILLED), `7d907ad74364`(KILLED/KILLED), `26e44096428c`(KILLED/KILLED), `53697b2d077e`(KILLED/KILLED), `8e228ad7ea2d`(KILLED/KILLED), `ac83ce2e9393`(KILLED/KILLED), `7dda948fc68f`(KILLED/KILLED), `fafed97d42fd`(KILLED/KILLED), `46184b1a9fab`(KILLED/KILLED), `d9c41d45bb17`(KILLED/KILLED), `e0f8eeb27938`(KILLED/KILLED), `b02d448cd873`(KILLED/KILLED), `51abee92922e`(KILLED/KILLED), `55e523c4e303`(KILLED/KILLED), `8c5fcb2064c4`(KILLED/KILLED), `e6c50edec0cc`(KILLED/KILLED), `da7de7326bbe`(KILLED/KILLED), `14e6955eae1d`(KILLED/KILLED), `4143d82d7478`(KILLED/KILLED), `6a5baa99db06`(KILLED/KILLED), `1082270f835b`(KILLED/KILLED), `b2527301e99a`(KILLED/KILLED), `d8540bd51f41`(KILLED/KILLED), `0cfa472a9557`(NON_KILLED/NO_COVERAGE), `d3d159737c17`(NON_KILLED/NO_COVERAGE), `0745e3647ebf`(NON_KILLED/NO_COVERAGE), `fa4e11a4ecb4`(NON_KILLED/NO_COVERAGE), `b1e44ec99123`(NON_KILLED/NO_COVERAGE), `4e9834c5b540`(NON_KILLED/NO_COVERAGE), `86e1f7ed67ea`(NON_KILLED/NO_COVERAGE) | + +### 6.4 Category C — GENUINELY_NEW_NON_KILLED (107 identities) + +| # | candidate identity | raw status | family | module | class | method | mutator | description | block | index | source-level key | predecessors at key | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | `04ed49ac905fc2abdf7cfdd761e7582bd7ac491aa223a084e13e742d0d09d33f` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedApprovalStoreKt` | `requireAttributionMatchesBinding` | `NegateConditionalsMutator` | negated conditional | 7 | 25 | `:tramai-engine|dev.tramai.engine.approval.GovernedApprovalStoreKt|requireAttributionMatchesBinding|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 2 | `050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NegateConditionalsMutator` | negated conditional | 12 | 74 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 3 | `062ef5efb7ee873d74da6c82da726c85406b3df1c9ae0040aa715081a88a3ef8` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 24 | 131 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 4 | `080c1a247bb10d6724a005dc6a718e8566dea770b07383aff5f3f04c0c6abdb5` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 4 | 29 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 5 | `09642639c5ec0e9791214208bb1d953a203adfc7ba5bd8fde1ef175d780df17f` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator$resume$2::invokeSuspend | 6 | 36 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator$resume$2::invokeSuspend` | — | +| 6 | `098258d70a2379a58e39d24f5f85824705998bb6d3e20cf0bec54ffecc4db0a6` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 44 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 7 | `09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 5 | 32 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 8 | `0df4e1e91cdadf04e0f755db04b79fb465972d0f446ffbc9944aab98a8c53512` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 53 | 271 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 9 | `10ee55f449883c987fd7019d46d5d32380e072a8b669f78aa614058c6123864f` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 13 | 94 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension` | — | +| 10 | `15c7def4f5c98cbdea1a90b5b4fd44978a3d90d13c30d2beafccf83997250120` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 27 | 154 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 11 | `1a9e9e35471793c09bcb21e205b59c2a76215adcb1c069794669ab52a0b65f56` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | `requireGovernedContinuity` | `NegateConditionalsMutator` | negated conditional | 4 | 29 | `:tramai-engine|dev.tramai.engine.approval.GovernedContinuationContinuityKt|requireGovernedContinuity|(Ljava/lang/String;Ldev/tramai/core/identity/GovernedRunIdentity;Ldev/tramai/core/identity/GovernedRunIdentity;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 12 | `1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NegateConditionalsMutator` | negated conditional | 14 | 79 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 13 | `1b309f9a702a4e6ceecf930f61718bb260539696cfa516ce53ad1897291bb436` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 12 | 73 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 14 | `1b8938aaabc6e8c69890f8c7ac50b50a6d0e01f2bcb3c3d82049d3bba33735b2` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 14 | 122 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 15 | `1c4ca901fd8d56ace18e167d294242db6cfdd8a6f551c7860dfaa934ea26ce54` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 6 | 37 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 16 | `1e85193edcd55874f3bdb58865290bcb1dc9353f0175bbe17ba1aced419ad902` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 13 | 78 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned` | — | +| 17 | `2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NegateConditionalsMutator` | negated conditional | 17 | 101 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 18 | `2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `NegateConditionalsMutator` | negated conditional | 10 | 63 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 19 | `2d1f9e373009b20d4462805571e0e9435e599916a76d4bc78d3d2ccd6525f9b2` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 12 | 71 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume` | — | +| 20 | `2f92ef797c538ab858fdeca1c5a464a8bba2c633cf74bad2fac99d756f42ca65` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 63 | 470 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 21 | `38af093087fd0acbd846859277155e9155f29f59b79c59dd9a0c8d6bf86c6355` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 12 | 77 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 22 | `39f37feb8af8811303e70d73fea29138e2cc3c866330e3d1f5371e94239a92e8` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 62 | 427 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume` | — | +| 23 | `3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep | 17 | 106 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep` | — | +| 24 | `3fa7e539cb12045632a1d4ed3c9ff9355785c024410c6896c2cd3882b6a0912a` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NegateConditionalsMutator` | negated conditional | 20 | 164 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 25 | `492f9a132e141f512c5690d8b0d43c6e5925b488de13d80ef71504bcf61b62d4` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 40 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 26 | `4be603cfac01a955a396208293347fa218b4f0f364a6bea0e851e58e327678cc` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 37 | 195 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 27 | `4cca31fe707f13c869b7fa55a507661638e60a62cdb13f1b7cf5cb3899e023ae` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 40 | 228 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume` | — | +| 28 | `4e3ec3205822664846f9e727b232647706a9535829fc83bb7a55dbd4fd2baf4b` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 39 | 224 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 29 | `4fc586ff85c68cfdeca566abb58fcfc7986945efb5540844990d16a75ed127bb` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 32 | 271 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 30 | `56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches | 9 | 50 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches` | — | +| 31 | `57a4c81569e7d75271f721a4f689845a31ca3569b00af2fd980d76a0ae1a2393` | SURVIVED | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | `validateMetadata` | `VoidMethodCallMutator` | removed call to dev/tramai/security/evidence/RuntimeEvidenceAttribution::validate | 12 | 51 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceContractValidator|validateMetadata|(Ldev/tramai/security/evidence/RuntimeEvidenceRecord;)V|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/security/evidence/RuntimeEvidenceAttribution::validate` | — | +| 32 | `5ac56e0a701ab6fcde19f86e640b34609b0ebeb64437e7ae185479282e7a6c7d` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 16 | 100 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 33 | `5cae2063c995e0938a94146e5e66b1b173eb4e205fac4339449f644c85deb22d` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 54 | 275 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume` | — | +| 34 | `5d4ca8196b51364c881844ac42e5b155040463a57047cb308bafd4cda258af34` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NegateConditionalsMutator` | negated conditional | 7 | 30 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|persistSuspendedInvocation|(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 35 | `60f7b020e009cc99a8a80e19e34a956e570614449294444db057993e433b1ac6` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 44 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 36 | `613bf2d479166ff51b50e27060a2442f69fa2d20a9c103b75c5147e8264d3f7f` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 55 | 369 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 37 | `63e5372ba37bc3b097def60c22bddd050404d168b26c2fa73bc94a99a6705c1e` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 55 | 292 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 38 | `64b71d7ed973b9d0863a93fa15b70e0b193f58ccdfc48b2eea575f34f61956be` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 24 | 133 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume` | — | +| 39 | `6759437252f37022e139ccaa2ca729226147768a1d7972fcd59317f185d965a4` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume | 38 | 199 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::authorizeResume` | — | +| 40 | `6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches | 26 | 141 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches` | — | +| 41 | `6cef3fd2e23d3df1571d390564b8274596c70c97965bc5719603d39116b74b8c` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 2 | 12 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 42 | `6dc734145518bb488ac0b6d202711c3d1b0c1d804489ed3d9b09453e75db96a1` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NegateConditionalsMutator` | negated conditional | 12 | 50 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|persistSuspendedInvocation|(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 43 | `70d345f72064e7cea4ebb9cff97f74946f00d582ba38a4e62c5498261494b0a0` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend | 8 | 44 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend` | — | +| 44 | `75ec22b0e703bb8a54ddbde9d6edab92e1e3fbbff3318021b9a6d11eccca21ec` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 33 | 197 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 45 | `7b156f3a3c6265cf6e474c7001a4e6f06d54cbae3d042616889451e5629ba0a6` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 11 | 65 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned` | — | +| 46 | `7d3fc3ab2af8aff687f6b242abb14d98d96af1914e515dda317bb7c38de55767` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 32 | 185 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned` | — | +| 47 | `7e799e77e8c7396e770465175fde071720cab896296f61dab7fca18d7df87572` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 23 | 136 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned` | — | +| 48 | `7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NegateConditionalsMutator` | negated conditional | 30 | 239 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 49 | `84224c763190937a1428a51988461c3f8669d81c7cb2e8476e35aa8e03b15cf9` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 19 | 113 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 50 | `8510790e9203bf585125a30cf5178e0002d129f7580ddc36fd928ed8a9ea1ad5` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 41 | 251 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 51 | `8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NegateConditionalsMutator` | negated conditional | 12 | 90 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 52 | `887313eb0142a0add13da39b7d198c8bcc351ac8cd2dd1639028d47d7a2fd38e` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 22 | 196 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 53 | `8abddce868884ec7da02fd97518d43894ab3009ec7433721ad4580505f09b4fb` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend | 6 | 32 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend` | — | +| 54 | `8cdddbe0775a84fda14fe7bb987e3d4caff65dc0c20934acd8abf538a07afb41` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 25 | 135 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume` | — | +| 55 | `8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 5 | 28 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 56 | `8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 22 | 127 | `:tramai-engine|dev.tramai.engine.approval.ApprovalRunAttributionKt|decodeApprovalAttribution|(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 57 | `9516ca2fa7750c16a31995eaa14e2f651d4e5798089a11f5053e01d141591f0d` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 25 | 150 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 58 | `95f4d3181b61682eafec19984f4bd5ebfac46cde52bacaa5a4d18f21814e78ee` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep | 11 | 67 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep` | — | +| 59 | `980c70e5ac83cda9c9fd96339e82d200255c2e12afe527b95f998666c1249806` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend | 5 | 33 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend` | — | +| 60 | `9c4c3454c7f772591a2a5b5f468c2d0f281ddbd5a5d8945aa0577a366d2be1a8` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 14 | 90 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 61 | `9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 11 | 68 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 62 | `a14d63a582a3626d45aae6a2c10f651e5507b6bb2a1c0a3ac38ebace47045f0c` | SURVIVED | evidence | :tramai-security | `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | `getMetadataKeys` | `EmptyObjectReturnValsMutator` | replaced return value with Collections.emptySet for dev/tramai/security/evidence/RuntimeEvidenceAttribution::getMetadataKeys | 0 | 4 | `:tramai-security|dev.tramai.security.evidence.RuntimeEvidenceAttribution|getMetadataKeys|()Ljava/util/Set;|org.pitest.mutationtest.engine.gregor.mutators.returns.EmptyObjectReturnValsMutator|replaced return value with Collections.emptySet for dev/tramai/security/evidence/RuntimeEvidenceAttribution::getMetadataKeys` | — | +| 63 | `a15f9807befc68bd1e00747e77a09e147656195cac0833b68904cb326ccf0a5b` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 6 | 37 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 64 | `a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 61 | 423 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 65 | `a4dbacf1299871940f52d31fa1bad147b674028106db00cf0d32545eafb93303` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 18 | 105 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned` | — | +| 66 | `a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned | 35 | 204 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned` | — | +| 67 | `ab78ed8577ff2073f4690730365919999ad7f76ddb68bef5c56a08deeef15c31` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 44 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 68 | `ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NegateConditionalsMutator` | negated conditional | 25 | 142 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 69 | `af8cf7ad147d631f613f8f05cbd1a3d3ab2987fbcfb9a0b576fa5399b61639b6` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 31 | 243 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension` | — | +| 70 | `b1d24bc23a862911a495013126f2713cf5443baeee820ee8da3f66f81a54c645` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 39 | 216 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 71 | `b36fc3ca50a326d7924fe14e278a2c4902307649d2b15ad19aef66d7ab17ed7b` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 23 | 129 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 72 | `b4bde9e3e32ec4786c9201e8ed557700e977626dd65a2e5a82893cbc2ca402bc` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 8 | 31 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|persistSuspendedInvocation|(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation` | — | +| 73 | `b545c2ae147a0ed5c6e743c446f179d2998dea15a0b781fd9131aa3ae18799fd` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 26 | 153 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 74 | `c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NegateConditionalsMutator` | negated conditional | 53 | 332 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 75 | `c67230adf1097aeef5217c95976e0d6f85dd129a70f205e98ee97acb15bd383e` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedApprovalStoreKt` | `requireAttributionMatchesBinding` | `NegateConditionalsMutator` | negated conditional | 2 | 12 | `:tramai-engine|dev.tramai.engine.approval.GovernedApprovalStoreKt|requireAttributionMatchesBinding|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 76 | `c6784c2618c57ec3eaba846ee2dab13b696e32ebcfcb6a6312d7c550bfa23412` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 14 | 58 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|persistSuspendedInvocation|(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation` | — | +| 77 | `c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateStep` | `VoidMethodCallMutator` | removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation | 16 | 101 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation` | — | +| 78 | `c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NegateConditionalsMutator` | negated conditional | 22 | 132 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 79 | `cb90dfa953b5b8f3e70f6fcad04d793fe9f952f4d49816dee8be9f3bebf7504d` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 44 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 80 | `ceab2162714ae5159800ad6e1dde34ed859dd7afeb6e64f90f83f48206ea302e` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend | 5 | 33 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend` | — | +| 81 | `d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalRunAttributionKt` | `decodeApprovalAttribution` | `NegateConditionalsMutator` | negated conditional | 24 | 131 | `:tramai-engine|dev.tramai.engine.approval.ApprovalRunAttributionKt|decodeApprovalAttribution|(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 82 | `d5b70d305cec04c7aca39a5a9c629221c88509f4a8ee07d9643b2f8e37d1277f` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 44 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 83 | `d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NegateConditionalsMutator` | negated conditional | 10 | 61 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 84 | `d8e1290f30007d7296e5509916277dde18d7bf84b9ed568cbebe927dd96a66cb` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 24 | 148 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 85 | `ddf6092edbf61789c9cb31c2d9ce6d98a0a817dc56b587a269ac36125f2ef4d9` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 36 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 86 | `e09a3c1cc38bcd7ec2214ce9aea881a3b1ffa38ba92f49e8e912e8259d0c2a66` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 21 | 168 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension` | — | +| 87 | `e0ae69141eabff4855f7acc9e498139795c71ae3aca5a096d468101f3ceca1f0` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 2 | 12 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 88 | `e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `NegateConditionalsMutator` | negated conditional | 4 | 29 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 89 | `e28c35edba409d362d08cd122805137f43a325c9c9a335db1e045b9aca435dfe` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.GovernedApprovalStoreKt` | `requireAttributionMatchesBinding` | `NegateConditionalsMutator` | negated conditional | 5 | 22 | `:tramai-engine|dev.tramai.engine.approval.GovernedApprovalStoreKt|requireAttributionMatchesBinding|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution;)V|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 90 | `e2f022edd78a3f2e65a2c14eda3793ca0fa99762aa24513a623d3c1b23cb823a` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 54 | 336 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume` | — | +| 91 | `e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistGoverned` | `NegateConditionalsMutator` | negated conditional | 31 | 181 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 92 | `e8276b8096040f41b037f371b6ca328f710a43c2a1042e37532446a5dcd2be50` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 26 | 146 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned` | — | +| 93 | `eacdfd8c8a0f505292702876fa5450fdd86babd28931cb8600a5894ef07843e3` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 13 | 83 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 94 | `eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 9 | 38 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|persistSuspendedInvocation|(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation` | — | +| 95 | `eb8827c4f4ca1d6b1f90686e5335ffb189d78b4694548a6843f7ebc5d1d95a79` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 44 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 96 | `ebc96b3e8599f671eb60c470b2a259a474aa994c2599547731b9944800e60b57` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 2 | 12 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 97 | `ec0280ffb25881b65754a45adb048e4e28f52996812c287c268d8b5cb1c74be0` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `persistSuspendedInvocation` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation | 13 | 51 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|persistSuspendedInvocation|(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation` | — | +| 98 | `eec481d97a434f85884bdc18877d8a54801c8ba289658e0fb49d92e286cad2f7` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | `invokeSuspend` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 2 | 12 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 99 | `f175e8698db5459d85e11e56b04155c7b963d148d906f57908c6440dd533fead` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend | 8 | 44 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend` | — | +| 100 | `f20df64ded8b98458fc68d8583afd170ebbec2fa33a9ed0db1cc349b80225d6d` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 13 | 85 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 101 | `f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `persistUngoverned` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned | 29 | 161 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned` | — | +| 102 | `f56f28b7d039e3bf8f5790671b10d5202d38555f464e5270aa47c101445b082f` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension | 36 | 289 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateSuspension` | — | +| 103 | `f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53` | TIMED_OUT | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `authorizeResume` | `NegateConditionalsMutator` | negated conditional | 11 | 67 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional` | — | +| 104 | `f5c807b43f9fe724ccbed38be0c2939820c115361446f18e236fa6e66fb46ac6` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.DefaultApprovalGateway` | `requireExistingAttributionMatches` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches | 15 | 83 | `:tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches` | — | +| 105 | `f60db12bdb9ea3f1b61d5c01867a18b9ebe7f2a8726a8287f8129dad183a4625` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | `compensateSuspension` | `VoidMethodCallMutator` | removed call to kotlin/ResultKt::throwOnFailure | 7 | 44 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure` | — | +| 106 | `f802165800d8737a082118c89e9c5eae4e7d8470cd15c54c57bc19476be9a15c` | SURVIVED | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | `invokeSuspend` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend | 9 | 43 | `:tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend` | — | +| 107 | `f8bdda993a7ca9073a0fdc3ad08b52203dc497be50d67c150e7ee094066f5ece` | NO_COVERAGE | approval | :tramai-engine | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | `prepareResume` | `NullReturnValsMutator` | replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume | 12 | 72 | `:tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalResumeCoordinator::prepareResume` | — | + +## 7. Totals by class for the whole M06 set + +| class | total | NO_COVERAGE | SURVIVED | TIMED_OUT | A | B | C | +|---|---|---|---|---|---|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 67 | 44 | 4 | 19 | 16 | 22 | 29 | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 46 | 26 | 7 | 13 | 6 | 12 | 28 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 44 | 23 | 11 | 10 | 15 | 6 | 23 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | 12 | 8 | 4 | 0 | 0 | 11 | 1 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | 5 | 2 | 2 | 1 | 0 | 0 | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | 5 | 2 | 2 | 1 | 0 | 0 | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | 5 | 2 | 2 | 1 | 0 | 0 | 5 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | 4 | 2 | 1 | 1 | 0 | 0 | 4 | +| `dev.tramai.engine.approval.GovernedApprovalStoreKt` | 3 | 0 | 3 | 0 | 0 | 0 | 3 | +| `dev.tramai.engine.approval.ApprovalRunAttributionKt` | 2 | 0 | 2 | 0 | 0 | 0 | 2 | +| `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | 1 | 0 | 1 | 0 | 0 | 0 | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | 1 | 0 | 1 | 0 | 0 | 0 | 1 | + +## 8. Path 1 — is pure current recorded evolution viable? No. + +Path 1 = replace `mutation-baseline.json` with the exact fresh population, write 189 removal records, +leave the verifier unchanged, apply the `mutation-population-evolution` label. + +It fails, and it fails on M06: the loop at `MutationRatchetVerifier.kt:299-313` compares +`candidateIds - baseIds` against the base authority and emits +`MUTATION_RATCHET_NEW_SURVIVOR` for every NON_KILLED member of that set. Evolution mode is not an input +to that loop (Section 2.2), and no record shape can describe a candidate-only identity (Section 2.3). +With the fresh population as the candidate, `candidateIds - baseIds` is the 349-identity candidate-only +set and 195 of those are NON_KILLED, so the transition would report 195 M06 failures. The g1A ratchet +run already showed the coupled shape of this: 384 failure lines = M21 189 + M06 195 + M10 0 + M01 0. + +This is an analytic proof from control flow, not an executed transition: producing the executed +counter-example would require creating the candidate baseline, the 189 records and a measurement proof — +exactly the artifacts this preflight forbids. The claim I make is bounded accordingly: under the current +verifier, M06 cannot be satisfied by recorded evolution, so **pure recorded evolution is not +mergeable** as a mechanism for this authority. + +## 9. Path 2 — safe identity-rekey inheritance (evaluated, not implemented) + +Could category A be treated as pre-existing residual authority without weakening B or C? + +### 9.1 Why key equality is not sufficient evidence + +The identity schema is the problem, and it is deliberate. `MutationIdentity.kt:6-14` states the design: +`methodDescription` and `index` are included *"so overloaded methods and distinct bytecode mutation +points that share textual fields cannot collapse into one identity"*. The descriptor-aware key removes +`block` and `index` — the two fields the schema added precisely to keep mutation points apart. Treating +key equality as identity continuity reintroduces exactly the collapse the schema refuses, and the data +shows the collapse is real, not theoretical: fan-in up to 37, only 5 clean one-to-one rekeys, 44 +expanding keys. + +The failure mode to prevent is concrete. In category B every key carries a KILLED predecessor and a +NON_KILLED predecessor. Any rule of the form "the new identity inherits the old authority at this key" +must choose between a killed outcome and a survivor at the same key, and choosing the survivor is +precisely how a killed mutation becomes NON_KILLED under a new PIT coordinate. B is not a corner case: +it is 51 of the 88 identities that the descriptor key would otherwise claim as inherited. + +### 9.2 Minimum evidence a rekey mechanism would have to carry + +Not a recommendation — the floor below which the mechanism is unsound: + +1. **Per-identity lineage, not per-key.** Each new identity names exactly one predecessor identity; a + key may not carry a bulk mapping. With 37→8 and 21→9 fan-ins, a key-level rule has no single + defensible answer to "which predecessor?". +2. **Predecessor outcome must be NON_KILLED.** Any KILLED predecessor at the key disqualifies the + inheritance outright, or the mechanism launders kills. This alone removes all 51 B identities. +3. **The predecessor must actually disappear in this transition**, and be consumed at most once — + otherwise one approved survivor could authorize several new NON_KILLED identities (the 44 expanding + keys show this is reachable). +4. **The lineage set must be complete and auditable**: every new NON_KILLED identity either carries a + lineage record or fails, with no silent default in either direction. +5. **The evidence must not be authored by the transition it authorizes.** This is the binding + constraint, and it is the same one M08 protects for classifications: *a candidate PR cannot certify + its own new survivors*. If the PR that contains the new mutation points also writes the records that + excuse them, the mechanism is a self-approval channel with extra steps, and the 5-identity clean + subset does not change that — 189 records would be written by the same author in the same commit. +6. **It must remain a strictly narrower authority than the current one.** No rule may apply to a new + identity that is not at a measured, previously-authoritative source point with a non-killed + predecessor — which is the only sense in which category A is a candidate at all. + +### 9.3 Assessment + +Category A is the only category that could be argued for, and Section 5.6 strengthens what can honestly be +said about it: no KILLED identity exists anywhere at its keys, retained or disappearing. That is still not +lineage — it is a statement about outcomes at a key, not about which mutation point the key describes. Nor is +it self-certifying: the 5 clean rekeys are weak evidence in the presence of 83 multi-predecessor identities, +and category A itself is 30 NO_COVERAGE / 4 SURVIVED / 3 TIMED_OUT — that is, 37 new mutants that were +never killed, most of them never even covered. Nothing in the descriptor key distinguishes "the same +mutation point, shifted" from "a new mutation point added inside a method that already had mutations and +whose earlier points have shifted", and those two answers have opposite ratchet consequences. Path 2 can +only be sound as an **authority-side lineage artifact adjudicated outside the candidate PR** — the same +ceremony shape as classifications today — not as a ratchet exception granted by inheritance. + +## 10. Path 3 — remaining debt if only category A could inherit + +| Quantity | Value | +|---|---| +| M06 identities remaining | **158** | +| of which category B (ambiguous inherited) | 51 | +| of which category C (genuinely new) | 107 | + +| raw status | count | +|---|---| +| NO_COVERAGE | 79 | +| SURVIVED | 36 | +| TIMED_OUT | 43 | + +| family | count | +|---|---| +| approval | 145 | +| evidence | 13 | + +| class | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator` | 51 | +| `dev.tramai.engine.approval.DefaultApprovalGateway` | 40 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator` | 29 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator` | 12 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1` | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2` | 5 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3` | 5 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2` | 4 | +| `dev.tramai.engine.approval.GovernedApprovalStoreKt` | 3 | +| `dev.tramai.engine.approval.ApprovalRunAttributionKt` | 2 | +| `dev.tramai.engine.approval.GovernedContinuationContinuityKt` | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceAttribution` | 1 | + +Actionable clusters for a later task (class#method [raw status] → identities): + +| cluster | count | +|---|---| +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#authorizeResume [NO_COVERAGE]` | 10 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#authorizeResume [SURVIVED]` | 1 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#authorizeResume [TIMED_OUT]` | 2 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#executeClaimedResume [NO_COVERAGE]` | 3 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#prepareResume [NO_COVERAGE]` | 10 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#prepareResume [SURVIVED]` | 1 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#prepareResume [TIMED_OUT]` | 5 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#resume [NO_COVERAGE]` | 8 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#resume [SURVIVED]` | 1 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#resume [TIMED_OUT]` | 8 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#revealAndValidateReplayPayload [NO_COVERAGE]` | 1 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator#revealAndValidateReplayPayload [TIMED_OUT]` | 1 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2#invokeSuspend [NO_COVERAGE]` | 2 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2#invokeSuspend [SURVIVED]` | 1 | +| `dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2#invokeSuspend [TIMED_OUT]` | 1 | +| `dev.tramai.engine.approval.ApprovalRunAttributionKt#decodeApprovalAttribution [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateStep [NO_COVERAGE]` | 3 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateStep [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateStep [TIMED_OUT]` | 1 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateSuspension [NO_COVERAGE]` | 6 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateSuspension [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#compensateSuspension [TIMED_OUT]` | 3 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#persistSuspendedInvocation [NO_COVERAGE]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#persistSuspendedInvocation [SURVIVED]` | 4 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator#suspendToolExecution [TIMED_OUT]` | 6 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1#invokeSuspend [NO_COVERAGE]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1#invokeSuspend [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1#invokeSuspend [TIMED_OUT]` | 1 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2#invokeSuspend [NO_COVERAGE]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2#invokeSuspend [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2#invokeSuspend [TIMED_OUT]` | 1 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3#invokeSuspend [NO_COVERAGE]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3#invokeSuspend [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3#invokeSuspend [TIMED_OUT]` | 1 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistGoverned [NO_COVERAGE]` | 6 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistGoverned [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistGoverned [TIMED_OUT]` | 3 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistUngoverned [NO_COVERAGE]` | 6 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistUngoverned [SURVIVED]` | 2 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#persistUngoverned [TIMED_OUT]` | 3 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requestApproval-Atj0Sqo [NO_COVERAGE]` | 6 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requestApproval-Atj0Sqo [TIMED_OUT]` | 6 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requireExistingAttributionMatches [NO_COVERAGE]` | 2 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requireExistingAttributionMatches [SURVIVED]` | 3 | +| `dev.tramai.engine.approval.DefaultApprovalGateway#requireExistingAttributionMatches [TIMED_OUT]` | 1 | +| `dev.tramai.engine.approval.GovernedApprovalStoreKt#requireAttributionMatchesBinding [SURVIVED]` | 3 | +| `dev.tramai.engine.approval.GovernedContinuationContinuityKt#requireGovernedContinuity [SURVIVED]` | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceAttribution#getMetadataKeys [SURVIVED]` | 1 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator#validateMetadata [NO_COVERAGE]` | 8 | +| `dev.tramai.security.evidence.RuntimeEvidenceContractValidator#validateMetadata [SURVIVED]` | 4 | + +Reading of the clusters, for whoever picks this up next: + +- **TIMED_OUT (43 remaining)** is the smallest actionable group and the one most likely to be test- + harness debt rather than missing tests: a timeout means the mutant ran and the test process did not + finish in budget, not that nothing exercised it. Cluster them by class and check the per-mutant + timeout budget before writing a single new test. +- **SURVIVED (36 remaining)** is real missing protection and needs stronger tests. It cannot be closed by + this candidate classifying its own survivors: Section 2.4 shows every candidate-added classification is + an M08/M09 failure, so this path requires a separately designed authority-side adjudication mechanism. + No current repository mechanism lets this candidate adjudicate its own new survivors. +- **NO_COVERAGE (79 remaining)** is the largest group and the one where a decision is cheapest. It means + PIT measured no coverage **of that mutation point** — not that the enclosing method or class is never + entered; a method can run while a specific mutated instruction is not observed. So the question is + whether the point is reachable and worth covering, or whether it should not be in the target set at all. + `compensateSuspension` + and its three synthetic `invokeSuspend` bodies, `persistGoverned`/`persistUngoverned`, and + `requestApproval-Atj0Sqo` account for most of it. +- Note the shape of the concentration: the remaining debt is dominated by two classes + (`ApprovalResumeCoordinator`, `ApprovalSuspensionCoordinator`) plus `DefaultApprovalGateway`. This is + a small number of large coroutine-heavy methods, not 158 unrelated problems. + +## 11. Path 4 — explicitly authorizing new residual debt + +What repository policy would have to change to permit it, described without recommending it: + +- The ratchet would need a candidate-side allowance the base authority does not constrain: either a + numeric ceiling on new NON_KILLED identities per transition, or an approval artifact the candidate + authors for its own findings. Both are properties of the *candidate*, while every current mechanism + (M01–M21) is a property of the *base*, which is the ratchet's whole design. +- The invariant *"a candidate PR cannot certify its own new survivors"* is exactly what M06 and M08 + exist to enforce. Permitting residual debt does not bypass that invariant at the rekey level; it + contradicts it at the policy level: the candidate would certify its own survivors by policy instead + of by record. Any ceiling high enough to admit 195 identities (or 158 if A inherits) is not a safety +- margin, it is the absence of one, and it would silently absorb the next transition's growth too. +- If new residual debt must ever be admitted, the only shape consistent with the current design is + authority-side and out of band: the debt is adjudicated and committed as part of an approval + ceremony that lands **outside** the PR introducing it, so the PR under review is judged against a + base that already carries the debt. That is Path 3 plus time, not a ratchet relaxation. +- I am not recommending this path, and nothing in this preflight should be read as authorization for it. + +## 12. Proposed minimum next steps (for decision, not started) + +1. Decide the lineage question as an **authority-side artifact** or not at all (Section 9.2). If the + answer is "not at all", category A is debt like B and C and Path 3 is the only road. +2. If Path 3 is chosen: kill the 36 SURVIVED, then triage the 43 TIMED_OUT against their timeout + budgets, then decide the 79 NO_COVERAGE by reachability. 158 identities remain under the most + generous reading of category A, so the work is real either way. +3. Land that work as its own transition, then evolve the authority as a plain removal baseline with + the 189 removal records — at which point M21 is the only mechanism left to exercise and recorded + evolution is sufficient. +4. Do not touch the verifier to make Path 1 pass. The M06 loop is the ratchet's core statement. + +## 13. Explicit non-claims + +- I did not modify any mutation authority, verifier, workflow, classification, threshold, family, + baseline or test. The working tree contains one new file (this document). +- I did not execute an evolution transition, and I did not apply the + `mutation-population-evolution` label. Path 1's failure is proven from control flow, not from an +- executed run (Section 8), and Path 2/3/4 are analyses only. +- No fresh mutation campaign was run: the A/B artifacts were present and byte-identical. +- The partition is descriptor-aware **and nothing more**. It is not a lineage, not a proof that a + category-A identity is the same mutation point as any predecessor, and not evidence about which + source revision introduced either side. +- I did not verify that the 189 base-only identities are *legitimately* removed (refactors, deleted + code); the g1A audit attributed them `SOURCE_REFACTORED`, and this document inherits that + attribution without re-deriving it. +- Category C being "genuinely new" means new with respect to the descriptor key and the method-level + cross-check. I did not diff source revisions to confirm every one of those methods is newly written. +- The whole-authority check in Section 5.6 establishes the outcomes at every category-A key across all 2384 + base identities. It does **not** establish that any category-A identity is the same mutation point as any + predecessor, and it is not evidence for lineage. +- The 37/51/107 split is not a safety verdict about any individual mutant, and no count here is a + recommendation to classify, exempt, or waive anything. +- The base SHA defect in Section 1 is unresolved by design: I used the resolved Epic tip and said so. + +## 14. Commands run + +``` +git fetch origin +git rev-parse origin/epic/0.7.1-control-plane-authority # e38ce9f9dbb3519947ffe3ea692932c934eb3f17 +git rev-parse 'e38ce9f9dbb3519947ffe692932c934eb3f17^{commit}' # no such object (37-char literal) +git status --porcelain # empty +git checkout -b task/0.7.1g1b-mutation-authority-evolution e38ce9f9dbb3519947ffe3ea692932c934eb3f17 +sha256sum /tmp/tramai-071g1a-population-A.json /tmp/tramai-071g1a-population-B.json +# both 06549e8a21e199b3f4c94fed359a3ae783e7796a29b27d6189c0dd261bc8a457 +python3 /tmp/tramai-071g1b-partition.py # Phase 2/3 analysis; writes /tmp/tramai-071g1b-*.json +``` + +Read for Phase 1 (no writes): `MutationRatchetVerifier.kt`, `MutationPopulationEvolution.kt`, +`MutationIdentity.kt`, `.github/workflows/ci.yml`. The analysis scripts live in `/tmp` and are not +committed; the JSON they produced is `/tmp/tramai-071g1b-{phase2,partition,m06}.json`. + +## 15. Working-tree proof + +Recorded BEFORE COMMIT, while the analysis ran — at that point the base was the tip and no task commit +existed yet: + +``` +$ git rev-parse HEAD +e38ce9f9dbb3519947ffe3ea692932c934eb3f17 + +$ git status --porcelain # before this document was written +(empty) + +$ git status --porcelain # after writing it — one entry, nothing else +?? docs/roadmap/0.7.0/TASK-0.7.1g1B-M06-EVOLUTION-PREFLIGHT.md +``` + +Re-run AFTER COMMIT, against the PR head rather than pasted here: amending this document moves the SHA, so +any hash quoted in this section would go stale. The expected output is: + +``` +$ git rev-parse HEAD^ # e38ce9f9dbb3519947ffe3ea692932c934eb3f17 — the task commit sits on the base +$ git show --stat --oneline HEAD # 1 file changed: this document +$ git status --porcelain # (empty) +``` + +Either way, the task commit contains exactly that one file. No authority, verifier, workflow, classification, +threshold, family, baseline, production source or test file appears in it, and the base SHA stays the tip +this branch was cut from. + +## 16. Final verdict + +``` +AUTHORITY_EVOLUTION_BLOCKED_BY_M06 +``` + +Every candidate-only NON_KILLED identity fails M06, and M06 is unconditional: it does not read the +evolution mode, and no evolution record shape can name a candidate-only identity. Recorded evolution +resolves M21 and nothing else, so a pure transition (fresh population + 189 records) leaves 195 M06 +failures and is not mergeable. I cannot demonstrate a legitimate resolution of those 195 failures under +the current verifier without a mechanism narrower than the descriptor key and adjudicated outside the +transition it authorizes, which does not exist today. 158 of the 195 remain even under the most +generous reading (category A inheriting), so the path forward is debt repair, not authority evolution. + +### 16.1 Durable conclusion + +**M21 evolution and M06 debt are separate problems.** The transition in front of us is: + +``` +189 disappearing identities → understood by recorded evolution / M21 +195 candidate-only NON_KILLED → rejected independently by M06 +``` + +Even under the most permissive hypothetical reading of category A: + +``` +195 candidate-only NON_KILLED +- 37 possible inherited-debt candidates += 158 unresolved M06 +``` + +So the next slice is not a baseline replacement. It is either a mutation-debt closure slice, or — if lineage +is judged worth designing — an authority-side lineage design that lands before any ratification of it. M06 +should not be modified: it is currently doing exactly what it was written to do, preventing the candidate +that introduces new mutation identities from certifying those same survivors. + +Stop here. No fix implemented, no authority modified. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1C-M06-TIMEOUT-DEBT-TRIAGE.md b/docs/roadmap/0.7.0/TASK-0.7.1g1C-M06-TIMEOUT-DEBT-TRIAGE.md new file mode 100644 index 000000000..c6f27280e --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1C-M06-TIMEOUT-DEBT-TRIAGE.md @@ -0,0 +1,396 @@ +# TASK-0.7.1g1C — M06 TIMED_OUT Debt Triage + +Diagnostic slice for the first tranche of M06 debt: the 43 TIMED_OUT identities that remain after +the g1B preflight. No production code, test, mutation authority, verifier, threshold or +classification was modified. This document is the only artifact. + +## 1. Base and scope + +| Item | Value | +|---|---| +| Task branch | `task/0.7.1g1c-m06-timeout-triage` | +| Exact base | `5c5de97e0c32d14a70b95dec5e9435266d1f57bb` (Epic tip; 40 characters, verified equal to `origin/epic/0.7.1-control-plane-authority`) | +| Working tree at start | clean (`git status --porcelain` empty) | +| Bounded question | Of the 43 unresolved TIMED_OUT M06 identities, which are genuine mutation/test failures and which are artifacts of PIT timeout or harness behaviour? | + +### 1.1 Custody of the measurement evidence + +The population used here was measured at `0f36ab8744c14ebbdb3b10964311d0416e012ab2` (campaigns A and B, +g1A). The g1B preflight proved that chain for `e38ce9f9` (§3.1 of the g1B document); this slice extends +it to its own base, and states the result for the whole range: + +``` +$ git diff --name-only e38ce9f9dbb3519947ffe3ea692932c934eb3f17 5c5de97e0c32d14a70b95dec5e9435266d1f57bb +docs/roadmap/0.7.0/TASK-0.7.1g1B-M06-EVOLUTION-PREFLIGHT.md + +$ git diff --exit-code 0f36ab8744c14ebbdb3b10964311d0416e012ab2 5c5de97e0c32d14a70b95dec5e9435266d1f57bb \ + -- config/quality/test-quality.yml config/quality/mutation-baseline.json \ + config/quality/mutation-classifications.yml config/quality/mutation-evolution.yml \ + build-logic/src/main/kotlin/dev/tramai/build/quality +(no output, exit 0) + +$ git diff --name-only 0f36ab87 5c5de97e | wc -l +9 +$ git diff --name-only 0f36ab87 5c5de97e | grep -cE '^tramai-(engine|security|sovereign|core|structured)/' +0 +``` + +The nine files, in full: + +``` +build-logic/src/main/kotlin/dev/tramai/build/docs/DocsContractVerifierTask.kt +build-logic/src/main/kotlin/dev/tramai/build/docs/PromotedReleaseVerifier.kt +build-logic/src/main/kotlin/dev/tramai/build/docs/VersionAlignmentVerifier.kt +build-logic/src/test/kotlin/dev/tramai/build/docs/TramaiDocsGuardsPluginTest.kt +build-logic/src/test/kotlin/dev/tramai/build/quality/CanonicalProbeFunctionalTest.kt +build-logic/src/test/kotlin/dev/tramai/build/quality/ResidualQualityVerifierTasksTest.kt +docs/roadmap/0.7.0/TASK-0.7.1g1A-MUTATION-POPULATION-DRIFT-AUDIT.md +docs/roadmap/0.7.0/TASK-0.7.1g1B-M06-EVOLUTION-PREFLIGHT.md +tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowMcpStepTest.kt +``` + +Across the whole chain from the measurement SHA to this base, **nine** files changed and **none of them is a +mutation-target input**: the analyzer, the mutation configuration, the authority and the classifications +are byte-identical, and no production or test source under a configured mutation target module changed. +The A/B populations are therefore valid evidence at `5c5de97e`, and no fresh 50-minute campaign was run +for this slice — a third campaign would repeat a measurement already demonstrated deterministic (A and B +are byte-identical, `sha256 06549e8a21e199b3f4c94fed359a3ae783e7796a29b27d6189c0dd261bc8a457`). + +## 2. Phase A — the 43, reproduced exactly + +### 2.1 The raw PIT reports, joined to canonical identities + +The g1A campaign's raw PIT reports (`/tmp/tramai-071g1a-pit-A`, 14 `mutations.xml` files, one per +family/module) were re-read and every `` element was recomputed into a canonical identity +(`SHA-256` over module, class, method, methodDescription, mutator, description, block, index — the schema +of `MutationIdentity.kt`) and matched against the fresh population: + +| Check | Result | +|---|---| +| PIT mutation records parsed | 2544 | +| Fresh population rows | 2544 | +| Duplicate identities in the raw reports | 0 | +| Records whose recomputed identity did not match the population | 0 | +| Raw status disagreements between campaign A and campaign B | 0 | + +The raw reports and the canonical population agree exactly, identity for identity, with no duplicates +and no status disagreement — including for every TIMED_OUT mutation. + +Raw PIT status totals in campaign A: + +| raw status | count | +|---|---| +| KILLED | 1667 | +| NO_COVERAGE | 330 | +| SURVIVED | 463 | +| TIMED_OUT | 84 | + +### 2.2 Where the 84 timeouts sit + +The campaign contains **84** TIMED_OUT mutations. The M06 set (candidate-only NON_KILLED) holds 46 of +them; three of those are category A, which leaves the **43** in scope for this slice. The rest are +already-authoritative timeouts that the base already carries: + +| bucket | TIMED_OUT | +|---|---| +| candidate-only/A | 3 | +| candidate-only/B | 21 | +| candidate-only/C | 22 | +| shared | 38 | + +Of the 84, 80 are `NegateConditionalsMutator`, 3 are `VoidMethodCallMutator` and 1 is +`ConditionalsBoundaryMutator`. Family concentration: `approval` 76 (65 on `:tramai-engine`, 11 on +`:tramai-security`), `retry` 5, `policy` 2, `routing` 1. + +### 2.3 The exact 43 + +Exact identity set, sorted: sha256 `8fd6d1ef269497f40ac7195dc61537100892f3a63cf168efd3ceec330778a64a`. + +By class#method, with raw PIT status (all TIMED_OUT) and the count: + +| class#method | count | +|---|---| +| `ApprovalResumeCoordinator#authorizeResume` | 2 | +| `ApprovalResumeCoordinator#prepareResume` | 5 | +| `ApprovalResumeCoordinator#resume` | 8 | +| `ApprovalResumeCoordinator#revealAndValidateReplayPayload` | 1 | +| `ApprovalResumeCoordinator$resume$2#invokeSuspend` | 1 | +| `ApprovalSuspensionCoordinator#compensateStep` | 1 | +| `ApprovalSuspensionCoordinator#compensateSuspension` | 3 | +| `ApprovalSuspensionCoordinator#suspendToolExecution` | 6 | +| `ApprovalSuspensionCoordinator$compensateSuspension$2$1#invokeSuspend` | 1 | +| `ApprovalSuspensionCoordinator$compensateSuspension$2$2#invokeSuspend` | 1 | +| `ApprovalSuspensionCoordinator$compensateSuspension$2$3#invokeSuspend` | 1 | +| `DefaultApprovalGateway#persistGoverned` | 3 | +| `DefaultApprovalGateway#persistUngoverned` | 3 | +| `DefaultApprovalGateway#requestApproval-Atj0Sqo` | 6 | +| `DefaultApprovalGateway#requireExistingAttributionMatches` | 1 | + +Every one of the 43 is a `NegateConditionalsMutator` mutation and every one reports +`numberOfTestsRun=0`: no test in the unit completed +before PIT's minion was killed. That is the signature of a hang, not of a slow run. + +## 3. Phase B — why they time out + +### 3.1 The mutation points are Kotlin coroutine suspension checks + +Every non-terminating mutant in scope sits on a source line that is a **suspend call site**. Kotlin +compiles each such call site as: + +``` + 137: invokespecial prepareResume:(...) // the suspend call at source line 65 + 140: dup + 141: aload 12 // the local holding COROUTINE_SUSPENDED + 143: if_acmpne 165 // continue only if the call really returned +``` + +`NegateConditionalsMutator` negates exactly that `if_acmpne`. The state machine then continues when the +call actually suspended (and returns the sentinel when it did not), so the continuation is never resumed +and the awaiting test never completes. Non-termination is not a side effect here — it is the mutation's +behaviour. + +Attribution was done against the compiled bytecode of this base (`javap -p -c -l`), by tracking which +local receives `IntrinsicsKt.getCOROUTINE_SUSPENDED` and locating the comparison that reads it, then +mapping the comparison offset through the `LineNumberTable`: + +| Result | Count | +|---|---| +| in-scope mutation points directly attributed to a `getCOROUTINE_SUSPENDED` comparison | 39 | +| in-scope points in synthetic suspend-lambda classes, each containing exactly one sentinel reference (`$resume$2`, `$compensateSuspension$2$1/2/3`) | 4 | +| **total** | **43** | + +The comparison that implements the check is `if_acmpne` in all 54 attributed sites across these classes. + +### 3.2 It is not a timeout-budget problem + +PIT's per-unit budget is `timeoutConst + timeoutFactor × baseline unit time`. Both values are set in the +single authority for the canonical init script, `MutationProbeInitScript.kt:32-33` +(`TIMEOUT_CONST_MILLIS = 4_000L`, `TIMEOUT_FACTOR = 1.25`) and applied at `:112-113`; `test-quality.yml` +carries no timeout keys, and this slice touched neither. Measured against that: + +| Measurement | Value | +|---|---| +| covering test classes for the timed-out classes | 11 | +| their total test time, freshly measured (`./gradlew :tramai-engine:test --tests "dev.tramai.engine.approval.*" --rerun-tasks`) | 0.7 s total; slowest class 0.20 s | +| PIT's own baseline diagnostic on the approval unit runs | `0 tests took longer than 2000 ms` | +| derived per-unit timeout for those units | ≈ 4.0 s + 1.25 × (well under 1 s) ≈ **4.3 s** | + +A mutant killed at ~4.3 s against a sub-second baseline is not running slowly; it is not terminating. The +budget is generous by roughly an order of magnitude, so `HARNESS_BUDGET` is not a valid explanation for +this cluster, and no timeout setting needs loosening. + +### 3.3 It is deterministic, not scheduling + +Two independent campaigns produced byte-identical populations, and the 43 identities have identical raw +statuses in both (`A_vs_B_status_mismatches = 0`). The timeout follows the mutation identity, not the +machine's mood. `NONDETERMINISTIC_EXECUTION` is not a valid explanation here. + +### 3.4 Negating a sentinel check usually hangs; NegateConditionals elsewhere usually does not + +Restricting the population to the sites attributed above sharpens the picture. On the **54 attributed +sentinel sites**, the `NegateConditionals` mutants distribute as: + +| status | count | +|---|---| +| TIMED_OUT | 51 (every one with `numberOfTestsRun=0`) | +| KILLED | 3 | +| NO_COVERAGE | 3 | +| **total** | **57** | + +All 54 sites carry at least one such mutant. The rest of the population behaves the opposite way: the +**1107** `NegateConditionals` mutants that do **not** sit on an attributed sentinel site are 925 KILLED, +103 SURVIVED, 29 TIMED_OUT and 50 NO_COVERAGE. + +An earlier version of this document said "most negations of a suspension check are killed" on the strength +of the population-wide 1164 figure. That claim did not follow from that denominator and is **wrong**: 51 of +57 (89%) of the sentinel-site negations hang. The three KILLED sentinel-site mutants do matter, though — +detection is not impossible in principle, which is why §5 keeps "bound the test" alive as a hypothesis to +be measured rather than assuming the opposite. + +The off-site bucket means **not on one of the 54 sentinel sites attributed in this slice** — it is not a +claim that those mutants are proven not to be sentinel sites. The attribution pass covered only the +inspected classes, so the 1107 residual mutants may include further sentinel-check sites in classes this +slice never opened. + +41 of the 43 in-scope lines carry the timeout as the *only* mutation on that line; the remaining 2 lines +also carry one killed sibling negation, which shows those lines' ordinary conditionals are detectable. + +The 43 in-scope identities themselves distribute 39 on the 54 attributed sites and 4 on the synthetic +suspend-lambda sites, so the attribution covers the entire scope. + +## 4. Phase C — cause classification (investigation result, not authority) + +These buckets are diagnostic labels for this report. They are **not** mutation classifications, they do +not enter `mutation-classifications.yml`, and they grant no exemption from M06. + +| bucket | count | evidence | +|---|---|---| +| `PATHOLOGICAL_MUTANT` | **43 / 43** | the negated conditional is the coroutine suspension sentinel check; negation makes resumption impossible, and `numberOfTestsRun=0` shows the unit never completed a single test | +| `HARNESS_BUDGET` | 0 | covering tests run in 0.7 s against a ~4.3 s budget | +| `TEST_HANG_OR_DEADLOCK` | 0 | no test-side hang was observed on its own; the hang follows the mutant, and disappears with it | +| `NONDETERMINISTIC_EXECUTION` | 0 | A and B agree identity-for-identity | +| `REAL_MISSING_PROTECTION` | 0 identified | no ordinary assertion gap was found: the mutant is not "uncovered", it is undetectable *by completion* — the code under test never returns, so no assertion of any strength can run. Whether bounded **liveness** protection is materially missing is a different question, and §4.1 does not settle it. | + +The honest summary: **this is one mechanical cause, not 43 defects** — a property of the intersection of +Kotlin coroutines, PIT's `NegateConditionals` mutator and the JVM. The 43 in scope share this +coroutine-sentinel failure mode. Whether equivalent sentinel negations elsewhere also time out depends on +their covering tests and is **not** established by this slice. + +### 4.1 What this classification does not establish + +`REAL_MISSING_PROTECTION = 0` means **no ordinary assertion gap was identified**. It is not a semantic +zero and it should not be frozen as one. The mutants are pathological at the compiler-generated suspension +check — that much is proven — but if a bounded test can detect the resulting non-termination, then that +test also establishes a legitimate liveness property of the approval workflow, and the *absence* of such a +test is a real gap of a different kind. Stating that as a missing protection would be premature; stating it +as zero would be worse. It is measured in the next slice (g1D), not here. + +## 5. Phase D — how the authority should represent this (decision, not implementation) + +The scope of this slice was triage. No fix is applied, deliberately, and the reason is not caution: the +triage result changes *what the fix would be*, and three of the four available levers are forbidden or +weakening. + +| Option | Assessment | +|---|---| +| Kill them with stronger tests | The only non-weakening lever. A test that is bounded in wall-clock time fails when the mutant stops terminating, and a failing test is a **kill** in PIT's eyes — the identity leaves M06 honestly and the suite gains a real safety property. Not implemented here: the covering tests currently have no wall-clock bound, and whether a bound converts this specific hang into a kill (rather than a leaked thread that still outlives the minion) is a *measurement*, not a deduction. It needs a demonstration before it is written into tests. | +| Classify them as approved survivors | Requires 43 authority-side approval records adjudicated on master. It would also misdescribe the observation: these mutants are not "accepted risk", they are undetectable-by-completion. Expensive and semantically wrong. | +| Change the canonical mapping (e.g. treat TIMED_OUT as KILLED) | **Weakening, and out of scope.** A timeout is not evidence of detection: a hung or overloaded test would also produce it. This slice did not touch it and recommends against it. | +| Remove the mutation points from the target set | **Narrowing the measurement, and forbidden** by the task's own constraints (`add/remove mutation target families`, target-class narrowing is an M14/M15 failure). | + +### 5.1 What the next slice must demonstrate before any test change + +The family-scoped machinery already exists and should be reused rather than duplicated: +`MutationProbeInitScript` renders the canonical PIT init script and selects exactly one family through +`gradle.startParameter.projectProperties['tramaiMutationFamily']` (`:67`), refusing an unknown or missing +family (`:68-70`), applying that family's `targetClasses`/`targetTests` (`:105-106`) and registering +`canonicalMutationProbe` over its `pitest` tasks (`:121`). That is how the canonical campaign was rendered +family-by-family, and one family's run is minutes, not fifty. + +So the gap is narrower than "there is no scoped capability": + +1. **Add a per-class selection path through the existing renderer** — a way to isolate e.g. + `ApprovalResumeCoordinator` without running the broad canonical ceremony, without touching analyzer + semantics (target classes, mutators, `timeoutConst`, `timeoutFactor` all stay as they are). This is + `build-logic` class and must not ride a documentation or debt PR. +2. **Start from the three KILLED sentinel-site mutants, not from a timeout.** They are the control group: + §3.4 established that 3 of 57 mutants on attributed sentinel sites are already killed while 51 time out. + Why do those three die — do their covering tests impose a deadline, use a different scheduler, exercise + a non-suspending path, or fail on another observable effect? Deriving that difference is what can produce + the right test shape, instead of introducing a timeout blind and discovering afterwards what it hides. +3. **Reproduce one known TIMED_OUT identity unchanged** under the per-class path, then **add the bounded + liveness test** the control group implies and observe whether PIT records KILLED — or TIMED_OUT with a + leaked thread, in which case the bound hides the hang and must be rejected. +4. **Then repeat for determinism**, and only then generalise to the other 42. +5. **Then re-measure the canonical population** to quantify the actual debt reduction. + +I am not recommending an accelerated version of this, and if the bounded test still yields TIMED_OUT the +experiment stops there rather than mass-editing tests. The user-facing risk of guessing wrong is a test +suite that appears to detect non-termination while actually leaking threads into the next test. + +## 6. Remaining M06 debt, stated exactly + +| Component | Count | Status after this slice | +|---|---|---| +| TIMED_OUT (categories B+C) | 43 | investigated and explained in this document; **still NON_KILLED, still M06 debt** | +| SURVIVED | 36 | untouched; needs stronger tests | +| NO_COVERAGE | 79 | untouched; needs reachability adjudication | +| **total** | **158** | unchanged | + +The M06 population is unchanged by this slice: 195 candidate-only NON_KILLED identities, of which 158 +remain under the most permissive reading of category A. Explaining a debt is not paying it, and this +document claims no reduction. + +## 7. Explicit non-claims + +- No production code, test, build-logic, workflow, threshold, family, baseline, evolution record or + classification was modified. The only artifact is this document. +- No fresh mutation campaign was run. The A/B populations are reused under the custody proof in §1.1. +- No mutant was killed, classified, exempted or removed. The 43 remain M06 failures. +- The `PATHOLOGICAL_MUTANT` label is a diagnostic bucket in this report, not a mutation classification, + and it must not be lifted into `mutation-classifications.yml`. +- The claim "negating the suspension sentinel check causes non-termination" is supported by direct + bytecode attribution (43/43) plus PIT's own observation, not by a controlled single-mutant experiment. +- The derived ~4.3 s PIT budget is **derived** from the committed `TIMEOUT_CONST_MILLIS` / + `TIMEOUT_FACTOR` (`MutationProbeInitScript.kt:32-33`) and the measured baseline, not read from PIT's + verbose log (verbose logging is disabled in campaign runs). +- I did not verify that a bounded test converts any of these timeouts into a kill; §5.1 says so and + proposes the measurement rather than assuming its outcome. +- The other 41 timeouts in the campaign (38 shared, and the 3 category-A ones) were not investigated and + are not characterised by this document. + +## 8. Commands run + +``` +git fetch origin && git rev-parse origin/epic/0.7.1-control-plane-authority # == 5c5de97e... +git status --porcelain # empty +git checkout -B task/0.7.1g1c-m06-timeout-triage 5c5de97e0c32d14a70b95dec5e9435266d1f57bb +git diff --exit-code 0f36ab87 5c5de97e -- \ + config/quality/test-quality.yml config/quality/mutation-baseline.json \ + config/quality/mutation-classifications.yml config/quality/mutation-evolution.yml \ + build-logic/src/main/kotlin/dev/tramai/build/quality # exit 0, no output +python3 /tmp/tramai-071g1c-phaseab.py # raw PIT XML inventory + PIT timing diagnostics +python3 /tmp/tramai-071g1c-joina.py # identity join: raw reports <-> canonical population +./gradlew :tramai-engine:compileKotlin # for bytecode inspection +CP=tramai-engine/build/classes/kotlin/main +for c in ApprovalResumeCoordinator ApprovalSuspensionCoordinator DefaultApprovalGateway; do + javap -p -c -l -classpath $CP dev.tramai.engine.approval.$c +done +for c in 'ApprovalResumeCoordinator$resume$2' 'ApprovalSuspensionCoordinator$compensateSuspension$2$1' \ + 'ApprovalSuspensionCoordinator$compensateSuspension$2$2' 'ApprovalSuspensionCoordinator$compensateSuspension$2$3'; do + javap -p -c -classpath $CP "dev.tramai.engine.approval.$c" +done +python3 /tmp/tramai-071g1c-phaseb.py # sentinel attribution via LineNumberTable +./gradlew :tramai-engine:test --tests "dev.tramai.engine.approval.*" --rerun-tasks # 0.7s, 11 classes +``` + +Analysis scripts live in `/tmp` and are not committed; the JSON they produced is +`/tmp/tramai-071g1c-{phaseA,phaseB,phase-ab}.json`. + +## 9. Working-tree proof + +### 9.1 Before the task commit + +``` +$ git rev-parse HEAD +5c5de97e0c32d14a70b95dec5e9435266d1f57bb # the base, unmoved +$ git status --porcelain # empty at task start +$ git status --porcelain # after writing this document, before committing: +?? docs/roadmap/0.7.0/TASK-0.7.1g1C-M06-TIMEOUT-DEBT-TRIAGE.md +``` + +### 9.2 After the task commit + +Commands, and the values that hold for this document's own commit: + +``` +$ git rev-parse HEAD^ +5c5de97e0c32d14a70b95dec5e9435266d1f57bb # the task commit's parent is the base +$ git diff --name-only HEAD^ HEAD +docs/roadmap/0.7.0/TASK-0.7.1g1C-M06-TIMEOUT-DEBT-TRIAGE.md +$ git diff --name-only 5c5de97e HEAD | wc -l +1 # exactly one file changed relative to base +$ git status --porcelain # empty +``` + +The literal head SHA is deliberately **not** hardcoded here: it is the SHA of the commit that contains +this paragraph, so any literal value would be falsified by the act of committing it — the same +temporal-impossibility trap that an earlier revision of this section fell into by showing `HEAD == base` +and "the task head commit" in one transcript. The invariant that matters is checkable regardless: +`HEAD^ == 5c5de97e`, one changed file against base, clean status. + +## 10. Outcome + +``` +TRIAGED — 43 / 43 TIMED_OUT IDENTITIES EXPLAINED, NONE KILLED, NO CODE CHANGED +``` + +The 43 are one mechanical cause: negation of Kotlin's coroutine suspension sentinel check, deterministic, +with a timeout budget an order of magnitude larger than the covering tests need. None of them is an +ordinary missing-assertion problem, so none can be closed by writing more assertions of the usual kind; +and none of them justifies loosening a timeout or changing M06. The cheapest honest route to closing them +is a bounded covering test, which must be demonstrated on one cluster before it is generalised — a +separate slice (g1D), reusing the existing family-scoped renderer with a per-class selection path, not +part of this document. + +M06 is untouched. Authority evolution remains blocked until this debt is genuinely resolved. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1D-SENTINEL-LIVENESS-KILL-PROOF.md b/docs/roadmap/0.7.0/TASK-0.7.1g1D-SENTINEL-LIVENESS-KILL-PROOF.md new file mode 100644 index 000000000..162361355 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1D-SENTINEL-LIVENESS-KILL-PROOF.md @@ -0,0 +1,300 @@ +# TASK-0.7.1g1D — Scoped Sentinel Liveness Kill Proof + +Investigation of the g1C control group: the three `NegateConditionals` mutants that appeared to show a +coroutine-sentinel mutation being **KILLED** while 51 structurally similar ones time out. + +**Outcome: the controls are not sentinel mutations at all.** They negate ordinary null-check conditionals +that happen to share a source line with a suspension-sentinel comparison. By the g1D stop conditions, the +diagnostic probe (Phase A), the faithfulness proof (Phase C) and the liveness experiment (Phase D) were +**not** performed — see §6 — and no production code, test, build-logic, authority, threshold or +classification was modified. This document is the only artifact. + +## 1. Scope + +| Item | Value | +|---|---| +| Task branch | `task/0.7.1g1d-sentinel-liveness-kill-proof` | +| Exact base | `5a71dca293b10be0a3208dacf166588ffc738f2a` (Epic tip; 40 characters, verified equal to `origin/epic/0.7.1-control-plane-authority` before branching) | +| Working tree at start | clean (`git status --porcelain` empty) | +| Bounded question | Why are three attributed coroutine-sentinel mutants KILLED while 51 are TIMED_OUT, and is that mechanism transferable? | +| Verdict | `CONTROL_MECHANISM_NOT_TRANSFERABLE` | + +### 1.1 Evidence custody + +This slice reuses the raw PIT reports and populations measured for g1A and analysed in g1C: 14 +`mutations.xml` files (`/tmp/tramai-071g1a-pit-A`), campaign A and B populations, and the g1C Phase B +bytecode attribution. The range `0f36ab87 → 5a71dca2` changes only documentation and the three +build-logic/docs + orchestration-test files already custody-proved in g1C §1.1; no mutation-target input +changed, so no fresh campaign was warranted. + +## 2. Phase B — the three KILLED records, reconstructed exactly + +Root cause of the g1C control group: g1C attributed sentinel sites at **(class, method, line)** +granularity and then reported statuses at that granularity. A source line can carry **both** the +compiler-generated suspension check **and** ordinary conditionals from the same expression. Counting +mutant statuses per line therefore mixes two different populations. + +The three KILLED records, with full evidence: + +### 2.1 `ApprovalResumeCoordinator#prepareResume` @ line 126 + +| Field | Value | +|---|---| +| canonical identity | `84a24307a542aedc17fc0d87a91bebac6f685daa8bad081160b9c962af7be339` | +| family / module | tramai-engine / `:tramai-engine` | +| class | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | +| method + descriptor | `prepareResume(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;` | +| mutator | `NegateConditionalsMutator` — negated conditional | +| source | `ApprovalResumeCoordinator.kt:126` | +| block / index | 28 / 160 | +| raw PIT status | **KILLED** | +| numberOfTestsRun | 1 | +| killing test | `[method:missing suspended metadata yields ApprovalNotFoundException()]` | + +### 2.2 `ApprovalResumeCoordinator#prepareResume` @ line 131 + +| Field | Value | +|---|---| +| canonical identity | `01063c9a48c58a603826c50ad15171a04fa47d94c9682c45936534e77fbde1ad` | +| family / module | tramai-engine / `:tramai-engine` | +| class | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | +| method + descriptor | `prepareResume(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;` | +| mutator | `NegateConditionalsMutator` — negated conditional | +| source | `ApprovalResumeCoordinator.kt:131` | +| block / index | 35 / 196 | +| raw PIT status | **KILLED** | +| numberOfTestsRun | 1 | +| killing test | `[method:nested approval cancels state and never claims or executes()]` | + +### 2.3 `ApprovalResumeCoordinator#revealAndValidateReplayPayload` @ line 218 + +| Field | Value | +|---|---| +| canonical identity | `0b1dfa3878dfeb481301c9688453981c30a605bf6918c983cb1ef44320bee5e7` | +| family / module | tramai-engine / `:tramai-engine` | +| class | `dev.tramai.engine.approval.ApprovalResumeCoordinator` | +| method + descriptor | `revealAndValidateReplayPayload(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;` | +| mutator | `NegateConditionalsMutator` — negated conditional | +| source | `ApprovalResumeCoordinator.kt:218` | +| block / index | 14 / 94 | +| raw PIT status | **KILLED** | +| numberOfTestsRun | 1 | +| killing test | `[method:missing replay envelope leaves continuation claimed and uncompleted()]` | + +## 3. Why they are killed: they are ordinary conditionals, not suspension checks + +Three independent lines of evidence agree. + +### 3.1 Bytecode opcodes on the same lines + +`javap -p -c -l` over `dev.tramai.engine.approval.ApprovalResumeCoordinator`, attributing each conditional +jump through its `LineNumberTable` (offset → source line): + +| line | conditional jumps on that line | +|---|---| +| 126 | `if_acmpne` @257 (**sentinel**); `ifnonnull` @302 (ordinary) | +| 131 | `ifnull` @347 (ordinary); `if_acmpne` @401 (**sentinel**) | +| 218 | `if_acmpne` @143 (**sentinel**); `ifnonnull` @187 (ordinary) | + +Every control line holds exactly one `if_acmpne` — the coroutine suspension sentinel — and exactly one +ordinary null-check conditional. The KILLED mutation is the null check; the sentinel's mutation on the +same line is TIMED_OUT. + +### 3.2 The status counting is exact — 54 of 54 sentinel lines + +For every line that carries a sentinel comparison, the mutant status counts partition exactly along the +two instruction classes: + +| quantity | value | +|---|---| +| sentinel-bearing lines | 54 | +| sentinel comparisons on them (from bytecode) | 54 | +| mutants with status TIMED_OUT on those lines | 51 | +| mutants with status NO_COVERAGE on those lines | 3 | +| mutants with status KILLED on those lines | 3 | +| ordinary conditional jumps on those lines | 3 | + +`TIMED_OUT + NO_COVERAGE = 54 = 54`, exactly the number of sentinel comparisons, and +`KILLED = 3 = 3`, exactly the number of ordinary conditionals. The partition is forced: +**every sentinel negation hangs or is uncovered, and every kill on those lines belongs to an ordinary +conditional.** + +### 3.3 The killing tests assert on the null-check paths + +| control | killing test | what it asserts | +|---|---|---| +| `prepareResume` @126 | `missing suspended metadata yields ApprovalNotFoundException()` | absence of suspended metadata → the `ifnonnull` guard produces an exception | +| `prepareResume` @131 | `nested approval cancels state and never claims or executes()` | nested-state `ifnull` branch → observable state assertion | +| `revealAndValidateReplayPayload` @218 | `missing replay envelope leaves continuation claimed and uncompleted()` | absent envelope → the `ifnonnull` guard path | + +Each killing test observes a **value or exception produced before or instead of** suspension. None of them +demonstrates that a broken suspension protocol can be detected — which is the only thing that would have +made them a control group for bounded liveness. + +## 4. The corrected population picture + +Within the attributed scope (the three classes plus four synthetic suspend-lambda classes): + +| outcome of the single `NegateConditionals` mutant on each sentinel comparison | count | +|---|---| +| TIMED_OUT | 51 | +| NO_COVERAGE | 3 | +| KILLED | **0** | +| **total sentinel comparisons** | **54** | + +Every sentinel comparison in scope carries exactly one `NegateConditionals` mutant, and **not one of them +is killed**. There is therefore no observed instance anywhere in this scope of a test detecting a broken +coroutine suspension check. + +## 5. Corrections to the g1C record + +The merged g1C document is still correct on its central finding — the 43 in-scope TIMED_OUT identities are +all suspension-sentinel negations, deterministic, and not explained by timeout budget. Two of its +statements were built on the line-level control group and are **superseded by this slice**: + +| g1C statement | corrected by this slice | +|---|---| +| §3.4: "The three KILLED sentinel-site mutants do matter, though — detection is not impossible in principle, which is why §5 keeps 'bound the test' alive as a hypothesis to be measured" | The three are ordinary null-check mutations. They are not evidence that a sentinel negation can be detected, so "detection is not impossible in principle" loses its only support. | +| §5.1 step 2: "Start from the three KILLED sentinel-site mutants, not from a timeout. They are the control group" | There is no control group. That step is void; the bounded-liveness hypothesis now rests on zero positive evidence. | + +The corrected reading is narrower and less comfortable: 51 of 54 sentinel comparisons hang and 3 are +uncovered. Nothing in the existing evidence shows a suspension-check negation being detected, so **test +strength alone has no demonstrated route to closing the 43.** + +## 6. Why Phases A, C and D were not performed + +The task's own stop conditions trigger here: + +> Stop and report instead of broadening scope if … the KILLED controls are killed for unrelated/non-transferable reasons + +and Phase D is explicitly conditional: + +> Only if Phase B identifies a credible transferable mechanism + +Phase A's diagnostic probe exists to run the Phase C/D experiment. That experiment has no hypothesis left +to test — the control group dissolved into ordinary mutations — so building the probe now would add a +mutation-measurement capability with nothing to measure, in `build-logic`, on a task whose premise did not +survive its own investigation. The stop-and-report condition is the correct exit, and the task's Socratic +clause anticipated exactly this outcome. + +Deliberately **not** done: no per-class diagnostic path, no `diagnosticMutationProbe`, no test change, no +attempt to manufacture a timeout that turns PIT green, no M06 or authority change. + +### 6.1 What would unblock a future slice + +1. **A real control**: any suspension-sentinel negation that is genuinely KILLED. Finding one bounds the + search — e.g. sweeping the remaining mutation population for a suspend call site whose covering test + fails for a liveness reason. Without one, bounded liveness is unfalsifiable from inside this repo. +2. **The representation question is not open, and this document was wrong to imply otherwise.** An earlier + draft of §6.1 said a representation for these identities would be "impossible without weakening M06". + That is **false**, and the repository itself disproves it: `config/quality/mutation-classifications.yml` + already carries six `tool-limitation` classifications, one of which — + `id 1923c2a12c991940f93bbc4a17c54ce268f8299ccc49c57efb6bee7d1ff65914` — records **exactly this + mechanism**: "PIT negates the compiler-generated COROUTINE_SUSPENDED state-machine dispatch comparison + (bytecode offset 140 if_acmpne, block 12, reported line 22) … Negation prevents normal continuation + resumption, deterministically deadlocking the awaiting test and reaching PIT's timeout (reproduced + isolated at 17s with only ModelRegistryEnforcerTest selected; not a broad-selection artifact)", together + with proof that the source-semantic conditional on the same line is independently KILLED. + That entry requires **no** change to raw `TIMED_OUT`, to canonical `NON_KILLED`, or to M06. The three + layers stay as they are — measurement (`rawStatus = TIMED_OUT`), canonical outcome + (`outcome = NON_KILLED`, read literally as "PIT did not kill this mutant"), and authority disposition + (`classification = tool-limitation`) — and an exact-identity, base-authoritative classification is what + makes such an outcome acceptable. + What remains open is therefore **adjudication, not representation**: whether these 43 identities satisfy + that existing base-authoritative standard, which is a governance task for a later slice and explicitly + not something g1D can assert. + +## 7. Verdict + +``` +CONTROL_MECHANISM_NOT_TRANSFERABLE +``` + +The three KILLED records are ordinary `ifnull`/`ifnonnull` negations on lines that also carry a +suspension-sentinel comparison. They were killed by named tests asserting on exception and state paths, +they never exercised a permanently-suspended coroutine, and they carry no mechanism that transfers to a +hanging sentinel negation. Phase C's requirement — reproduce a KILLED control and a TIMED_OUT identity +under a scoped probe — cannot be satisfied for the control half, because no sentinel control exists. + +## 8. Remaining M06 debt + +| Component | Count | Status after this slice | +|---|---|---| +| TIMED_OUT | 43 | explained by g1C, control group disproved here; still NON_KILLED | +| SURVIVED | 36 | untouched | +| NO_COVERAGE | 79 | untouched | +| **total** | **158** | **unchanged** | + +No mutant was killed, classified, exempted or removed. M06 is untouched and authority evolution remains +blocked. + +## 9. Non-claims and limitations + +- No production code, test, build-logic, workflow, threshold, family, baseline, evolution record or + classification was modified. The only artifact is this document. +- The attribution covers the seven classes inspected in g1C. "No sentinel negation is killed" is a + statement about those 54 comparisons, not about the whole population; sentinel sites in uninspected + classes were not enumerated. +- The opcode attribution depends on `LineNumberTable` mapping and on the parser treating one mutation per + conditional jump; that assumption holds on all sentinel lines here (54/54), and this document does not + claim the mapping is exact at the `block`/`index` level, which was never established. +- Three non-sentinel lines in the data show more mutants than conditional jumps found by the parser + (`ApprovalSuspensionCoordinator#validateInitialApprovalRequirement` @133 and @138, + `#validateRenewedApprovalRequirement` @117: two mutants, one jump). Those lines carry no sentinel and do + not affect §3.2, but the discrepancy is recorded rather than hidden. +- No fresh PIT campaign was run, and no diagnostic probe exists, so no mutant status was re-measured in + this slice. + +## 10. Commands run + +``` +git fetch origin && git rev-parse origin/epic/0.7.1-control-plane-authority # == 5a71dca2... +git status --porcelain # empty +git checkout -B task/0.7.1g1d-sentinel-liveness-kill-proof 5a71dca293b10be0a3208dacf166588ffc738f2a +python3 /tmp/tramai-071g1d-controls.py # exact KILLED records + killing tests from raw PIT XML +python3 /tmp/tramai-071g1d-linejoin.py # per-line bytecode conditionals vs per-line mutant statuses +# javap -p -c -l -classpath tramai-engine/build/classes/kotlin/main dev.tramai.engine.approval.ApprovalResumeCoordinator +``` + +## 11. Working-tree proof + +### 11.1 Before the task commit + +``` +$ git rev-parse HEAD +5a71dca293b10be0a3208dacf166588ffc738f2a # the base, unmoved +$ git status --porcelain # empty at task start +``` + +### 11.2 After the task commit + +``` +$ git rev-parse HEAD^ +5a71dca293b10be0a3208dacf166588ffc738f2a # the task commit's parent is the base +$ git diff --name-only HEAD^ HEAD +docs/roadmap/0.7.0/TASK-0.7.1g1D-SENTINEL-LIVENESS-KILL-PROOF.md +$ git diff --name-only 5a71dca2 HEAD | wc -l +1 # exactly one file changed relative to base +$ git status --porcelain # empty +``` + +As in g1C §9.2, the head SHA is deliberately not hardcoded: it is the SHA of the commit containing this +paragraph. The checkable invariants are `HEAD^ == base`, one changed file, clean status. + +## 12. Outcome + +``` +CONTROL_MECHANISM_NOT_TRANSFERABLE — 0 of 54 SENTINEL NEGATIONS ARE KILLED +``` + +The control group g1C handed to g1D does not exist: those three kills are ordinary null-check mutations +sharing a line with a suspension check. The bounded-liveness approach therefore has no positive evidence +behind it, and g1D stopped at Phase B rather than building a probe and a test to chase it. The 43 M06 +TIMED_OUT identities remain unexplained in the only sense that matters — nothing in the suite demonstrably +detects a broken suspension protocol. + +That does not make the debt unrepresentable. The authority already has an exact-identity, +base-authoritative way to record a deterministic PIT timeout on a compiler-generated suspension check as +`tool-limitation` without changing raw `TIMED_OUT`, canonical `NON_KILLED` or M06 (§6.1). So the open +question is **adjudication**: whether these 43 identities satisfy that existing standard, established +identity by identity rather than inferred in bulk. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1E-TIMEOUT-TOOL-LIMITATION-ADJUDICATION.md b/docs/roadmap/0.7.0/TASK-0.7.1g1E-TIMEOUT-TOOL-LIMITATION-ADJUDICATION.md new file mode 100644 index 000000000..43a7c52d6 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1E-TIMEOUT-TOOL-LIMITATION-ADJUDICATION.md @@ -0,0 +1,291 @@ +# TASK-0.7.1g1E — TIMED_OUT Tool-Limitation Adjudication + +Adjudication slice for the 43 M06 TIMED_OUT identities, plus the governance question g1D left open: +by what trusted mechanism can an adjudicated classification become base authority? + +**Two results.** (1) All 43 identities satisfy the repository's existing `tool-limitation` evidentiary +standard, identity by identity, from bytecode evidence. (2) **No executable enrollment ceremony exists** — +the classifications that already exist in the authority were created before the ratchet that now forbids +candidate-side classification additions. Nothing was enrolled, and no production code, test, build-logic, +authority file, threshold or classification was modified. + +## 1. Scope + +| Item | Value | +|---|---| +| Task branch | `task/0.7.1g1e-timeout-tool-limitation-adjudication` | +| Exact base | `607e0f05ebc660f70d2ddcfbd0553b0a449d1516` (Epic tip after #435; verified equal to `origin/epic/0.7.1-control-plane-authority` before branching) | +| Working tree at start | clean (`git status --porcelain` empty) | +| Bounded question | Do the 43 M06 TIMED_OUT identities satisfy the existing exact-identity `tool-limitation` standard, and what is the trusted mechanism by which an adjudicated classification becomes base authority? | +| Verdict | `CLASSIFICATION_ENROLLMENT_CEREMONY_MISSING` | + +## 2. Phase A — the standard, reconstructed from precedent + +`config/quality/mutation-classifications.yml` holds 23 classification records: 17 `equivalent-mutant` and 6 +`tool-limitation`. The six are the policy, so the checklist below is derived from what they actually +contain, not from what would be convenient: + +| criterion | entries carrying it | +|---|---| +| exact canonical identity (`id` = SHA-256) | 6 / 6 | +| mutation point attributed to a bytecode construct (offset / block / line) | 6 / 6 | +| compiler-generated coroutine sentinel (vs source-authored guard) | 3 / 6 | +| deterministic non-termination stated | 6 / 6 | +| **isolated** or reduced reproduction | **1 / 6** | +| an independently KILLED mutant on the same construct | 6 / 6 | +| explicit "no source-level behavioural condition" | 1 / 6 | + +Two facts from that matrix govern this slice: + +1. **Isolated reproduction is not a universal requirement.** Only + `id 1923c2a1…` mentions it ("reproduced isolated at 17s with only ModelRegistryEnforcerTest selected; + not a broad-selection artifact"). The other five carry no isolation evidence. Requiring it of the 43 + would invent a rule the repository does not have. +2. **The precedent covers both kinds of non-termination**: three entries are compiler-generated + `COROUTINE_SUSPENDED` dispatch negations, three are source-authored guards whose negation makes a loop + non-terminating. The 43 belong to the first kind, which is the better-evidenced case. + +The one criterion every entry carries is the last: proof that the classification does not conceal an +untested source-level decision. That is the criterion this slice had to settle. + +## 3. Phase B — settling the concealment criterion + +Every precedent proves concealment is absent by citing an independently KILLED mutant. That works when the +mutated source line also carries an authored conditional. Most of the 43 do not: in the attributed scope +the suspension check is typically the **only** conditional the compiler emits on that line. + +Sibling-kill is therefore one *proof technique*, not the definition. The policy objective is: + +> prove the classification does not conceal an untested source-level behavioural decision + +Two acceptable proofs are used in Phase C: + +- **(a) sibling kill** — the line carries an authored conditional whose mutant is independently KILLED. +- **(b) bytecode proof of absence** — the line's conditional-jump set contains exactly one instruction and + it is the suspension check reading `COROUTINE_SUSPENDED`. There is then no authored Boolean, null or + branch decision at that location to conceal, and the mutant's status is TIMED_OUT rather than + NO_COVERAGE, which independently proves a covering test reached the mutated instruction (PIT reports + NO_COVERAGE when no test reaches a mutation). + +## 4. Phase C — 43 / 43 adjudicated + +Verdict distribution: **43 TOOL_LIMITATION_ELIGIBLE**, 0 INSUFFICIENT_EVIDENCE, 0 +NOT_TOOL_LIMITATION. Evidence form: 39 by bytecode proof of absence, 4 by sibling kill. + +Shared evidence for every row: raw status TIMED_OUT in campaign A **and** campaign B, identical in both; +identity present in the committed authority and in both fresh populations; `numberOfTestsRun = 0`; +module `:tramai-engine`, mutator `NegateConditionalsMutator` / "negated conditional". + +| # | class#method @line | block/index | bytecode conditional(s) on the line | evidence | +|---|---|---|---|---| +| 1 | `ApprovalResumeCoordinator#resume` @65 | 9/59 | `if_acmpne`@143 **(sentinel)** | bytecode proof of absence | +| 2 | `ApprovalResumeCoordinator#resume` @66 | 14/95 | `if_acmpne`@198 **(sentinel)** | bytecode proof of absence | +| 3 | `ApprovalResumeCoordinator#resume` @68 | 26/155 | `if_acmpne`@303 **(sentinel)** | bytecode proof of absence | +| 4 | `ApprovalResumeCoordinator#resume` @81 | 42/264 | `if_acmpne`@465 **(sentinel)** | bytecode proof of absence | +| 5 | `ApprovalResumeCoordinator#resume` @85 | 54/356 | `if_acmpne`@634 **(sentinel)** | bytecode proof of absence | +| 6 | `ApprovalResumeCoordinator#resume` @90 | 67/459 | `if_acmpne`@803 **(sentinel)** | bytecode proof of absence | +| 7 | `ApprovalResumeCoordinator#resume` @98 | 85/575 | `if_acmpne`@1003 **(sentinel)** | bytecode proof of absence | +| 8 | `ApprovalResumeCoordinator#resume` @109 | 105/704 | `if_acmpne`@1216 **(sentinel)** | bytecode proof of absence | +| 9 | `ApprovalResumeCoordinator#prepareResume` @121 | 11/68 | `if_acmpne`@147 **(sentinel)** | bytecode proof of absence | +| 10 | `ApprovalResumeCoordinator#prepareResume` @126 | 24/131 | `if_acmpne`@257 **(sentinel)**, `ifnonnull`@302 (authored) | sibling kill | +| 11 | `ApprovalResumeCoordinator#prepareResume` @131 | 39/224 | `ifnull`@347 (authored), `if_acmpne`@401 **(sentinel)** | sibling kill | +| 12 | `ApprovalResumeCoordinator#prepareResume` @138 | 53/332 | `if_acmpne`@568 **(sentinel)** | bytecode proof of absence | +| 13 | `ApprovalResumeCoordinator#prepareResume` @140 | 61/423 | `if_acmpne`@738 **(sentinel)** | bytecode proof of absence | +| 14 | `ApprovalResumeCoordinator#authorizeResume` @148 | 11/67 | `if_acmpne`@144 **(sentinel)** | bytecode proof of absence | +| 15 | `ApprovalResumeCoordinator#authorizeResume` @162 | 53/271 | `if_acmpne`@495 **(sentinel)** | sibling kill | +| 16 | `ApprovalResumeCoordinator#revealAndValidateReplayPayload` @223 | 31/170 | `if_acmpne`@321 **(sentinel)** | bytecode proof of absence | +| 17 | `ApprovalResumeCoordinator$resume$2#invokeSuspend` @86 | 5/32 | `if_acmpne`@69 **(sentinel)** | sibling kill | +| 18 | `ApprovalSuspensionCoordinator#suspendToolExecution` @145 | 11/74 | `if_acmpne`@158 **(sentinel)** | bytecode proof of absence | +| 19 | `ApprovalSuspensionCoordinator#suspendToolExecution` @152 | 35/206 | `if_acmpne`@362 **(sentinel)** | bytecode proof of absence | +| 20 | `ApprovalSuspensionCoordinator#suspendToolExecution` @165 | 60/388 | `if_acmpne`@633 **(sentinel)** | bytecode proof of absence | +| 21 | `ApprovalSuspensionCoordinator#suspendToolExecution` @218 | 109/668 | `if_acmpne`@1072 **(sentinel)** | bytecode proof of absence | +| 22 | `ApprovalSuspensionCoordinator#suspendToolExecution` @219 | 132/853 | `if_acmpne`@1420 **(sentinel)** | bytecode proof of absence | +| 23 | `ApprovalSuspensionCoordinator#suspendToolExecution` @239 | 157/1067 | `if_acmpne`@1785 **(sentinel)** | bytecode proof of absence | +| 24 | `ApprovalSuspensionCoordinator#compensateSuspension` @293 | 12/90 | `if_acmpne`@187 **(sentinel)** | bytecode proof of absence | +| 25 | `ApprovalSuspensionCoordinator#compensateSuspension` @294 | 20/164 | `if_acmpne`@330 **(sentinel)** | bytecode proof of absence | +| 26 | `ApprovalSuspensionCoordinator#compensateSuspension` @295 | 30/239 | `if_acmpne`@478 **(sentinel)** | bytecode proof of absence | +| 27 | `ApprovalSuspensionCoordinator#compensateStep` @301 | 10/63 | `if_acmpne`@120 **(sentinel)** | bytecode proof of absence | +| 28 | `ApprovalSuspensionCoordinator$compensateSuspension$2$1#invokeSuspend` @293 | 5/28 | `if_acmpne`@63 **(sentinel)** | bytecode proof of absence | +| 29 | `ApprovalSuspensionCoordinator$compensateSuspension$2$2#invokeSuspend` @294 | 4/29 | `if_acmpne`@64 **(sentinel)** | bytecode proof of absence | +| 30 | `ApprovalSuspensionCoordinator$compensateSuspension$2$3#invokeSuspend` @295 | 4/29 | `if_acmpne`@63 **(sentinel)** | bytecode proof of absence | +| 31 | `DefaultApprovalGateway#requestApproval-Atj0Sqo` @82 | 9/68 | `if_acmpne`@159 **(sentinel)** | bytecode proof of absence | +| 32 | `DefaultApprovalGateway#requestApproval-Atj0Sqo` @93 | 23/160 | `if_acmpne`@304 **(sentinel)** | bytecode proof of absence | +| 33 | `DefaultApprovalGateway#requestApproval-Atj0Sqo` @105 | 35/243 | `if_acmpne`@457 **(sentinel)** | bytecode proof of absence | +| 34 | `DefaultApprovalGateway#requestApproval-Atj0Sqo` @107 | 46/328 | `if_acmpne`@618 **(sentinel)** | bytecode proof of absence | +| 35 | `DefaultApprovalGateway#requestApproval-Atj0Sqo` @112 | 60/426 | `if_acmpne`@799 **(sentinel)** | bytecode proof of absence | +| 36 | `DefaultApprovalGateway#requestApproval-Atj0Sqo` @114 | 72/515 | `if_acmpne`@968 **(sentinel)** | bytecode proof of absence | +| 37 | `DefaultApprovalGateway#requireExistingAttributionMatches` @210 | 14/79 | `if_acmpne`@151 **(sentinel)** | bytecode proof of absence | +| 38 | `DefaultApprovalGateway#persistUngoverned` @223 | 10/61 | `if_acmpne`@130 **(sentinel)** | bytecode proof of absence | +| 39 | `DefaultApprovalGateway#persistUngoverned` @225 | 17/101 | `if_acmpne`@185 **(sentinel)** | bytecode proof of absence | +| 40 | `DefaultApprovalGateway#persistUngoverned` @230 | 25/142 | `if_acmpne`@243 **(sentinel)** | bytecode proof of absence | +| 41 | `DefaultApprovalGateway#persistGoverned` @248 | 12/74 | `if_acmpne`@145 **(sentinel)** | bytecode proof of absence | +| 42 | `DefaultApprovalGateway#persistGoverned` @253 | 22/132 | `if_acmpne`@228 **(sentinel)** | bytecode proof of absence | +| 43 | `DefaultApprovalGateway#persistGoverned` @261 | 31/181 | `if_acmpne`@306 **(sentinel)** | bytecode proof of absence | + +The four sibling-kill rows are the two-conditional lines: + +- `ApprovalResumeCoordinator#authorizeResume@162` — the authored conditional on this line is + independently KILLED by `[method:claim failure propagates before executor or completion()]`, so no source branch is concealed. +- `ApprovalResumeCoordinator#prepareResume@126` — the authored conditional on this line is + independently KILLED by `[method:missing suspended metadata yields ApprovalNotFoundException()]`, so no source branch is concealed. +- `ApprovalResumeCoordinator#prepareResume@131` — the authored conditional on this line is + independently KILLED by `[method:nested approval cancels state and never claims or executes()]`, so no source branch is concealed. +- `ApprovalResumeCoordinator$resume$2#invokeSuspend@86` — the authored conditional on this line is + independently KILLED by `[method:a standalone resume recovers and installs the persisted identity()]`, so no source branch is concealed. + +Supporting context — how much of each class is actually killed (so no class is being blanket-exempted): + +| class | mutants | KILLED | TIMED_OUT | NO_COVERAGE | SURVIVED | +|---|---|---|---|---|---| +| `ApprovalResumeCoordinator` | 150 | 36 | 28 | 63 | 23 | +| `ApprovalResumeCoordinator$resume$2` | 5 | 1 | 1 | 2 | 1 | +| `ApprovalSuspensionCoordinator` | 87 | 37 | 10 | 26 | 14 | +| `ApprovalSuspensionCoordinator$compensateSuspension$2$1` | 5 | 0 | 1 | 2 | 2 | +| `ApprovalSuspensionCoordinator$compensateSuspension$2$2` | 5 | 0 | 1 | 2 | 2 | +| `ApprovalSuspensionCoordinator$compensateSuspension$2$3` | 5 | 0 | 1 | 2 | 2 | +| `DefaultApprovalGateway` | 79 | 32 | 13 | 26 | 8 | + +Full per-identity records (identity, descriptor, block/index, jumps, other mutants, checks, verdict) +were produced by `/tmp/tramai-071g1e-adjudicate.py` and are not duplicated here; the table above is +identity-addressed by class#method@line plus block/index, which together with the mutator and description +reproduce the canonical identity exactly. + +## 5. A parser correction, and re-verification of g1D + +While attributing the four synthetic suspend-lambda classes, the bytecode parser used in g1C/g1D was found +to miss `aload_N`-encoded local reads (the operand is in the opcode name, not an argument), so sentinel +comparisons in classes compiled that way were not attributed. The detector was corrected to accept both +`aload N` and `aload_N` forms. + +g1D's partition was then **re-run unchanged** to check that this did not alter it: + +``` +sentinel-bearing lines 54 | sentinel comparisons 54 | TIMED_OUT 51 | NO_COVERAGE 3 | KILLED 3 | ordinary conditionals 3 +partition still exact: True +``` + +g1D's conclusion is unaffected: the four synthetic identities were already handled separately there, and +the corrected detector merely adds their instruction-level attribution here (single `if_acmpne` per class, +mapped to lines 86 / 293 / 294 / 295). + +## 6. Phase D — the enrollment boundary: no executable ceremony exists + +The verifier states the intended policy in `MutationRatchetVerifier.kt:53-59`: + +> Classification authority semantics: a PR may only REMOVE classifications (when the underlying mutant +> dies), never add or re-author one. New classifications are adjudicated on master during an enrollment +> ceremony and become part of the base; anything a candidate adds is either self-approval of its own +> survivor (M08) or fabrication (M09). + +The question is whether that ceremony exists as tooling. It does not. + +| question | finding | +|---|---| +| 1. Existing task/workflow/ceremony for base-side adjudication? | **None.** No Gradle task, workflow or script references classification enrollment. Searching the repository for `enroll` returns only unrelated enrollments (provider TCK, maintainability scanner suites, module catalog). | +| 2. Executable and tested? | **No** for classifications. The one executable mainline authority mechanism is mutation-population evolution, which is M21-only (proven in g1B) and cannot carry a classification. | +| 3. Does it preserve M08 for ordinary PRs? | M08/M09 are unconditional in the candidate direction. `AUTHORITY_EXCLUDED_IDENTITIES` (`:530`) is passed only into the M01 branch (`:88` vs its only use at `:278`), so it bypasses neither M06 nor M08/M09/M14/M15. | +| 4. Does it require direct Epic/master authority mutation? | A direct mainline commit does not help: on push, CI verifies against `github.event.before` (`.github/workflows/ci.yml:223-242`), so the same M06/M09 rules apply to the commit that would do the enrolling. | +| 5. A change class/label that changes verifier semantics? | Two exist and neither helps: the `mutation-population-evolution` label selects `-PtramaiMutationEvolution=recorded-evolution` (M21 only), and `-PchangeClass=baseline-migration` affects `verifyChangePolicy`, not verifier semantics. | +| 6. Is "adjudicated on master" tooling or governance prose? | **Prose.** The six existing `tool-limitation` records were added in `d5b19e35` (2026-09-03); `MutationRatchetVerifier.kt` first appears in `0c02ff64` (2026-09-05). The precedent predates the rule that now forbids creating it. | + +So the state is: a **correct policy with no way to exercise it**. M06 is doing its job — it prevents a +candidate from deciding for itself that a new NON_KILLED result is acceptable — but the complementary +operation it presupposes (a trusted transition from unresolved NON_KILLED to base-adjudicated NON_KILLED) +has no implementation. + +### 6.1 What the missing ceremony must satisfy + +Any future ceremony has to demonstrate two discriminators, not one: + +1. an identity adjudicated and enrolled on the base side is accepted when a later candidate retains it + byte-identically (the M03 path); +2. a **fresh, unrelated** candidate classification still fails M08 — approved-from-base must stay allowed + while candidate-self-approval stays rejected. + +Without (2), fixing the gap would simply reopen the hole M08 closes. + +## 7. What was deliberately not done + +- No classification records were written, in either file or draft form. +- No enrollment was attempted through `mutation-classifications.yml`, `mutation-baseline.json`, + `mutation-evolution.yml`, a label, a change class, or `AUTHORITY_EXCLUDED_IDENTITIES`. +- M06, M08, M09, the raw-status mapping, canonical outcomes, the mutator set, timeouts and target families + are untouched. `baseline-migration` was not used: it is reserved for analyzer schema/identity/ + cardinality changes and this is none of those. +- No test was changed and no mutant was killed, removed or exempted. +- No fresh PIT campaign was run; the adjudication uses the raw reports and both populations already + custody-proved in g1C/g1D. + +## 8. Remaining M06 debt + +| Component | Count | Status after this slice | +|---|---|---| +| TIMED_OUT | 43 | **adjudicated eligible for `tool-limitation`; NOT enrolled — no mechanism exists** | +| SURVIVED | 36 | untouched | +| NO_COVERAGE | 79 | untouched | +| **total** | **158** | **unchanged** | + +Adjudicated is not enrolled, and enrolled would not be killed. The identities remain NON_KILLED and remain +M06 failures until a trusted base-side enrollment exists. + +## 9. Non-claims and limitations + +- The 43 are eligible **by the current precedent**, which is a governance artifact that may be tightened; + this slice did not vote on the standard, it applied it. +- Eligibility is not approval. Nothing here asserts that these classifications *should* be enrolled, only + that they meet the evidence standard the repository already applies to the same mechanism elsewhere. +- Instruction attribution for the two two-conditional lines (126, 131) is by status/behaviour partition, + not by a `block`/`index` → bytecode-offset mapping, which remains unestablished. Those rows carry the + strongest concealment evidence (a named killing test) and the weakest attribution evidence. +- The 4 synthetic-class rows rest on a single `if_acmpne` per class plus the class containing exactly one + `COROUTINE_SUSPENDED` reference; their `invokeSuspend` line tables are small (5 entries). +- Coverage for the bytecode-proof rows is inferred from TIMED_OUT ≠ NO_COVERAGE, not from a named covering + test list (PIT records `killingTest` only for killed mutants). +- Eligibility was assessed only for the 43; the 3 NO_COVERAGE sentinel comparisons found in g1D and the 36 + SURVIVED / 79 NO_COVERAGE identities were not adjudicated. + +## 10. Commands run + +``` +git fetch origin && git rev-parse origin/epic/0.7.1-control-plane-authority # == 607e0f05... +git status --porcelain # empty +git checkout -B task/0.7.1g1e-timeout-tool-limitation-adjudication 607e0f05ebc660f70d2ddcfbd0553b0a449d1516 +python3 /tmp/tramai-071g1e-adjudicate.py # per-identity bytecode attribution + verdicts +python3 /tmp/tramai-071g1d-linejoin.py # g1D partition re-verified with the corrected detector +# javap -p -c -l -classpath tramai-engine/build/classes/kotlin/main <7 classes> +``` + +## 11. Working-tree proof + +### 11.1 Before the task commit + +``` +$ git rev-parse HEAD +607e0f05ebc660f70d2ddcfbd0553b0a449d1516 # the base, unmoved +$ git status --porcelain # empty at task start +``` + +### 11.2 After the task commit + +``` +$ git rev-parse HEAD^ +607e0f05ebc660f70d2ddcfbd0553b0a449d1516 # the task commit's parent is the base +$ git diff --name-only HEAD^ HEAD +docs/roadmap/0.7.0/TASK-0.7.1g1E-TIMEOUT-TOOL-LIMITATION-ADJUDICATION.md +$ git diff --name-only 607e0f05 HEAD | wc -l +1 # exactly one file changed relative to base +$ git status --porcelain # empty +``` + +## 12. Outcome + +``` +CLASSIFICATION_ENROLLMENT_CEREMONY_MISSING +43 / 43 TIMED_OUT IDENTITIES ADJUDICATED ELIGIBLE — NOTHING ENROLLED +``` + +The 43 are evidentially ready for `tool-limitation` under the repository's own existing standard, and the +enrollment boundary has no implementation: the six classifications that exist were created two days before +the ratchet that forbids creating them. The next slice should therefore build the ceremony — with the two +discriminators in §6.1 — rather than touching the authority. Until then the debt stays at 158 and M06 +keeps doing exactly what it was built to do. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1F-CLASSIFICATION-ENROLLMENT-CEREMONY.md b/docs/roadmap/0.7.0/TASK-0.7.1g1F-CLASSIFICATION-ENROLLMENT-CEREMONY.md new file mode 100644 index 000000000..e06ec0674 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1F-CLASSIFICATION-ENROLLMENT-CEREMONY.md @@ -0,0 +1,289 @@ +# TASK-0.7.1g1F — Base Classification Enrollment Ceremony + +Implementation of the missing authority operation g1E identified: the mutation ratchet rejects +candidate-side classification additions (M08/M09), while its documented complement — new +classifications "adjudicated on master during an enrollment ceremony and becoming part of the base" — +had **no implementation at all**. + +This slice builds that ceremony as an authority primitive. It enrolls nothing. + +## 1. Scope and custody + +| Item | Value | +|---|---| +| Task branch | `task/0.7.1g1f-classification-enrollment-ceremony` | +| Exact base | `8c30d7db6ebd4b3476115bdea9db1b9ecdbca662` (Epic tip after #436; 40 characters, verified equal to `origin/epic/0.7.1-control-plane-authority` before branching) | +| Working tree at start | clean (`git status --porcelain` empty) | +| Primary change class | `build-logic` (verifier/authority mechanism) | +| Verdict | ceremony implemented and proven; **no classification enrolled** | + +## 2. The problem this closes + +`MutationRatchetVerifier` states the policy in its header: a PR may only *remove* classifications, never +add or re-author one; new ones are adjudicated on master during an enrollment ceremony. g1E proved the +ceremony did not exist as tooling: no Gradle task, workflow or script implements it, `AUTHORITY_EXCLUDED_IDENTITIES` +bypasses only M01, mainline pushes are verified against `github.event.before` (so a direct authority +commit faces the same rules), and the six existing `tool-limitation` records were created in `d5b19e35` +(2026-09-03) two days *before* `MutationRatchetVerifier` appeared in `0c02ff64` (2026-09-05). + +The ratchet was therefore correct and unusable: an adjudicated survivor could not be enrolled by any +mechanism, at any trust level. + +## 3. The trust model: temporal and base-derived, never mode-derived + +An enrollment is an **authorization that a later transition consumes**, in two phases: + +``` +PHASE 1 — authorize PHASE 2 — consume (a LATER transition) + base: no authorization base: exact authorization X + candidate: + authorization X candidate: + exact classification X + (no classification) - authorization X (consumed) + -> PASS -> PASS (M22 enrollment, M26 consumption) +``` + +The security property is exactly this asymmetry: + +``` +candidate authorization X + candidate classification X (base has neither) -> FAIL (M23) +base authorization X + candidate classification X (identical payload) -> PASS (M22) +``` + +A candidate can never create the authority it uses, because the authorization that permits enrollment +must already exist in the **PR base**. No privileged mode, label, actor, branch name, commit token, +environment, manual workflow bypass or hard-coded identity is involved: the ceremony adds no branch to the +verifier that the ratchet would not already trust. + +The authorization binds the **complete classification record** — `id`, `classification`, `reason`, +`issue`, `targetPhase` — not the identity. Data-class equality over the whole payload makes "exact +authorization" literal, so a later transition cannot substitute a different classification type or rewrite +the approved rationale (M24). + +## 4. Design + +| Artifact | Role | +|---|---| +| `config/quality/mutation-classification-enrollments.yml` | The authorization ledger (schemaVersion `1`, `enrollments:` list). **Not created by this slice** — an absent file means "no authorizations", the most restrictive state. | +| `MutationClassificationEnrollments.kt` | `MutationClassificationEnrollment(classification)` and the ledger type. The payload *is* a `MutationClassification`, so no second semantic type and no payload drift. | +| `MutationClassificationEnrollmentLoader.kt` | Ledger parser. Absent → `NONE` (fail-closed). Present but malformed → hard `GradleException`. Duplicate ids → hard failure. | +| `MutationClassificationLoader.parseClassificationEntry` | Per-entry parsing/validation **shared** with the classifications loader, so the allowed vocabulary and field rules have one authority (`missing-test`, `equivalent-mutant`, `low-risk-implementation-detail`, `tool-limitation`, `known-design-ambiguity` — generic, nothing timeout-specific). | +| `MutationRatchetAuthority.enrollments` / `MutationRatchetCandidate.enrollments` | Base-side and candidate-side ledgers. Defaults to `NONE`, so every pre-existing construction keeps its exact meaning. | +| `MutationEnrollmentCeremony.kt` | M22-M27 plus the unchanged M08/M09 backstop. Split out of the verifier because keeping every rule inline exceeded the maintainability function-count limit; the verifier now delegates exactly where it used to dispatch added classifications. | +| `MutationRatchetAuthorityLoader` | Loads the base ledger from the **base SHA** (same temp-tree mechanism as every other authority file); a base predating the ledger simply has none. | + +## 5. Discriminator matrix + +Each row is a test in `MutationRatchetEnrollmentCeremonyTest` (31 tests, all passing). + +| # | Scenario | Expected | Rule | +|---|---|---|---| +| 1 | base authorization X → exact classification X | PASS | M22 | +| 2 | base authorization X → classification X, authorization removed | PASS | M22 + M26 | +| 3 | candidate adds authorization X **and** classification X (base has neither) | FAIL | M23 (+ unchanged M08/M09 backstop) | +| 4 | no base authorization → classification added | FAIL | M08/M09 **unchanged** | +| 5 | classification type differs from authorization | FAIL | M24 | +| 6 | reason differs | FAIL | M24 | +| 7 | issue differs (and removed) | FAIL | M24 | +| 8 | targetPhase differs (and removed) | FAIL | M24 | +| 9 | authorization targets an identity absent from the base population | FAIL | M25 | +| 10 | authorization targets an identity absent from the base population **and** the candidate introduces it | FAIL | M25 **and M06** | +| 11 | authorization targets a KILLED identity | FAIL | M25 | +| 12 | authorization targets an already-classified identity | FAIL | M25 | +| 13 | duplicate authorization ids (in-memory ledger) | FAIL | M25 | +| 14 | duplicate authorization ids (ledger file) | FAIL | loader | +| 15 | unknown classification name in the ledger | FAIL | loader (shared vocabulary) | +| 16 | authorization removed without enrollment | FAIL | M27 | +| 17 | authorization retained after its classification was enrolled | FAIL | M27 (single-use) | +| 18 | enrolled classification retained byte-identically | PASS | M03 | +| 19 | enrolled classification rewritten later | FAIL | M03 | +| 20 | after enrollment, an unrelated fresh candidate classification | FAIL | M08 | +| 21 | BASE0 → BASE1 → BASE2 → CANDIDATE3 (below) | see below | full ceremony | +| 22 | retained authorization rewritten while unconsumed: classification / reason / issue / targetPhase | FAIL | M28 | +| 23 | retained authorization carried byte-identically (still pending) | PASS | pending | +| 24 | retained authorization whose target became KILLED | FAIL | M27 stale | +| 25 | retained authorization whose target disappeared | FAIL | M27 stale | +| 26 | authorization removed once its target became KILLED | PASS | cleanup, M04 governs | +| 27 | authorization removed once its target disappeared | PASS | cleanup, M21 governs | +| 28 | enrollment for a mutant this transition killed | FAIL | M29 | +| 29 | enrollment for a mutant this transition removed | FAIL | M29 | +| 30 | proposed authorization for a mutant already KILLED in the candidate | FAIL | M25 (candidate side) | +| 31 | every passing transition is usable as the next base | PASS | invariant (see §5.2) | + +### 5.1 The mandatory transition proof + +One test walks the complete sequence: + +``` +BASE0 X is NON_KILLED, no authorization, no classification +BASE1 <- + authorization X for an existing base survivor, nothing enrolled PASS +BASE2 <- + exact classification X, authorization X consumed PASS +CAND3 <- X retained byte-identically (M03) PASS for X + unrelated NON_KILLED Y gets a candidate-side classification FAIL for Y (M08) +``` + +The final assertion is the one that matters: the failure is attributed to **Y only** — `findingId == +identityOf("y")` — and no diagnostic carries X's identity. Base-approved means allowed; candidate +self-approval still means rejected, in the same verifier run. + +### 5.2 The invariant the review blockers violated + +Every transition that passes must be usable as the next transition's base. The last test above +constructs each passing ceremony state — authorized-and-pending, consumed, and cleaned-up-after-death — +and re-verifies it as its own base/candidate pair, which is exactly the shape CI runs. A transition that +could poison the next base now fails here as well as in production. + +## 6. M06 / M08 / M09 are untouched + +- **M08/M09**: the unauthorized-addition path is byte-identical to the pre-ceremony implementation + (`MutationEnrollmentCeremony.unauthorizedAddition`, messages preserved verbatim). Rows 4 and 20 above + are the negative discriminators. +- **M06**: the new-survivor rule is unchanged (`outcomeRatchet`, `candidateIds - baseIds`, NON_KILLED → + fail). Row 10 proves an authorization cannot smuggle a new NON_KILLED identity past it. +- **M01-M05, M07, M10-M21**: no code path or constant was altered. All 13 pre-existing `Mutation*Test` + classes pass unchanged (169 tests including the new suite; 148 before it). +- No mutation population, baseline, raw-status mapping, canonical outcome, mutator, timeout, target family + or evolution record was touched. `AUTHORITY_EXCLUDED_IDENTITIES` was not used. + +## 7. Finding: the ceremony cannot yet enroll the g1E identities (and must not pretend to) + +The 43 TIMED_OUT identities adjudicated `TOOL_LIMITATION_ELIGIBLE` in g1E are **not** eligible for +authorization today, and the gate says so rather than the reviewer having to notice: + +| Evidence | Value | +|---|---| +| The 43 identities present in the committed base population (`config/quality/mutation-baseline.json`, 2384 rows) | **0 of 43** | +| Committed base NON_KILLED rows | 789, of which **766 are unclassified** | +| Rule that fires | **M25** — an authorization may only ratify an unresolved NON_KILLED survivor the base already carries | +| The complementary rule that blocks the alternative ordering | **M06** — a new NON_KILLED identity cannot enter the authority as a candidate, so the 43 cannot be admitted to the base population first | + +So `g1G1 (authorize the 43)` **cannot run as planned**: its precondition is unmet, and the operation that +would satisfy it is exactly the population transition that g1B proved blocked by M06. The ceremony is +necessary but not sufficient; the 43 need the population-transition question resolved first. + +What the ceremony *does* have today is a real, large customer base: **766** committed base NON_KILLED +identities are unresolved in the authority and could legitimately be adjudicated → authorized → enrolled. + +## 8. Files changed + +| File | Change | +|---|---| +| `build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollments.kt` | new — ledger types | +| `build-logic/src/main/kotlin/dev/tramai/build/quality/MutationClassificationEnrollmentLoader.kt` | new — ledger parser (fail-closed) | +| `build-logic/src/main/kotlin/dev/tramai/build/quality/MutationEnrollmentCeremony.kt` | new — M22-M29 + M08/M09 backstop | +| `build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentTestSupport.kt` | new — shared fixtures (abstract, no test methods) | +| `build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentCeremonyTest.kt` | new — 15 discriminators for M22-M26 + the transition proof | +| `build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLifecycleTest.kt` | new — 12 discriminators for M27/M28/M29 + the next-base invariant | +| `build-logic/src/test/kotlin/dev/tramai/build/quality/MutationRatchetEnrollmentLedgerTest.kt` | new — 4 ledger-parsing discriminators | +| `MutationClassificationLoader.kt` | shared per-entry validation extracted (`requiredField`, `optionalField`, `requireAllowedClassification`); messages unchanged | +| `MutationRatchetAuthority.kt` | base + candidate ledgers; base ledger loaded from the base SHA | +| `MutationRatchetVerifier.kt` | delegates added-classification dispatch and the ceremony; header documents M22-M27; companion `private` → `internal` so the ceremony object can share its vocabulary | +| `VerificationDiagnostic.kt` | four new codes (M23/M24/M25/M27) | +| `VerifyArchitectureTask.kt` | exhaustive `DiagnosticCode` classification updated (compile-time decision preserved) | +| `MaintainabilityBaselinePlugin.kt` | both `verify` call sites wire the ledger | +| `docs/roadmap/0.7.0/TASK-0.7.1g1F-CLASSIFICATION-ENROLLMENT-CEREMONY.md` | this document | + +## 9. The maintainability pin correction (moved out of this change) + +Adding a `Mutation*Test` suite changes the `policy-maintainability` lane's exact count assertion, and +measuring it exposed **pre-existing drift**: + +``` +305 committed pin (what this branch inherited) +333 measured at this base through the lane's own 13 filters (0 failures) +364 measured with this change's 31 ceremony tests (0 failures) +``` + +The two measurements agree independently (333 + 31 = 364), and the lane's own filter list was extracted +from the workflow rather than approximated. + +This workflow triggers **only for pull requests targeting `master`**, so epic-targeted PRs never exercised +the pin and it had been stale for the whole epic line. Review classified the correction as scope hygiene +rather than an authority defect, so this change no longer touches `.github/workflows/**`: the pin moves to +its own `ci-workflow` pull request, re-measured against the merged state. The other lanes' pins were **not** +re-measured and may carry the same drift, which that follow-up records too. + +## 10. Verification + +| Command | Result | +|---|---| +| `./gradlew :build-logic:test --tests '...MutationRatchetEnrollmentCeremonyTest'` | 31 tests, 0 failures | +| `./gradlew :build-logic:test --tests '...Mutation*Test'` | 179 tests, 0 failures (all pre-existing suites unchanged) | +| `policy-maintainability` lane filters (13, extracted from the workflow) | 364 tests, 0 failures (333 without this change's 31) | +| clean worktree at base `8c30d7db` — `CancellationWiringTest`, `TramaiDocsGuardsPluginTest` | both fail at base: pre-existing, attributed by rerun (§10.1) | +| `./gradlew :build-logic:test` (full module) | see §12 | +| `./gradlew spotlessCheck verifyStaticAnalysis` | PASSED — `Detekt baseline OK: base 4792 -> current 4792; 0 removed, 0 added`; new/unbaselined findings **0** | +| `./gradlew verifyMutationRatchet -PtramaiMutationBaseSha=8c30d7db…` | see §12 | +| `./gradlew verifyChangePolicy -PchangeClass=build-logic -PchangePolicyBase=8c30d7db…` | PASSED, 12 changed files, no violations | +| `./gradlew verifyPr -PchangePolicyBase=8c30d7db…` | see §12 | + +No `@Suppress` was added anywhere and the detekt baseline was not edited: the seven findings the gate +initially reported (LongMethod ×2, MaxLineLength ×5, TooGenericExceptionCaught, ThrowsCount ×2, +TooManyFunctions, LongParameterList, UnusedPrivateProperty) were fixed by restructuring the code, which is +the repository's stated rule — config owns exceptions, the baseline is a ceiling (not an allowance budget). + +### 10.1 Pre-existing failures in the full module run (attributed, not mine) + +`:build-logic:test` (the whole module, not the lane above) reports failures in two families that predate +this change and are unreachable from it: + +| Family | Evidence | +|---|---| +| `CancellationWiringTest > C1 cancellation authority remains exact base task()` | Fails identically in a clean worktree at base `8c30d7db` with no ceremony code present (3 tests, 1 failed). Needs its base-SHA property; not in the `policy-maintainability` lane. | +| `dev.tramai.build.docs.TramaiDocsGuardsPluginTest` (version-surface guards) | Fails in a clean worktree at base `8c30d7db` too (19 tests, 18 failed); in a full checkout the known deferred `verifyVersionAlignment` cases (4) are what remain. Belongs to the release lane, not `policy-maintainability`. | + +Neither class is in the lane whose pin §9 measures, and none of the files this change touches is read by +them. Both were reproduced at the base commit, so the attribution is by rerun, not by inspection. + +## 11. Non-claims and open decisions + +- **No classification was enrolled, and no g1E identity was authorized.** The ledger file does not exist in + this slice. §7 explains why the 43 cannot be authorized yet. +- The ceremony does not decide *what* may be adjudicated; it decides *how* an adjudicated decision becomes + base authority. Adjudication standards remain where g1E found them (repository precedent). +- Whether an authorization should be cancellable before consumption is deliberately **not** implemented: + M27 fails closed on removal without enrollment rather than inventing a silent cancellation path. +- The other maintainability-lane pins (§9) may be stale; only `policy-maintainability` was measured. +- The separation of the workflow pin from the build-logic change is a judgement call disclosed here: + `verifyChangePolicy` accepts the combination, and AGENTS.md rule 5 concerns production source. If review + prefers strict separation, the workflow line can move to its own `ci-workflow` PR without touching code. + + +## 13. Review round 1 corrections + +Review of `d3ea942c` (CHANGES REQUESTED) found two authority holes the 21 tests did not cover. Both were +real, both were in the transition state machine, and both are fixed here rather than by weakening a rule. + +**Blocker 1 — a retained authorization could be rewritten before consumption.** `consumption()` compared +the base and candidate ledgers only by identity, so an unconsumed authorization could be silently +re-authored (`tool-limitation`/`A` → `equivalent-mutant`/`B`) in an intermediate transition, and the next +PR could legitimately consume the rewritten approval. Fixed by **M28**: a retained authorization must be +byte-identical to the record the base authorized. It must be consumed exactly, or left unchanged. + +**Blocker 2 — the lifecycle could create invalid base authority.** Two manifestations: + +- *2A*: a transition could enroll a classification for a mutant it had just reported KILLED (or removed), + because target validity was only ever checked against the **base** population. The resulting candidate + authority is one its own next verification rejects (`baseClassificationIntegrity` requires NON_KILLED). + Fixed by **M29**: every active candidate classification — new or retained — must point at a candidate + mutant that is present and NON_KILLED. +- *2B*: when a target died before consumption, the authorization was **undeletable** — retaining it left + an M25-invalid base record, removing it tripped M27. Fixed by terminal semantics: a dead or disappeared + target makes removal legal (cleanup, like M04), while retaining or enrolling it fails. + +M25's candidate-side half also now applies to *proposed* authorizations, so a transition cannot propose an +authorization for a mutant it is killing in the same commit. + +The reviewer's full matrix (12 cases) is covered by tests 22-31 above, including the strong invariant test. +One bug was caught by these tests during implementation: M28 initially reported +`MUTATION_RATCHET_ENROLLMENT_ORPHANED` instead of `MUTATION_RATCHET_ENROLLMENT_MISMATCH`; the rule was +corrected, not the test. + +**Scope hygiene:** the `.github/workflows/maintainability-baseline.yml` pin correction was removed from +this change and is being landed separately as a `ci-workflow` change (see §9). + +## 12. Outcome + +Filled in with the final gate results and head custody at push time (see the PR body for the same set). + +``` +CEREMONY IMPLEMENTED — M22-M27 PROVEN, M06/M08/M09 UNCHANGED, NOTHING ENROLLED +``` diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G0-POPULATION-ADMISSION-PREFLIGHT.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G0-POPULATION-ADMISSION-PREFLIGHT.md new file mode 100644 index 000000000..b5501fb20 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G0-POPULATION-ADMISSION-PREFLIGHT.md @@ -0,0 +1,612 @@ +# TASK-0.7.1g1G0 — Candidate-Only Population Admission Semantics Preflight + +Evidence-only preflight. No population enrollment, no authority file modified, M06 not weakened, no build +logic changed. + +Bounded question: **can TramAI safely support an explicit base-authoritative admission ceremony for +candidate-only NON_KILLED mutation identities without weakening M06, and what exact transition would move +the g1E identities into the committed population?** + +## 1. Scope and custody + +| Item | Value | +|---|---| +| Task branch | `task/0.7.1g1g0-population-admission-preflight` | +| Exact base | `0693662969f7459e7106d3d708b300139820b43a` (== `origin/epic/0.7.1-control-plane-authority` at task start; 40 characters verified) | +| Worktree at start | clean (`git status --porcelain` empty) | +| Deliverable | this document only | +| Change class | `documentation` | + +No `config/quality/**` file, no build logic, no workflow, no test and no production source is modified. + +## 2. Phase A — the population transition, refreshed at the current head + +### 2.1 What the previous evidence was, and why it may not be current + +g1A/g1B established, measured at `0f36ab8744c14ebbdb3b10964311d0416e012ab2` (2026-09-24): + +``` +committed 2384 fresh 2544 shared 2195 base-only 189 candidate-only 349 +candidate-only KILLED 154 candidate-only NON_KILLED 195 (TIMED_OUT 46 / NO_COVERAGE 109 / SURVIVED 40) +``` + +Those counts are treated as historical. The committed population itself last changed on 2026-09-04 +(`488f28e8`, `measuredCommit 5856530e…`); the drift became *visible* on 2026-09-24 with #429. + +### 2.2 Measurement campaigns + +| Campaign | Head | Command | Result | +|---|---|---|---| +| g1A-A | `0f36ab87` | `./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks` (detached worktree) | 2544 rows, BUILD SUCCESSFUL | +| g1A-B | `0f36ab87` | same, independent run | 2544 rows, BUILD SUCCESSFUL | +| **g1G0-A** | `06936629…` | same canonical command, detached worktree `/tmp/tramai-071g1g0-a` | **2544 rows**, BUILD SUCCESSFUL, 50m14s, artifact sha256 `5091cf86e42288e1e4c16fd15a2ff065850b83e4693dd009ddec4bc52071407e` | + +Independent-repeatability evidence from the g1A pair (identical canonical settings): + +| Comparison | Result | +|---|---| +| identity sets identical | **yes** | +| rows differing in status/outcome/family/module | **0** | +| analyzer block identical | **yes** | +| canonical projection hash | identical (`d7b6af9ca4d18e65b19a537d89490226…`) | + +### 2.3 Analyzer semantics equality + +| Fact | At head | Committed authority | +|---|---|---| +| PIT Gradle plugin / engine | `1.19.0` / `1.22.1` | identical | +| mutators | 11 (CONDITIONALS_BOUNDARY, INCREMENTS, INVERT_NEGS, MATH, NEGATE_CONDITIONALS, TRUE_RETURNS, FALSE_RETURNS, PRIMITIVE_RETURNS, EMPTY_RETURNS, NULL_RETURNS, VOID_METHOD_CALLS) | identical | +| `timeoutConst` / `timeoutFactor` | `4000` / `1.25` | identical | +| identity schema | `2` — SHA-256 over `module␟className␟method␟methodDescription␟mutator␟description␟block␟index` | identical | +| family/module scope | 7 families over `:tramai-engine`, `:tramai-security`, `:tramai-sovereign`, `:tramai-core`, `:tramai-structured` | unchanged | + +Config hashes at head: `test-quality.yml` `dff61c7775472ca9ed6e460bce28d740…`, +`mutation-baseline.json` `07f595225666e0b0cbd8914db9881d9b…`, +`mutation-classifications.yml` `2440fd5be443b1073b98d9461b21a646…`, +`mutation-evolution.yml` `23808e191d627b13c27c4adc87cf3fd2…`. + +### 2.4 Custody of the range since the old measurement + +`git diff --name-only 0f36ab87..06936629` contains **no** mutation-target source +(`tramai-{engine,security,sovereign,core,structured}/src/**`), **no** analyzer-semantics file +(`MutationProbeInitScript`, `MutationIdentity`, `MutationPopulationAggregator`, `MutationReportParser`) and +**no** quality configuration. Exactly one non-target test changed +(`tramai-orchestration/src/test/.../WorkflowMcpStepTest.kt`); `:tramai-orchestration` is not a mutation +family module and appears in no `targetTests`, so it cannot move a measurement. + +### 2.5 Refreshed counts and set hashes + +Three independent measurements now exist, taken at **two different heads**. Comparison is row-for-row over the +complete persisted row (status, outcome, family, module, class, method, method description, mutator, +description, block, index): + +| Comparison | Result | +|---|---| +| g1G0-A (`06936629…`) vs g1A-A (`0f36ab87`) | identity sets identical; **0 rows differing** | +| g1G0-A vs g1A-B | identity sets identical; **0 rows differing** | +| `analyzer` block, g1G0-A vs g1A-A | byte-equal | +| g1A-A vs g1A-B | identical (0 differing) | + +⇒ **the population is stable.** Three campaigns, two heads, zero differences — including one campaign taken +after the head moved by 10 merges. The `POPULATION_NOT_STABLE_ENOUGH_FOR_ADMISSION_DESIGN` stop condition is +**not** triggered, and a fourth campaign would measure the same rows. (Decision recorded: a fresh campaign B +was **not** run for this reason — three-way agreement with one measurement at a *different* head is stronger +evidence of stability than a repeat at the same head.) + +The measurement's own totals cross-check against PIT's campaign summary exactly: + +| Raw status | Population | PIT summary | +|---|---|---| +| KILLED | 1667 | 1667 | +| NO_COVERAGE | 330 | 330 | +| SURVIVED | 463 | 463 | +| TIMED_OUT | 84 | 84 | +| total | 2544 | 2544 | + +Family counts likewise: approval 918, evidence 808, policy 209, structuredOutput 382, retry 155, routing 15, +tools 57. `measuredCommit` recorded in the artifact = `0693662969f7459e7106d3d708b300139820b43a`. + +**The current transition** (g1G0-A vs the committed population): + +| Quantity | Value | +|---|---| +| committed population | 2384 | +| fresh population | 2544 | +| shared | 2195 | +| base-only (disappeared) | 189 | +| candidate-only | 349 | +| candidate-only KILLED | 154 | +| candidate-only NON_KILLED | 195 | +| **shared with a changed status/outcome** | **2** — see §4.2 | + +Candidate-only NON_KILLED raw-status distribution: `SURVIVED` 40, `TIMED_OUT` 46, `NO_COVERAGE` 109. + +These equal the g1B figures exactly, and the g1E 43 are 43/43 present with identical rows. ⇒ +**`CURRENT_POPULATION_CHANGED — REPARTITION_REQUIRED` is not triggered**: the g1B/g1C/g1E partition remains +current, now *re-measured* rather than inherited. + +### 2.6 Integrity of the refreshed population + +| Check | Result | +|---|---| +| identity self-consistency (recomputed SHA-256 over the `␟`-joined schema-v2 fields) | 0 inconsistencies / 2544 | +| duplicate identities | 0 | +| `outcome`/`status` disagreement (`status == KILLED` ⟺ `outcome == KILLED`) | 0 | +| rows with `status == KILLED` and `outcome != KILLED` | 0 | +| all 43 g1E identities present | 43/43 | +| rows differing from the g1A measurement | 0 | + +## 3. Phase B — the 43 g1E identities in the fresh population + +Measured fresh in this slice against `06936629…`; nothing here is inferred from the g1C/g1E artifacts. + +| Check | Result | +|---|---| +| present in the current fresh measurement | **43/43** | +| present in the committed population | **0/43** | +| persisted row identical to the g1A measurement (all fields) | 43/43 | +| persisted row identical to the g1E adjudication artifact | 43/43 | +| raw status | `TIMED_OUT` 43/43 | +| canonical outcome | `NON_KILLED` 43/43 | +| family / module / mutator | `approval` / `:tramai-engine` / `NegateConditionalsMutator` 43/43 | + +Conclusion unchanged at the current head: **0 of the 43 are base-population members**, so the g1F ceremony's +M25 refuses to authorize them, and M06 blocks admitting them first. g1G1 ("authorize the 43") remains +unexecutable — it is not the next slice, whatever happens to the ceremony. + +Precision note on the cohort boundary: the candidate-only `TIMED_OUT` bucket is **46**, and the g1E 43 are a +strict subset of it. The 3 additional identities are `ApprovalResumeCoordinator.executeClaimedResume` with +`NegateConditionalsMutator` — the same class/method/mutator shape as the adjudicated 43 (the "category A" +three in g1C). Any admission set that covers the 43 covers them too, or they fail M06 in the same transition. + +## 4. Phase C — is a partial "43 only" population transition possible? + +**No. The impossibility is structural, not incidental.** Three independent mechanisms forbid it: + +1. **The evolution proof binds the complete population.** In `RECORDED_EVOLUTION` mode `verifyMutationRatchet` + runs its own fresh full canonical measurement (`MaintainabilityBaselinePlugin.kt:840-853`, + `persistCommittedBaseline = false`) and mints a proof only when + `MutationPopulationEvolutionProof.exactComparison(fresh, candidate)` is diagnostic-free. + `rowDifferences` iterates `fresh.keys union candidate.keys` and fails on any row "absent from committed + candidate" as well as any status/outcome/family/module difference. A 43-row committed file is missing + every other fresh identity → diagnostics → `proof == null` → M21 fails with *"recorded evolution requires + a proof from an exact fresh canonical measurement"* (`MutationRatchetVerifier.kt:935-941`). +2. **M06 consults nothing** (`MutationRatchetVerifier.kt:312-323`): no classification, no authorization, no + ledger. Any unadmitted candidate-only survivor in the committed population fails, whatever the author + intended to add. +3. **Scope may not narrow.** M14/M15 reject a candidate that drops a family, module, target class or target + test, so a partial file cannot masquerade as scope reduction. + +⇒ **The hypothesis holds: a legitimate fresh baseline transition necessarily carries the complete measured +population**, so every candidate-only NON_KILLED identity in that measurement must be resolved or explicitly +admitted. There is no legal subset. + +### 4.1 The minimum viable admission set + +| Category | Count (g1A) | Requirement to commit the population | +|---|---|---| +| candidate-only KILLED | 154 | none — M07 passes new kills | +| candidate-only NON_KILLED | **195** | **every one must be admitted** (or killed by new tests) | +| base-only (disappeared) | 189 | one M21 record each, bound to `fromBaseSha` | + +"Admit the 43" therefore has a floor of **195 admissions, not 43**, and only 43 of those carry any +adjudication today. + +### 4.2 Changed rows: no new primitive is required (correction to an earlier draft) + +Two identities are present in **both** populations but with a different persisted row: + +| Identity | Target | Committed (base) | Fresh | +|---|---|---|---| +| `20b3d3021a…` | `PolicyEnforcementHelper.evaluate` [NullReturnValsMutator], policy, `:tramai-engine` | `NO_COVERAGE` / NON_KILLED | `KILLED` / KILLED | +| `49f02d5ba7…` | `PolicyEnforcementHelper.evaluate` [VoidMethodCallMutator], policy, `:tramai-engine` | `NO_COVERAGE` / NON_KILLED | `SURVIVED` / NON_KILLED | + +An earlier draft of this record concluded from these two rows that a third authority primitive +("changed-row resolution") is required. **That conclusion was wrong**, and the error was one of arrow +direction: + +| Comparison | Where it lives | What it checks | +|---|---|---| +| **fresh measurement vs candidate committed population** | `MutationPopulationEvolutionProof.exactComparison` → `rowDifferences`, called from exactly one place (`MutationRatchetVerifier.kt:739`) | per-row `status`, `outcome`, `family`, `module`; identities missing from either side; family/module topology; analyzer identity | +| **base population vs candidate population** | shared-identity loop, `MutationRatchetVerifier.kt:288-311` | `M01` regression (`base.outcome == KILLED && candidate.outcome == NON_KILLED`) and `M14` family re-homing — **nothing else** | + +`rowDifferences` never runs base→candidate. Its messages talk about raw and canonical status, which is what +made the misreading tempting, but the arrow is fresh→candidate. A legitimate transition commits the exact +fresh row, so `fresh == candidate` holds by construction and the proof is minted. + +Both rows are therefore absorbed by semantics that already exist: + +| Transition | Rule | Verdict | +|---|---|---| +| base KILLED → candidate NON_KILLED | M01 | FAIL (regression) | +| base NON_KILLED → candidate KILLED | none | PASS — monotonic improvement needs no authority | +| base NON_KILLED raw status A → candidate NON_KILLED raw status B | none | PASS — commit the exact fresh row | +| candidate-only NON_KILLED | M06 | FAIL unless killed or admitted | +| base-only identity | M21 | FAIL without an evolution record | + +Neither row carries a classification (`config/quality/mutation-classifications.yml` has no entry for +either), so M08/M09 cannot interact with the change. **No changed-row primitive and no third precondition:** +the first of the two rows is an improvement the ratchet already permits for free. + +## 5. Phase D — M21's trust properties, reconstructed + +| Property | Implementation | +|---|---| +| **Invocation authority** | property `tramaiMutationEvolution`: absent/`forbid` → FORBID, unknown → throw. CI gives a PR `recorded-evolution` only with the `mutation-population-evolution` label (`ci.yml:220`); a master push only when the diff changed **both** `mutation-baseline.json` and `mutation-evolution.yml` (`ci.yml:232-237`). An ordinary push can never enter a ~50-minute campaign. | +| **Exact fresh-measurement proof** | `MutationPopulationEvolutionProof(projectionHash)`; the hash covers every row as `identity\|status\|outcome\|family\|module`, the family→modules topology and the analyzer block (`MutationRatchetVerifier.kt:840-860`). Missing proof → fail; proof not matching the committed candidate → fail. | +| **Per-identity records** | `MutationEvolutionRecord(id, fromBaseSha, reason, issue?, targetPhase?, authorizedAt?, authorizedBy?)`. | +| **Exact record set** | every record must name a base identity disappearing in this transition (extra records fail); every disappearance must be named (else fail). | +| **Base binding** | every record's `fromBaseSha` must equal the transition base SHA — records cannot be replayed from an older base. | +| **Default FORBID** | without authority every base-only identity fails; absence is never read as improvement. | + +`config/quality/mutation-evolution.yml` is `records: []`: the disappearance ceremony has **never been +used**, so no part of this design may lean on precedent. + +### 5.1 Which M21 properties transfer to *appearing* identities, and which do not + +Additions and removals are **not** symmetric, and treating them as symmetric is the main design trap here. + +| Property | Transfers? | Why | +|---|---|---| +| Invocation authority (property + label) | **yes** | same trust shape: an explicit, non-default invocation with a human decision behind it | +| Exact-set bookkeeping | **yes**, inverted | disappearances: record-set must equal the removed set. Appearances: authorization-set must equal the *admitted* subset; every other appearing identity must still fail M06. The set is no longer equal to the whole delta — it is a *subset* — which is precisely why it needs its own rule rather than a reused one | +| Per-identity payload | **no — must be larger** | an M21 record names an identity plus a reason. An admission must freeze the **complete persisted row** (§6.4), because for an appearance the *value* is what M06 judges, while for a disappearance only the absence matters | +| `fromBaseSha` binding | **yes** | prevents replay from an older base | +| Fresh-measurement proof | **yes, and it becomes the binding constraint** | the proof is computed over the whole population, so it simultaneously (a) proves the measurement is fresh and (b) forces the transition to commit everything measured | +| Default FORBID | **yes** | an appearing NON_KILLED identity without authorization must keep failing M06 | +| "Absence is not improvement" reasoning | **no analogue** | removals are dangerous because absence may be an artifact (narrowed target, aborted campaign). Appearances are dangerous because they are *real* unverified survivors: the risk is not measurement error but an untested behaviour | +| Symmetry assumption | **rejected** | the failure modes, the payload size and the set semantics all differ. An admission rule is new authority, not a mirror of M21 | + +## 6. Phase E — exact-row preauthorization model (on paper; not implemented) + +Two transitions, by analogy with M22-M29 — same temporal trust boundary (the authorization must already +exist in the PR base), different payload. + +``` +P1 — AUTHORIZE P2 — CONSUME (a LATER transition) + base: X absent from committed pop base: exact authorization X present + no authorization X candidate: fresh measurement contains X + candidate: baseline unchanged committed population := exact fresh measurement + proposes authorization X X's candidate row == the authorized row + -> adjudication moment = merge of P1 authorization X consumed + -> X admissible; every unauthorised NON_KILLED still fails M06 +``` + +### 6.1 Minimum payload, field by field (each field justified by the attack it prevents) + +| Field | Attack / ambiguity it prevents | +|---|---| +| canonical identity (64-hex, schema v2) | identity substitution; also forbids descriptor/source-line similarity being used as authority — the identity is the only key | +| complete persisted row (`status`, `outcome`, `family`, `module`) | authorizing an identity and then admitting a *different* row: status laundering (a `TIMED_OUT`/`NO_COVERAGE` admitted as if it were `KILLED`), outcome substitution, re-homing into another family, module substitution under the same class name. Exactly the hole M28 closed for classifications | +| raw status | silent drift of the diagnostic value between authorization and admission | +| canonical outcome | admitting under an outcome the authorizer never saw — and the outcome is what M06 actually judges | +| analyzer semantics (plugin, engine, mutators, `timeoutConst`, `timeoutFactor`) | "authorize now, measure with different settings later": M16-M18 forbid analyzer drift, so an authorization valid under one analyzer must not be consumed under another | +| source base SHA (`fromBaseSha`) | replay of an authorization minted for an older base; mirrors M21's binding for removals | +| fresh-measurement digest (population projection hash) | authorizing row X against measurement M and admitting X from a *different* measurement M′; also binds the neighbours, so the transition's other identities are the measured ones | +| `reason`, `issue`, `targetPhase` | re-authoring the rationale or the tracked issue after the decision; the M24/M28 lesson | +| `authorizedBy`, `authorizedAt` | **audit only — no enforcement value.** Recording them is fine; letting them carry trust would make identity/actor a bypass | + +Minimum enforceable payload: everything above **except** `authorizedBy`/`authorizedAt`. + +Non-negotiable security property: **a candidate may never create the authorization it uses.** The ledger is +read at the base SHA; a candidate-added authorization plus an admission in the same transition must FAIL +(the M23 analogue). + +### 6.2 One thing the model must *not* become + +The digest binding creates a temptation: mint the digest from the *fresh* measurement so that a partial +population could present a matching digest. That would be a bypass: it would let a candidate define its own +measurement. The digest must be bound to the **canonical fresh measurement produced by the verifier's own +campaign**, and the committed candidate population must equal that measurement — which is exactly what the +M21 proof already enforces. + +## 7. Phase F — adversarial discriminator matrix + +The minimum negative/positive set an implementation would require. "Existing" means a rule that already +covers the case today and must keep covering it. + +| # | Scenario | Expected | Rule | +|---|---|---|---| +| 1 | base-authorized exact appearing row, row identical, consumed | PASS | new (M30) | +| 2 | same-transition authorization + population addition | FAIL | new (M31 analogue of M23) | +| 3 | no base authorization + new NON_KILLED identity | FAIL | **existing M06, unchanged** | +| 4 | authorization by identity only, row changed (status/outcome/family/module) | FAIL | new (M32 analogue of M24) | +| 5 | raw status changed from the authorized value | FAIL (or a new authorization) | new | +| 6 | canonical outcome changed | FAIL (re-adjudication required) | new | +| 7 | authorized row absent from the fresh measurement | FAIL — never admitted, never silently satisfied | new | +| 8 | authorized X **plus** unauthorized NON_KILLED Y in the same measurement | **FAIL for Y** | new + **existing M06** | +| 9 | new KILLED identity | PASS | existing M07 | +| 10 | base-only disappearance without an M21 record | FAIL | existing M21 | +| 11 | valid M21 removal + valid admission in the same exact transition | PASS (composable) — both sets are checked against the same committed population and the same proof | existing M21 + new | +| 12 | candidate baseline ≠ the exact fresh measurement | FAIL | existing M21 proof | +| 13 | authorization retained after consumption | FAIL | new (M26/M27 analogue) | +| 14 | authorization rewritten before consumption | FAIL | new (M28 analogue) | +| 15 | authorized identity becomes KILLED before consumption | authorization becomes stale and cleanable; admission must NOT be forced | new (M27 terminal-state analogue) | +| 16 | every passing population transition must itself be usable as the next base | PASS by construction | the invariant test from g1F, reused | + +**The single most important negative: authorized X + unauthorized NON_KILLED Y → FAIL for Y.** An admission +ceremony must never become a general M06 bypass. This is the discriminator that separates "explicit +authority for one adjudicated identity" from "turn M06 off for this transition". + +## 8. Phase G — the three options, traced + +### 8.1 Option B — committed population remains authoritative forever + +Not closure. It is a **latent publish blocker**, and this is checkable rather than rhetorical: + +``` +publish.yml:173 → verify060MaintainabilityRelease + → wireArchitectureAndMaintainabilityAuthorities() + → :verifyReleaseMutation + → runs a FRESH full PIT measurement, verifies it as candidate against the + committed population as base, with evolution defaulting to FORBID +``` + +`verifyReleaseMutation` therefore fires exactly the two rules that the drift violates: M21 for the 189 +base-only identities and M06 for the 195 candidate-only survivors. Consequences: + +| Consequence | Finding | +|---|---| +| fresh release mutation verification | fails deterministically while the committed population is stale — the publish path cannot go green | +| current code vs stale population | the authority stops describing the code; 154 measured KILLs exist in the measurement and are unrecorded in the authority | +| future M06/M21 comparisons | every future regeneration faces the same 195-identity wall, larger if tests are added | +| can g1 close? | not by declaring closure: the gate is wired into the release path | +| can the 43 ever reach classification enrollment? | **no** — M25 requires base-population membership, which B never provides | +| genuine closure or documented drift? | documented drift with a hard downstream gate — the worst of both | + +Latency detail (stated precisely): the last `publish.yml` run was 2026-09-09, *before* the drift became +visible on 2026-09-24 with #429. The blocker has therefore not fired yet; it fires at the next publish. + +### 8.2 Option C — wholesale population replacement + +Wholesale replacement does not remove the authority question, it *is* the authority question: + +| Question | Answer | +|---|---| +| does an exact fresh measurement solve anything by itself? | no — it is the *precondition*: the proof is what makes the committed population admissible evidence at all | +| every candidate-only NON_KILLED identity | must be admitted or killed; 195 of them today, and M06 has no exemption | +| every base-only identity | needs an M21 record (189), each bound to `fromBaseSha` | +| bypass or recreate? | recreates, at maximum blast radius: one transition carrying 2544 rows, 189 records and 195 admissions | +| blast radius vs A | identical in *content*, worse in *reviewability*: A can be staged (authorize, then consume), whereas C is one indivisible authority change with no intermediate state to review | + +### 8.3 Option A — base-preauthorized appearing-identity admission + +| Question | Answer | +|---|---| +| what it buys | the only mechanism by which an adjudicated survivor becomes base-authoritative WITHOUT being killed; without it, an unkillable survivor (tool limitation) can never be recorded, and the population can never be re-synchronised | +| what authority it creates | a new, *narrower-than-M06* admission path: base-minted, exact-row, single-use. It is new authority — it must be argued as a change to M06's rejection path, not smuggled as "just another ledger" | +| minimum evidence standard | the field table in §6.1 is the payload floor; the *adjudication* standard is the one g1E reconstructed for tool limitations (identity + exact instruction/row + construct + determinism + harness independence + semantic protection), which is what makes the authorization content-reviewed rather than typo-reviewed | +| does the whole NON_KILLED cohort need authorization? | **yes, unless killed.** The transition carries the whole measurement, so each candidate-only survivor must either be admitted or be KILLED by the time the measurement is taken (§4.1) | +| can the 43 be handled independently? | **no** — only as part of a complete fresh-population transition (Phase C) | +| how does it compose with M21? | cleanly: M21 handles removals, the admission rule handles appearing NON_KILLED identities, both are checked in the same transition against the same exact measurement and the same proof (discriminator 11) | +| is classification enrollment still separate? | yes, and the order is forced: identities must be admitted to the base population first (admission-first; the step formerly labelled `g1G2`), *then* their classifications can be enrolled through the existing M22-M29 ceremony, which requires base-population membership (M25) | + +### 8.4 The shortcut this preflight refuses + +Writing tests that kill the survivors is not a shortcut around the authority question — it is the *legitimate* +answer for the subset that can be killed. A survivor that a new test kills becomes `KILLED` in the next +measurement and passes M07 automatically: no admission, no authorization, no M06 change. That reduces the +irreducible admission set to the survivors that genuinely cannot be killed. + +## 9. Phase H — the whole candidate-only NON_KILLED cohort + +All 195 identities, not just the 43. Partitioned from data, not from status: every identity was matched to +its raw PIT record (195/195 matched, 14 `mutations.xml` files), giving `numberOfTestsRun` — the counts below +are therefore about *what the tests do at the mutation point*, which is the only thing that distinguishes a +coverage hole from an unkillable construct. Cross-referenced against the g1B cohort. + +| Bucket | n | Evidence | Reading | +|---|---|---|---| +| `TIMED_OUT` | **46** | 100% `NegateConditionalsMutator`; `numberOfTestsRun = 0` for all 46; 3 classes hold 42 | tool-limitation-shaped: the hang signature (PIT never completes a test, so it reports 0) | +| `NO_COVERAGE` | **109** | `numberOfTestsRun = 0` for all 109; `VoidMethodCallMutator` 49 + `NullReturnValsMutator` 50 + `NegateConditionalsMutator` 10; **93 of 109 in 3 approval classes** | real coverage gaps — no test reaches the mutation point at all | +| `SURVIVED` | **40** | `numberOfTestsRun` 1–80 (15 rows at exactly 4); `VoidMethodCall` 15, `NullReturnVals` 11, `NegateConditionals` 9, `ConditionalsBoundary` 4 | tests execute but do not detect: weak assertions **or** equivalent/redundant mutants — not separable without per-identity analysis | + +Concentration is the headline: the three approval classes (`ApprovalResumeCoordinator` 67, +`DefaultApprovalGateway` 46, `ApprovalSuspensionCoordinator` 44) hold **157 of the 195** identities across all +three buckets. This is one module's test surface, not a project-wide phenomenon. + +| Requested bucket | Count | Basis | +|---|---|---| +| already adjudicated / classifiable | **43** | the g1E adjudication; all 43 in the `TIMED_OUT` bucket | +| likely real test gaps | **109** | `NO_COVERAGE`, `numberOfTestsRun = 0` — nothing reaches the mutant | +| likely tool limitation | **46** | `TIMED_OUT`, `NegateConditionals`, `numberOfTestsRun = 0`, coroutine sentinel cluster — the g1C/g1D shape; 43 already adjudicated, **3 more** same class/method/mutator (`executeClaimedResume`) awaiting adjudication | +| stable but unresolved | **40** | `SURVIVED` with tests running — genuinely ambiguous between "equivalent mutant" and "assertion too weak"; splitting them requires the per-identity standard, which this slice does not apply | +| disappeared since g1B | **0** | cohort identical: 195 in both, 0 new, 0 gone | +| new since the old g1B evidence | **0** | same | + +Limits, stated so nobody over-reads the table: this is a *structural* partition backed by tests-run counts, +mutator type and class concentration. It deliberately does **not** claim per-identity semantic verdicts, and +in particular it does not claim the 40 `SURVIVED` rows are equivalent mutants, nor that the 46 are +permanently unkillable. + +**Answer to the Phase H question** — *if exact population admission must consume the whole fresh measurement, +what unresolved authority work remains?* + +| Work | Count | Nature | +|---|---|---| +| M21 disappearance records (bound to `fromBaseSha`) | 189 | bookkeeping, no judgement beyond the reason — but the ceremony has never been used | +| appearance admissions | 195 today | unless some are killed first | +| … of the 195: clearly remediation | **109** | `NO_COVERAGE`, 0 tests run — no test reaches the mutant | +| … of the 195: unresolved until diagnosed | **40** | `SURVIVED` with tests running — may add to remediation *or* to the admission/adjudication set | +| … of the 195: tool-limitation candidate set | **46** | 43 adjudicated, 3 still awaiting adjudication, so 46 is a structural candidate set and **not yet** an authoritative one | +| changed-row resolution | **0** | not required — absorbed by M01/M06/M21 (§4.2) | + +The finding, at the strength the evidence actually supports: **109 of the 195 are clearly remediation work, +not authority work.** A survivor killed by a new test becomes `KILLED` in the next measurement and passes M07 +with no admission, no authorization and no M06 change — which is both the cheaper path and the one that raises +the measured protection number. + +A further **40 require per-identity diagnosis first**, and the order matters: each is either a weak assertion +(remediation) or an equivalent/redundant mutant (adjudication). Writing killing tests before diagnosing them +risks spending the effort trying to kill mutants that cannot be killed, and then building authority machinery +around the wrong residual set. Likewise, the tool-limitation set becomes authoritative at 46 only when the +last 3 `TIMED_OUT` identities are adjudicated by the g1E standard; until then 46 is a candidate set. + +## 10. Verdict + +**No stop condition was triggered.** Audited one by one: this preflight does not weaken ordinary M06; does +not let a candidate authorize its own transition; does not admit an identity absent from a canonical fresh +measurement; does not accept a candidate baseline that is not the exact measured population; does not use +descriptor/source-line similarity as identity authority; does not use baseline-migration as a survivor +waiver; does not use `AUTHORITY_EXCLUDED_IDENTITIES`; does not treat g1E classification eligibility as +population authority (§3: 0/43 are base members); and does not silently admit the other candidate-only +`NON_KILLED` identities — they are enumerated in §9. + +### Findings + +| Outcome | Status | +|---|---| +| `EXACT_MEASUREMENT_MODEL_INCOMPATIBLE_WITH_PARTIAL_ADMISSION` | **TRIGGERED** — proven in §4 | +| `POPULATION_ADMISSION_REQUIRES_FULL_M06_COHORT_RESOLUTION` | **TRIGGERED** — the floor is 195 admissions, not 43 (§4.1) | +| `EXACT_ROW_POPULATION_ADMISSION_FEASIBLE` | **CONDITIONALLY TRIGGERED** — a safe model exists (§6, §7); it is not a 43-row increment, and it has two preconditions below | +| `CURRENT_POPULATION_CHANGED — REPARTITION_REQUIRED` | **NOT triggered** — three measurements, two heads, zero differences; counts equal g1B; the 43 are 43/43 identical (§2.5) | +| `NO_SAFE_APPEARING_IDENTITY_CEREMONY_FOUND` | **NOT triggered** — a safe model exists; the guarantee that keeps it safe is Phase F's discriminator 8 (authorized X + unauthorized Y still FAILS for Y) | + +### The two preconditions on the feasible model + +1. **M06's rejection path must become authorization-aware.** This is a change to M06 — not a weakening if the + four guarantees hold (authorization read from the **base** SHA, exact row bound, single-use, and every + unauthorized `NON_KILLED` still failing), but it is new authority and it is **the user's decision**. This + preflight does not make it. +2. **The transition remains all-or-nothing.** Staging is possible only in the sense of *authorize first, consume + later*; a single consuming transition still carries the complete measurement. + +No changed-row primitive is needed: §4.2 shows the two changed rows are already covered by M01's one-way +regression rule and by committing the exact fresh row. + +### Answer to the bounded question + +> Can TramAI safely support an explicit base-authoritative admission ceremony for candidate-only NON_KILLED +> mutation identities without weakening M06? + +**Yes in principle, and the shape is known — but "admit the 43" is not a thing that can exist.** The ceremony +is feasible only as part of a complete fresh-population transition whose failure set is small enough to +review, and its scope should be the identities that are genuinely unkillable, not every identity the +measurement happens to report as surviving. + +**What exact transition would move the 43 into the committed population:** a `RECORDED_EVOLUTION` transition +that (a) commits the exact fresh measurement, (b) carries an M21 record for each of the 189 disappeared +identities, (c) carries a base-minted exact-row authorization for each admitted appearing `NON_KILLED` +identity — 195 of them unless some are killed first — and (d) mints the proof by matching the verifier's own +fresh measurement. The 43 ride along; they are not separable. Changed rows need no step: they are resolved by +(a) alone (§4.2). + +### Recommendation — the next slice (not implemented here) + +**The next slice is cohort remediation, not ceremony implementation.** The ceremony is the narrow answer for +the unkillable; the bulk of the wall is a test-coverage problem, and 93 of those 109 rows sit in three +approval classes in one module. + +1. **g1G1a — remediate the clear coverage gaps (primary, `runtime-behaviour` class).** Tests that reach the 109 + `NO_COVERAGE` identities, starting with the 93 in `ApprovalResumeCoordinator`, `DefaultApprovalGateway` and + `ApprovalSuspensionCoordinator`. Every identity killed this way passes M07 permanently with **no** authority + change and raises the measured protection number. +2. **g1G1b — diagnose the 40 `SURVIVED` rows individually, before writing anything.** Diagnosis first: each is + either a weak assertion (remediation) or an equivalent/redundant mutant (adjudication). Killing tests written + before the diagnosis may chase mutants that cannot be killed, and then build authority around the wrong + residual. +3. **g1G1c — finish the tool-limitation adjudication (evidence-only).** Apply the g1E standard to the 3 + remaining `ApprovalResumeCoordinator.executeClaimedResume` `TIMED_OUT` identities; only then is the + tool-limitation set authoritative at 46 rather than a structural candidate set. +4. **g1G2 — design only the appearing-NON_KILLED admission ceremony (design-only, no implementation).** + ⚠️ **Identifier superseded.** `g1G2` now denotes the residual taxonomy and adjudication slice + ([TASK-0.7.1g1G2](TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md)); this admission step is implemented under a new task + id (the admission ceremony, new rules after M29), with the payload and discriminator specification below + unchanged. Current floor after the measured closures: **177** residual candidate-only identities, of which + **58** carry firm adjudications (12 `EQUIVALENT` + 46 `TOOLING_LIMITATION`), 1 is `DEFERRED_STRUCTURAL` and + **118** remain `UNDETERMINED` — the 118 are why this step cannot consume the population yet. **The + remainder of this item is unchanged:** §6 + payload, §7 discriminators as its test specification. The M06 authorization branch is presented as an + explicit authority decision, with its negative discriminator (authorized X + unauthorized Y still FAILS for + Y) as the acceptance test. No changed-row primitive: §4.2. +5. **Then, and only then, the population transition PR** — commit the exact fresh population, M21 records for + the then-current disappeared identities, consume base-preauthorized admissions for the then-current + candidate-only NON_KILLED identities, with the failure set already minimized by steps 1-3. + +Sequencing note that is not optional: the **publish gate** (`verify060MaintainabilityRelease` via +`publish.yml:173`) is blocked while the committed population is stale. This work is on the critical path of +the next release regardless of what happens to the 43, so "leave the population alone" is not a stable +resting place — it is a deadline. + +## 11. Verification + +| Command / check | Result | +|---|---| +| `git fetch origin`; `git rev-parse origin/epic/0.7.1-control-plane-authority` | `0693662969f7459e7106d3d708b300139820b43a` (40 chars) | +| branch cut from that SHA; `git status --porcelain` | clean | +| `git diff --name-only 0f36ab87..06936629` (custody of the range) | no mutation-target source, no analyzer semantics, no quality config; 1 non-target test | +| `./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks` in detached worktree at `06936629…` | BUILD SUCCESSFUL, 50m14s, 2544 mutants, exit 0 | +| artifact sha256 | `5091cf86e42288e1e4c16fd15a2ff065850b83e4693dd009ddec4bc52071407e` | +| row-for-row comparison vs g1A-A and g1A-B | 0 differing rows, 0 set differences | +| PIT campaign summary vs persisted population | 1667/330/463/84 and per-family counts identical | +| identity self-consistency, duplicates, outcome/status agreement | 0, 0, 0 | +| 43 g1E identities vs fresh and committed populations | 43/43 present, 0/43 committed, rows identical | +| Phase H partition (195 identities against 14 `mutations.xml`) | 195/195 matched to PIT evidence | +| `git status` at end of slice | one new untracked file (this document) | + +Reproducibility scripts live in `/tmp` and are **not committed**: `tramai-071g1g0-analyze.py`, +`tramai-071g1g0-partition.py`, `tramai-071g1g0-integrity.py`, `tramai-071g1g0-changed2.py`. + +### 11.1 The CI failure on this PR — attribution (unrelated; not fixed here) + +CI on this PR failed in `:tramai-orchestration:test`: +`WorkerShutdownCoordinatorTest > shutdown drains a running execution to completion before finishing()` at +`WorkerShutdownCoordinatorTest.kt:213` (1152 tests, 1 failed, 3 skipped). It is **not** attributable to this +change, and that is provable rather than asserted: + +| Evidence | Value | +|---|---| +| `WorkerShutdownCoordinatorTest.kt` blob at the Epic base `06936629…` | `36640558ac4f8482c44007ec8ad775f4d2ea0f61` | +| … at the **failing CI head** `642cd07a…` (first revision of this PR) | `36640558ac4f8482c44007ec8ad775f4d2ea0f61` | +| … at the **corrected head** `1bbfa088…` (second revision, the review correction) | `36640558ac4f8482c44007ec8ad775f4d2ea0f61` | +| … at `origin/master` and at `origin/epic/0.7.1-control-plane-authority` | `36640558ac4f8482c44007ec8ad775f4d2ea0f61` | +| this PR's diff vs the Epic base, across both revisions | one documentation file (597 insertions) | + +The failing head and the corrected head are different commits, and the blob is identical at both — as it is at +the base and at both branches. The merge head is deliberately not named here: it is whatever head carries this +text, and naming it would go stale on the next commit. + +The assertion is a real race, not an unexplained flake: `shutdown()` calls `observability.onShutdownStarted(...)` +(`WorkerShutdownCoordinator.kt:117`) **before** `pollJob?.cancelAndJoin()` (118) and +`executionSupervisor.activeExecutionsSnapshot()` (119). The test releases its gate as soon as it observes +`shutdownStarted` (`WorkerShutdownCoordinatorTest.kt:204-206`), so a valid interleaving exists in which the +execution completes and deregisters before the snapshot is taken — after which the drain legitimately reports +`drainProgress(0,0)`, while the assertion demands the subsequence `drainProgress(1,0)`. `shutdownStarted` +cannot prove the coordinator captured the execution; only a synchronization point *after* the snapshot can. + +**Deliberately not fixed here**: this record is evidence-only, and the test fix belongs in its own slice. + +The failed job was re-run **at its original failing head `642cd07a`**; that rerun is not a gate for any other +head. Only exact-head CI for the head that actually carries this change is authoritative for merge, and a later +commit to this branch invalidates a green rather than inheriting it. + +## 12. Non-claims + +- No authority file was modified: no population, classification, enrollment or evolution record changed. +- M06 was not modified, weakened or bypassed; its behaviour is documented from source. +- No measurement entered the committed authority; the campaign ran in a detached worktree. +- The 43 remain adjudicated-but-unenrolled, exactly as g1E left them. +- g1A/g1E evidence is cited as historical. Counts asserted as *current* are the ones measured in this slice + and are labelled as such. +- The publish blocker in §8.1 is derived from task wiring plus the unconditional M06/M21 rules. It is + deterministic, but it was **not** re-run end-to-end in this slice (that would cost a ~50 minute campaign); + the last publish run predates the drift. +- **No second campaign was run**, by recorded decision (§2.5). The stability conclusion rests on three + independent measurements taken at two different heads, not on a same-head repeat. +- The Phase H partition is **structural**: raw status, mutator type, `numberOfTestsRun` from the raw PIT XML, + and class concentration. It does not claim per-identity semantic verdicts, and it does not claim the 40 + `SURVIVED` rows are equivalent mutants or that the 46 `TIMED_OUT` rows are permanently unkillable. +- **§4.2 corrects an earlier draft of this same record.** The first revision claimed a changed-row authority + primitive was required; that came from reading `rowDifferences` (fresh→candidate) as a base→candidate + comparison. The correction is recorded rather than quietly deleted, and the wrong version was the one first + published in this PR. +- The **CI failure on this PR is not attributable to this change** (blob identical at the Epic base, this head + and `origin/master`; diff is one documentation file) and was **not** fixed here. §11.1 records the diagnosis + for a separate slice. +- "109 clearly remediation" means no test currently reaches those mutation points. It is not proof that a test + can kill each one — that is what writing them establishes. +- Nothing was implemented: no test was written, no survivor was killed, no build logic changed, no + authorization or record written. Every forward-looking item in §10 is a proposal, including the M06 change, + which is explicitly left as the user's decision. +- The candidate-only NON_KILLED floor of 195 is a statement about *today's* measurement with *today's* tests. + Adding tests moves it down; it is not a permanent property of the codebase. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G1-MUTATION-DEBT-TAXONOMY.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G1-MUTATION-DEBT-TAXONOMY.md new file mode 100644 index 000000000..528e8e166 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G1-MUTATION-DEBT-TAXONOMY.md @@ -0,0 +1,195 @@ +# TASK-0.7.1g1G1 — Mutation-Debt Taxonomy and Remediation Boundary (g1G1 consolidated diagnosis) + +**Status:** Recorded authority for the 0.7.1 candidate-only mutation-debt cohort +**Scope:** the 195 candidate-only `NON_KILLED` mutation identities measured at campaign A (`06936629`) +**Supersedes:** the working decompositions quoted in the g1G1a/g1G1b slice records (11 genuine + 18 genuine; "29 remediation identities"; "120 glue") +**Does not authorize:** any enrollment, classification write, M06 change, or gate exemption. See §8. + +## 1. Method + +Every row was classified from the **mutated instruction and the measured execution facts**, not from a filename or a description filter. A description-signature filter was used first and was **wrong in both directions** (§6.1), which is why the procedure below is the recorded one. + +Per identity: + +1. **Mutated instruction** — `javap -p -c -l` on the compiled class, attributing each instruction to its source line via the `LineNumberTable`. Traps that cost real time: `javap` prints `line : ` (line first), a single line can carry **several** entries, and a `{line: offset}` map built last-write-wins silently hides the entry you need. Inlined standard-library bodies also carry their own file's line numbers into the caller's class — `ApprovalRunAttribution.kt:174` is not in that 169-line file; it is the inlined `Iterable.filter` fast path. +2. **Execution fact** — `numberOfTestsRun` per row from the family `mutations.xml` (`an XML ATTRIBUTE on `, with `block`/`index` nested inside ``/``). +3. **Internal control** — for a branch-guard question, compare the guard's line with the line below it: the branch-header line shows `numberOfTestsRun > 0` (an existing test covers the guard) while the sentinel line shows `0`. That contrast is what proves a "coverage gap" is a capability limit rather than a missing test. +4. **Observability** — read the enclosing source (catch ordering, branch structure, the called helper's body) and decide whether the mutated behaviour can change an observable contract at all. + +Reproduction recipes: §7. + +## 2. Three distinct concepts (do not blur them) + +| Concept | Question | Where it is answered | +|---|---|---| +| **Mutation classification** | Is this mutant meaningful, equivalent, glue, or a compiler artefact? | §3, §4 | +| **Remediation eligibility** | Would a test killing it assert a real contract? | §5 | +| **Remediation reachability** | Can that remediation be made inside the permitted change class **at this repository state**? | §6 | + +The two coordinator identities in §5 are the case that forces the distinction. Both are *real weak assertions* and both are *eligible*, but their *reachability* differed: `ApprovalSuspensionCoordinator:117` could be asserted from an isolated test file that exercises the renewed-requirement validation without reopening the drifted test files (**closed by #446**), while `ApprovalResumeCoordinator:102` needs the full resume saga and stays **deferred**. + +## 3. Determined taxonomy + +| Bucket | Count | Meaning | +|---|---|---| +| Coroutine/compiler glue | 124 | Testing them asserts nothing about the contract (instruction-level evidence in §3.1) | +| Weak-assertion debt | 10 | Real, observable, test-killable — **8 closed by #444, 1 closed by #446, 1 deferred** (§5) | +| Equivalent mutants | 4 | The mutation cannot change any observable outcome | +| Compiler guards | 2 | Java-platform null guards whose removal changes only a compiler-generated message | +| Closed by #442 | 9 | 8 `NO_COVERAGE` validator rows + `validateMetadata:163` | +| **Determined** | **149** | 195 − 46 undetermined | +| Undetermined (§4) | 46 | `TIMED_OUT` — measurement-capability limited, **not** a settled category | +| **Cohort total** | **195** | | + +The 8 `#444` closures are the **closed half of the weak-assertion bucket**, not a sibling bucket: counting them a second time is how 149 determined identities silently become 157. + +### 3.1 Glue — 124 identities, with the instruction-level evidence + +Membership is established by the mutated instruction, never by the row's description: + +- **16 rows: the mutated line carries a coroutine/compiler marker** — `getCOROUTINE_SUSPENDED`, `KotlinNothingValueException`, or `ResultKt::throwOnFailure`. Examples: `ApprovalResumeCoordinator:119`, `:144`, `ApprovalResumeCoordinator$resume$2#invokeSuspend:85`, `ApprovalSuspensionCoordinator:286`, `:299`, `:293`–`:295` (three `compensateSuspension` lambdas), `DefaultApprovalGateway:201`, `:222`, `:244`, and the two sentinel checks `ApprovalSuspensionCoordinator:280`, `:282`. +- **8 rows: an `areturn` on a `Unit`-returning suspend function's return or early-return** — the value is discarded by the caller, so replacing it with `null` is unobservable by construction. `DefaultApprovalGateway:205` (`if (governedIdentity == null) return`), `:219`, `:234`, `:265`; `ApprovalSuspensionCoordinator:284` (×2), `:297`, `:307`. +- **2 rows: suspend-call sentinel checks in the approval coordinator** — `ApprovalResumeCoordinator:151`, `:155`. Each is an `if_acmpne` against `COROUTINE_SUSPENDED` immediately after `invokevirtual denyAndCancel(…, Continuation): Object` / `cancelForNestedApproval(…)`. The callees are declared `Nothing` and always throw, so the comparison runs only if the call **returns** — which needs a genuinely suspending collaborator, not a synchronous fake. The internal control confirms it: the branch-header lines `150`/`154` report `numberOfTestsRun = 1` (the existing deny and nested tests do cover the guards), while `151`/`155` report `0`. +- **98 rows: the canonical glue pair** at a suspend function's declaration line — one `NullReturnVals` ("replaced return value with null for `::`") plus one `VoidMethodCall` ("removed call to `kotlin/ResultKt::throwOnFailure`") per suspension point, in matching `block` pairs. + +**What glue means here:** the mutant is not killable by asserting a contract, because the mutated code is state-machine bookkeeping. Whether a suspension-driving harness could execute it at all is a separate, unanswered capability question (§4). + +### 3.2 Equivalents — 4 identities + +- **`ApprovalResumeCoordinator:108` and `ApprovalSuspensionCoordinator:305`** — a removed `CancellationKt::rethrowIfCancellation` call in a `catch (e: Exception)` clause that **follows** a `catch (cancelled: CancellationException)` clause. The helper's entire body is `if (this is CancellationException) throw this`, so at that call site the receiver is a non-cancellation **by construction** and the guard is provably false. Two consequences worth recording: (a) the call is vestigial defensive code — it does not walk the cause chain, so a *wrapped* cancellation is not rethrown either, which makes "nest a cancellation inside the exception" an unwinnable test design; (b) deleting the call would kill the mutant but would be a production change made for a mutation score, which is the wrong direction. +- **`ApprovalRunAttribution.kt:174` ×2** — negations of the inlined `Iterable.filter` fast path (`is Collection<*> && isEmpty()`) reached from `decodeApprovalAttribution:94`. Equivalent because the existing suite already pins every input class: `ApprovalRunAttributionTest:30` (round-trip equals `Governed`), `:53` (legacy stays un-attributed), `:62` (every partial reserved set is corruption). Any negation that changed the outcome for those inputs could not have survived those assertions. + +### 3.3 Compiler guards — 2 identities + +`ApprovalSuspensionCoordinator:160` (`checkNotNull` on `expiresAt`) and `:176` (`checkNotNullExpressionValue` on `clock.instant()`). Both are compiler-inserted guards on Java-platform values. With the guard removed and a null flowing, the enclosing constructor's `checkNotNullParameter` still throws **before any state is created**, so the original and the mutant fail fast at the same point and differ only in a Kotlin-compiler-generated message. Pinning that message as contract is not a legitimate assertion, so **no message-contract tests exist for these two** and none should be added. + +## 4. Undetermined — 46 identities + +The 46 `TIMED_OUT` rows are **measurement-capability limited, not a settled semantic category**. They are `NegateConditionals` mutants with `numberOfTestsRun = 0` (42 attributed to the coroutine sentinel comparison, 4 to synthetic `invokeSuspend` continuations). What is known: no test in the unit completed before the minion was killed, and the tranche has **0 KILLED** siblings at the same site class — i.e. no positive control exists for the bounded-test hypothesis, which is why g1E recorded `CONTROL_MECHANISM_NOT_TRANSFERABLE`. + +A genuinely suspending harness and a bounded covering test that converts a hang into a failure were the first two hypotheses, and they have now been **tested once, negative** (§4.1). Still untested: a different scheduler, and a future PIT mutation-selection or timeout policy. Measured harness cost, kept for that evaluation: the scoped two-family probe executed **675 tests, 9.12 per mutation**, so per-mutant execution amplification remains a live hypothesis for this tranche alongside mutant semantics and test-harness budget. Therefore: + +> These 46 are **undetermined**. They are not "non-actionable mutants" and must not be reclassified as glue or equivalent by inference from their neighbour rows. + +Nothing in this record changes their count, and no remediation is planned for them in g1G1. Their status is now undetermined **by attempt as well as by assumption**: one representative was pushed with every capability the first two hypotheses named, and it did not move. + +### 4.1 Measured attempt on one representative — capability negative + +**Subject** — the identity `4245a1aa59c91bc5…`: `DefaultApprovalGateway.kt:105`, block 35, index 243, `NegateConditionalsMutator` (`negated conditional`), on the `approvalStore.get(...)` call inside `requestApproval` (`requestApproval-Atj0Sqo`). Chosen by census rather than by guess: the 46 rows live in exactly three classes, and `DefaultApprovalGateway.requestApproval` holds six of them with the cheapest harness, its first store call being an injected `ApprovalStore` method — so the suspension is genuinely injectable. + +**Procedure** (throwaway worktree at the Epic head; no production, PIT-policy, classification, baseline or authority change): + +- a delegating store (`ApprovalStore by delegate`) overrides only `get`, incrementing a counter and genuinely suspending via `delay(1)` before delegating; +- one test drives the public entry point across that suspension and asserts the counter reached 1, so the suspending body provably ran and the state-machine resume path behind line 105 is exercised rather than short-circuited; +- the call is wrapped in `withTimeout(2_000)` inside `runBlocking` — below PIT's 4s `timeoutConst` — so a mishandled resume should surface as a deterministic test failure rather than a harness timeout; +- scoped diagnostic campaign over that class only: 79 mutants, 4m11s, provenance confirmed by a name-only diff against the PR head. + +**Result** + +- positive control passes: the unmutated implementation completes normally (13 tests, 0 failures, the new test green in 18 ms, returning `ApprovalRequestResult.Suspended`); +- the target identity **`TIMED_OUT` → `TIMED_OUT`**, with PIT recording `status="TIMED_OUT"`, `numberOfTestsRun="0"` and an empty `killingTest`; +- the class's `TIMED_OUT` census is unchanged (13 → 13); **0** `KILLED → non-KILLED` regressions; identity sets identical (79/79 shared, 0 new); +- incidental movement only: two rows on the method's declaration line (73) moved `NO_COVERAGE → KILLED` (`NullReturnVals`) and `NO_COVERAGE → SURVIVED` (`VoidMethodCall`) — driving the entry point adds coverage there. Movement on the 124 glue rows is interesting evidence but was never this experiment's success criterion. + +**Interpretation** — a test-level suspension harness does not overcome PIT's behaviour for this sentinel class. The operative fact is `numberOfTestsRun = 0`: under the mutant no test completes, so the mutation is recorded `TIMED_OUT` and never credited as killed, even when the suspending path is proven reachable and the calling test is locally bounded. + +**Scope limit, stated explicitly** — this is one representative in one class, not all 46. It converts the first two hypotheses from *untested* to *tested once, negative*; it does not license reclassifying the remaining rows, and it does not touch the still-untested hypotheses (a different scheduler, a future PIT selection/timeout policy), which stay open. + +## 5. Weak-assertion debt — 10 identities, and where they went + +All ten were `SURVIVED` with `numberOfTestsRun > 0` and a provably observable contract. Nine are closed — eight by #444, one by #446 — and one is **deferred**. + +| Identity | Contract asserted | Status | +|---|---|---| +| `GovernedApprovalStore.kt:56` b7.i25, b2.i12, b5.i22 | `(attribution as? Governed)?.identity ?: return` — every negation skips the binding comparison, so an approval whose attribution names another run is accepted (fail-open) | **closed by #444** | +| `RuntimeEvidenceAttribution.kt:24` b0.i4 | the five-key allowlist; Kotlin reads a same-object property as a direct field load, so the consumer is `RuntimeEvidenceContractValidator:133` (`allowedKeys = familyAllowedKeys + metadataKeys`) — an empty vocabulary rejects the identity tuple as unknown keys | **closed by #444** | +| `RuntimeEvidenceContractValidator.kt:135` b12.i51 | the removed `RuntimeEvidenceAttribution.validate(...)` call — a partial tuple is corruption, not legacy | **closed by #444** | +| `RuntimeEvidenceContractValidator.kt:170` b75.i270, `:201` b116.i459 | `approvalVersion >= 0` and `attempt >= 0`; `attempt = 0` is the **normal first attempt** | **closed by #444** | +| `GovernedContinuationContinuity.kt:35` b4.i29 | the run id in the continuity rejection; a message reading `null` describes no run | **closed by #444** | +| `ApprovalSuspensionCoordinator.kt:117` b43.i148 | `timeoutMillis > 0` — zero is an approval already expired when persisted | **closed by #446** | +| `ApprovalResumeCoordinator.kt:102` b76.i533 | the structured-parse uncertain reason must name the failure type; a constant `unknown` erases it from emitted evidence | **deferred** (§6) | + +Measured at the #444 head (`eb691e19`): the 8 admissible identities `SURVIVED → KILLED`, **0** `KILLED → non-KILLED` regressions across 1047 baseline-KILLED rows in scope, **0** identity churn, and exactly **2** `KILLED → SURVIVED` flips versus the ten-test version — the two then-blocked identities, confirming the removed tests account for exactly two kills. + +Measured for #446 at its head (`3fda224e`, scoped approval-family campaign, 918 mutants, 18m25s): `ApprovalSuspensionCoordinator.kt:117` b43.i148 — the `ConditionalsBoundaryMutator` (`> 0` → `>= 0`) — `SURVIVED → KILLED`, with **0** `KILLED → non-KILLED` regressions across 512 baseline-KILLED rows in scope, **0** identity churn, and exactly one flip (512 → **513** killed, 137 → **136** survived). The two `NegateConditionals` rows at the same site were already `KILLED`, so the **inverted boundary** was the specific survivor — which is why that contract is asserted from both sides: zero rejected, positive accepted, and each side fails when the boundary inverts. + +For the one deferred identity, the precise statement is: **classification** real weak assertion · **remediation eligibility** yes · **remediation reachability in g1G1** no. It is not a tool limitation and not merely "still SURVIVED". + +## 6. The change-class boundary (why one identity is deferred) + +`ApprovalSuspensionCoordinatorTest.kt` and `ApprovalResumeCoordinatorTest.kt` carry **dormant formatter drift** (unsorted imports, non-canonical wrapping). The spotless ratchet is **PR-scoped** — a file that is not part of the PR's change set is not checked, which is why the drift has survived — so touching either file puts the whole file through the formatter. Measured in both directions: + +- **Not reformatted**: `spotlessKotlinCheck` fails on the same two files. +- **Reformatted**: `spotlessKotlinCheck` passes, and the detekt baseline no longer matches: **22 non-baselined findings** in that state. + +The mechanism is **not** a one-to-one re-anchor, and it is worth stating precisely because the intuitive account is wrong. `.editorconfig` deliberately leaves KtLint's line-length rule **off** ("ktlint_official leaves it off"), while detekt's `MaxLineLength` is **active at 120**. Canonical formatting therefore moves lines across detekt's threshold in *both* directions at once. Measured on the two files (base `92b306eb` → canonical form): + +| | before | after | +|---|---|---| +| Lines over 120 characters (raw length) | 40 | 18 | +| Baseline `MaxLineLength` entries for these files | 38 | 28 | +| Constructs no longer over 120 (formatter wrapped them) | — | 32 | +| Findings on newly joined single-line statements | — | 22 | + +So the repair is a **baseline recomposition**, not a re-anchor: net **−10** entries for these two files (4792 → 4782 repository-wide) — 32 legacy suppressions genuinely removed, 22 added on statements KtLint canonicalises as single lines. It belongs to its own slice and its own change class (`baseline-migration`), and it needs an explicit per-entry mapping, because a slice that both reformats and adds assertions destroys the one property that made #444 strong: **mutation movement attributable purely to assertion-strengthening tests**. + +Measured hazard from the same run: `verifyStaticAnalysis` printed `Detekt baseline OK: base 4792 -> current 4792; 0 removed, 0 added` **while reporting 22 non-baselined findings**. The gate's removed/added counters compare *cardinality*, not identity, so equal counts can still fail. Treat "0 removed, 0 added" as a count statement only, and always pair it with detekt's own non-baselined finding list. + +``` +formatter/baseline normalization (own PR, baseline-migration) + → prove spotlessCheck + verifyStaticAnalysis stable with no changes to findings' cardinality + → add the resume test (own PR, runtime-behaviour) + → scoped PIT measurement +``` + +**Outcome in g1G1: this recomposition was measured and rejected** as unjustified for a single mutant. `ApprovalSuspensionCoordinator:117` never needed the drifted files — the renewed-requirement validation reads only the request, the policy decision and the arguments digester, touching no gate, store or suspension state — so an isolated test file with the real `Sha256ToolArgumentsDigester`, a matching digest, and never-reached stand-ins closed it (**#446**). What remains blocked is one identity: `ApprovalResumeCoordinator:102`, whose scenario needs the full resume saga and therefore one of the drifted files. The normalization stays available as repository hygiene if those legacy files are reopened for their own sake — never as a mutation-score prerequisite. + +### 6.1 The description filter that produced the earlier wrong counts + +The first partition classified a row as glue if its description matched `replaced return value with null` or `removed call to kotlin/ResultKt::throwOnFailure`. That rule is: + +- **too narrow** — it missed the two sentinel-negation rows (`ApprovalResumeCoordinator:151`, `:155`) and the two sentinel checks `ApprovalSuspensionCoordinator:280`, `:282`, all glue by instruction; +- **unverified** — it accepted 8 rows (`areturn` on `Unit` returns) as glue on the strength of the description alone, without checking the instruction. + +Net effect: "11 genuine `NO_COVERAGE` + 18 genuine `SURVIVED` = 29 remediation identities" and "120 glue". The instruction-verified figures are **10 weak-assertion identities total** and **124 glue**. The corrected arithmetic, which is the reason this record exists: + +``` +195 cohort +├─ 124 glue (determined) +├─ 46 TIMED_OUT (UNDETERMINED — measurement capability) +├─ 10 weak assertion (determined) +│ ├─ 8 closed by #444 +│ ├─ 1 closed by #446 +│ └─ 1 deferred (baseline-migration reachability) +├─ 4 equivalent (determined) +├─ 2 compiler guard (determined) +└─ 9 closed by #442 += 195 (149 determined · 46 undetermined) +``` + +The 8 `#444` closures are the already-closed half of the 10 weak assertions, not a separate bucket — writing them twice is how "195" silently becomes 203. + +## 7. Reproduction recipes + +**Instruction attribution** + +``` +javap -p -c -l /build/classes/kotlin/main/.class +# per method: read the LineNumberTable as (line, start_pc) pairs, keep EVERY entry per line, +# then attribute each instruction to the entry with the largest start_pc <= its offset. +# Glue markers: getCOROUTINE_SUSPENDED, KotlinNothingValueException, ResultKt::throwOnFailure. +``` + +**Internal control from the family report** — `numberOfTestsRun` (attribute) per line: the branch-header line `> 0`, the sentinel line below it `= 0`. + +**Scoped family measurement** — in a **throwaway worktree**, narrow `mutation.targetFamilies` in `config/quality/test-quality.yml` to the families under test, commit, then run `generateCriticalMutationBaseline`. The aggregate lands at `/config/quality/mutation-baseline.json` with per-row identity and status. Two traps: the **committed** population file is checked out in that worktree before the run, so verify `measuredCommit` equals the probe head and that the row count matches the narrowed scope before reading it; and never let the narrowed config leave the throwaway worktree. + +**Comparisons** — join on the **full canonical identity**, never on `(file, line, block, index)`: that tuple collides across mutators and produced a false "`ApprovalSuspensionCoordinator:117` was already KILLED" during this work. Identity in the partition summary is a 16-character prefix of the population identity, so prefix-join deliberately. + +## 8. What this record does not authorize + +- **No enrollment.** The 4 equivalents, the 2 blocked weak assertions, the 2 compiler guards and the 124 glue rows are **not** to be written into `config/quality/mutation-classifications.yml`. The g1F finding stands: a candidate-only identity cannot be authorized by a candidate, so classification of any of these requires the M22–M29 ceremony against a governing base — and the g1G0 wall (0 of 43 adjudicated identities in the committed population; admission floor is the whole 195-row cohort) is unchanged. +- **No new tests for glue, equivalents or compiler guards**, and no message-contract tests for the two compiler guards. +- **No reclassification of the 46** `TIMED_OUT` rows as a settled category (§4). +- **No gate change of any kind** — no baseline edit, no deviation ceiling, no workflow change, no exemption. The §6 migration is a future slice's work under its own change class, not a licence taken here. +- **The one deferred identity stays `SURVIVED`** in every measurement until §6's prerequisite lands. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md new file mode 100644 index 000000000..70db1b7f8 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md @@ -0,0 +1,155 @@ +# TASK-0.7.1g1G2 — Residual Mutation Taxonomy and Adjudication + +**Status:** Recorded adjudication for the residual candidate-only mutation population at the post-0.7.1g1G1 frozen head +**Frozen baseline:** Epic `epic/0.7.1-control-plane-authority` at `c568b9c7` (post-#447) +**Predecessor:** [TASK-0.7.1g1G1](TASK-0.7.1g1G1-MUTATION-DEBT-TAXONOMY.md) — the cohort and its buckets +**Does not authorize:** enrollment, classification writes, gate changes, or opportunistic kills. See §6. + +This slice is **measurement and adjudication only**. No production, test, PIT-policy, classification, baseline or authority change was made; no kill was attempted. Its purpose is to give every residual identity exactly one disposition before the g1G0 admission ceremony, without improving the mutation score opportunistically. + +## 1. Method + +1. **Recover the cohort identity-exactly.** The g1G0 partition buckets (`no_coverage` 109, `survived_with_tests` 40, `timed_out` 46) are joined to the campaign-A population on the canonical mutation identity prefix: **195 / 195 identities recovered**, none lost, none duplicated. +2. **Apply the measured closures.** An identity counts as closed only if a *newer measurement* reports it `KILLED` — not because a PR claims it: the `#442` scoped re-measurement (evidence family), the `#444` re-measurement (approval + evidence at `eb691e19`), and the `#446` re-measurement (approval at `3fda224e`). Result: **18 closed**, **177 residual**. +3. **Re-census at the frozen head** (approval family only, full family configuration otherwise unchanged; 918 mutants, 18m10s, throwaway worktree `a47a759f`). Approval is a complete scope here, because **all 177 residual identities are in the approval family**. +4. **Group by mechanism, not by appearance.** Grouping uses the mutation operator, the site's compiled instructions (`javap`, line-attributed), and the source line's shape. Line-level attribution is imperfect where PIT's line tables shift an instruction to a neighbouring line; where that happened the group is named for what is *measurable* (see G3) rather than for what was guessed. + +## 2. Accounting + +``` +cohort (candidate-only NON_KILLED at campaign A) 195 + closed by measured slices 18 (9 #442 + 8 #444 + 1 #446) + residual 177 + NO_COVERAGE 101 + SURVIVED 30 + TIMED_OUT 46 +``` + +Dispositions — every residual identity appears exactly once: + +``` +TOTAL_RESIDUAL = 177 + KILLABLE 0 + EQUIVALENT 12 + UNREACHABLE 0 + TOOLING_LIMITATION 46 + DEFERRED_STRUCTURAL 1 + UNDETERMINED 118 + --------------------------------- + sum 177 accounted 177/177 +``` + +Integrity: **lost identities 0 · duplicate identities 0 · unexplained movement 0.** + +Measurement reality is unchanged by this record — the re-census is identical to the measurement that the closures were recorded against: + +- census rows 918 / previous 918, identity sets identical (census-only 0, previous-only 0) +- **status movement between the two measurements: 0** +- family totals identical: `KILLED` 513, `NO_COVERAGE` 193, `SURVIVED` 136, `TIMED_OUT` 76 +- of the 177 residual identities, **177 are still `NON_KILLED` in the census and 0 became `KILLED`** +- of the 18 closed, none reverted; 13 are outside the approval scope (they are evidence-family validator rows) and 5 are `KILLED` inside it + +Adjudication therefore changes *classification*, not measurement. + +## 3. Mechanism groups (numeric view) + +| Group | Mechanism | Operator | Count | Statuses | +|---|---|---|---|---| +| G1 | `NULL_RETURN_OTHER` — return value replaced by null, site is not a suspension point | `NullReturnVals` | 49 | 38 `NO_COVERAGE`, 11 `SURVIVED` | +| G2 | `SUSPENSION_POINT_RETHROW` — removed `ResultKt::throwOnFailure` at a suspension point | `VoidMethodCall` | 43 | 38 `NO_COVERAGE`, 5 `SURVIVED` | +| G3 | negated conditional on a coroutine sentinel comparison (all `TIMED_OUT`) | `NegateConditionals` | 46 | 46 `TIMED_OUT` | +| G4 | `OTHER_VOID_CALL` — removed call that is not at a suspension point | `VoidMethodCall` | 20 | 11 `NO_COVERAGE`, 9 `SURVIVED` | +| G5 | `NULL_RETURN_AT_SUSPENSION_POINT` | `NullReturnVals` | 12 | 12 `NO_COVERAGE` | +| G6 | negated conditional on a source-level branch, not timing out | `NegateConditionals` | 5 | 2 `SURVIVED`, 2 `NO_COVERAGE`, 1 deferred identity | +| G7 | inlined standard-library `Iterable.filter` fast path | `NegateConditionals` | 2 | 2 `SURVIVED` | +| | | **total** | **177** | | + +G3 is defined by the *measurable* fact that every `TIMED_OUT` row in the residual is a `NegateConditionals` row and no other row times out (46 = 17 sites whose line carries the sentinel compare + 29 attributed one line off). It is **not** an instruction-exact split: the sub-tag is line-attribution dependent, and the disposition below does not depend on it. + +Locations: `ApprovalResumeCoordinator` 71, `ApprovalSuspensionCoordinator` 58, `DefaultApprovalGateway` 46, `ApprovalRunAttribution.kt:174` 2. + +## 4. Dispositions with evidence + +**G3 — 46 identities — `TOOLING_LIMITATION`.** +Evidence: every one is a `NegateConditionals` row at a suspend-call site and every one reports `TIMED_OUT`, i.e. no test completes (measured for the representative as `numberOfTestsRun="0"` with an empty `killingTest`, §4.1 of the predecessor record). The predecessor's bounded experiment — a genuinely suspending collaborator, a proven suspension, and a local bound below PIT's `timeoutConst` — left its representative in the same bucket. +Basis for generalising to the block: the operator, the site class and the status are identical across all 46, and PIT's behaviour here is a property of the operator applied to a compiler-generated sentinel comparison rather than of any particular test. This is a **structural generalisation with one representative demonstration**, not 46 independent demonstrations. +Further experimentation required: no for this disposition; yes only if a second representative is wanted. Failure modes excluded: the row is not merely uncovered and not merely weakly asserted — both were provided and neither moved it. + +**G7 — 2 identities — `EQUIVALENT`.** +`ApprovalRunAttribution.kt:174` ×2 are negations inside the inlined `Iterable.filter` fast path (`is Collection && isEmpty()`) reached from `decodeApprovalAttribution:94`. The existing suite pins every input class (`ApprovalRunAttributionTest:30/53/62`: governed round-trip, legacy un-attributed, every partial reserved set is corruption), so any negation that changed behaviour for those inputs could not have survived those assertions. +Further experimentation required: no. + +**Unit-return `areturn` subset — 8 identities — `EQUIVALENT`.** +`DefaultApprovalGateway:205`, `:219`, `:234`, `:265` and `ApprovalSuspensionCoordinator:284` (×2), `:297`, `:307`. The mutated instruction is the `areturn` of a `Unit`-returning suspend function's return or early return; the value is discarded by the caller, so replacing it with `null` is unobservable by construction. +Further experimentation required: no. + +**Compiler guards — 2 identities — `EQUIVALENT`.** +`ApprovalSuspensionCoordinator:160` (`checkNotNull` on `expiresAt`) and `:176` (`checkNotNullExpressionValue` on `clock.instant()`). Both are compiler-inserted guards on Java-platform values; with the guard removed and a null flowing, the enclosing `checkNotNullParameter` still throws before any state is created, so original and mutant fail fast at the same point and differ only in a compiler-generated message. That message is not part of the contract, which is why no message-contract test exists or should be added. +Further experimentation required: no. Recorded nuance: this is equivalence *for contract-observable behaviour*, not message-level identity. + +**G1 — 49 identities — `UNDETERMINED`.** +`NullReturnVals` at non-suspension sites. A replaced return value is observable exactly when the caller consumes it; that has not been established per site. The 8 identities above are the proven-unobservable subset inside this family; the remainder require per-site reading of the enclosing function's contract and its callers. +Required evidence: per-site result-usage analysis; one representative experiment would bound the family. + +**G2 — 43 identities — `UNDETERMINED`.** +Removed `ResultKt::throwOnFailure` at suspension points. Removing an exception rethrow is observable when the suspend call can return a `Failure` and the caller observes the difference; whether that path is reachable for each site has not been established. +Required evidence: reachability of a failing suspend call per site, then the family experiment. + +**G4 — 20 identities — `UNDETERMINED`, of which 9 are `KILLABLE` candidates.** +Removed calls that are not at suspension points are *source-level* calls, so the mutation removes real behaviour. The 9 `SURVIVED` rows in this group are the strongest `KILLABLE` candidates in the residual: they have covering tests that do not observe the missing call. +Required evidence: per-site reading of what the removed call guarantees; this is where a missing assertion would most plausibly be found, and finding one is **not** authorised by this record (§6). + +**G5 — 12 identities — `UNDETERMINED`.** Same question as G1 at suspension points. + +**G6 — 5 identities — 1 `DEFERRED_STRUCTURAL`, 4 `UNDETERMINED`.** +`ApprovalResumeCoordinator.kt:102` b76.i533 stays **`DEFERRED_STRUCTURAL`** exactly as the predecessor record left it: the structured-parse uncertain reason must name the failure type, and treating it requires the drifted-file formatter/baseline decision that was deliberately rejected for this tranche. It is not folded into this slice and its disposition is unchanged. +The other four are negated source-level conditionals that do not time out; they are `KILLABLE` candidates pending per-site analysis. + +## 5. Why `KILLABLE` and `UNREACHABLE` are empty + +Not because they cannot apply, but because nothing in hand establishes them: + +- `KILLABLE` requires a *concrete* missing behavioural assertion. Group G4 has nine plausible candidates; naming one is an act of diagnosis that leads directly to a test, and this slice is explicitly not a remediation slice. +- `UNREACHABLE` requires an argument that production structure makes the mutated path unreachable under valid inputs. The residual's sites are reachable in principle; what is missing is observability evidence, which is a different claim. + +Recording them as zero with the reason is stronger than assigning either to fill the cell. + +## 6. What this record does not authorise + +- **No enrollment.** Nothing here is written into `config/quality/mutation-classifications.yml`. Classification of candidate-only identities still requires the M22–M29 ceremony against a governing base, and the g1G0 wall is unchanged. +- **No kills.** No test was added and no survivor was fixed. If a later slice acts on a `KILLABLE` candidate it must preserve identity census, regression accounting, provenance, and a positive control. +- **No reclassification of the 118 `UNDETERMINED` rows by inference** from their neighbours, their family, or the one demonstrated `TOOLING_LIMITATION` representative. +- **No gate change** of any kind, and no deviation-ceiling change. +- **`ApprovalResumeCoordinator:102` stays deferred** until the taxonomy produces evidence specific to it. + +## 7. Reproduction + +1. Recover the cohort by joining `config/quality/mutation-baseline.json`-derived campaigns and the g1G0 partition buckets on the canonical identity (16-character prefix join; never join on `(file, line, block, index)` — that tuple collides across mutators). +2. Treat an identity as closed only if a newer measurement says `KILLED`. +3. Re-census in a throwaway worktree: narrow `mutation.targetFamilies` to the family owning the residual (all 177 are `approval`), commit the narrowing, run `generateCriticalMutationBaseline`, then confirm `measuredCommit` equals the probe commit and prove provenance with `git diff --name-only ` — it must list only the throwaway config file. +4. Group with `javap -p -c -l` per site: a suspend call is an instruction whose descriptor ends `Continuation;)Ljava/lang/Object;`; a sentinel comparison is a line carrying `getCOROUTINE_SUSPENDED`, `if_acmpne` or `if_acmpeq`. Line attribution shifts instructions to neighbouring lines — verify a representative before trusting any per-line split. +5. Re-derive the accounting equation and assert `lost = 0`, `duplicate = 0`, `unexplained movement = 0` before any admission step. + +## 8. Erratum — superseded grouping (added by `TASK-0.7.1g1G4`) + +This section corrects two facts in §3. The original text above is preserved as written; the split it records is +**superseded for reasoning**, exactly as §7 step 4 anticipated. + +### 8.1 The `VoidMethodCall` sub-populations (§3, groups G2 and G4) + +| Old, PIT-line-attributed | Instruction-exact inspection | +|---|---| +| G2 `SUSPENSION_POINT_RETHROW` — 43 | **59** compiler-generated `kotlin/ResultKt::throwOnFailure` removals | +| G4 `OTHER_VOID_CALL` — 20 | **2** source-level removed calls | + +The grouping was derived from PIT's source-line attribution. `javap -p -c` shows a single source line can carry +both the compiler-generated suspension machinery and ordinary calls from the same expression, so a per-line +split measures attribution, not instruction shape. The 61 `VoidMethodCall` identities of the residual are +therefore 59 compiler-generated rethrows at a suspension point and 2 genuine source-level calls; the G2/G4 +counts as printed must not be used as a mechanism split. + +### 8.2 Mutator name + +`NullReturnVals` is `org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator` (full +package-qualified name). Identity digests are computed from the fully qualified mutator name; the short form in +§3 is display-only. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G3-POPULATION-ADMISSION-CEREMONY.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G3-POPULATION-ADMISSION-CEREMONY.md new file mode 100644 index 000000000..a466b8d7a --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G3-POPULATION-ADMISSION-CEREMONY.md @@ -0,0 +1,132 @@ +# TASK-0.7.1g1G3 — Appearing Population Admission Ceremony + +**Status:** CEREMONY IMPLEMENTED; NOTHING ADMITTED OR PREAUTHORIZED +**Base:** Epic `35d5bcdf97cb6d05c3915e6472cc0b11b216d8bb` (post-#448) +**Rules:** M30–M39, enforced by `verifyMutationRatchet` (never by the maintainability baseline) +**Change class:** `build-logic` — the repository-defined class for analyzer/tooling and analyzer-adjacent quality machinery (`ChangePolicyEvaluator`). The new admission ledger is authority configuration, but it is **not** a canonical baseline path under `ChangePolicyEvaluator.isBaselinePath()`, so this slice is not a baseline migration and the admission ceremony is not an analyzer-semantic change. + +## 1. The boundary this closes + +M06 forbids a new `NON_KILLED` identity from appearing in a candidate measurement: a PR may not +certify its own survivor. That rule is correct, and it is also why an adjudicated survivor that is +**absent from the committed population** could never be admitted — admission would always look +exactly like the thing M06 exists to stop. + +The two authorities are deliberately distinct: + +| | Classification enrollment | Population admission | +|---|---|---| +| rules | M22–M29 | **M30–M39** | +| ledger | `config/quality/mutation-classification-enrollments.yml` | **`config/quality/mutation-population-admissions.yml`** | +| subject | an identity *already in* the base population | an identity *absent from* the base population that will **appear** | +| target rule | M25: the target must exist and be `NON_KILLED` in the base | **absence is the expected state and is never invalid** | + +``` +P1 — MINT base: X absent from the committed population, absent from the ledger + candidate: population unchanged, proposes an authorization for X +P2 — CONSUME base: the authorization for X present and byte-identical + candidate: the exact authorized row appears as NON_KILLED, and the authorization + is consumed (removed) in the same transition +``` + +## 2. Binding semantics + +| Binding | Meaning | When enforced | +|---|---|---| +| `identity` | canonical 64-hex schema v2 identity — the only key | mint and consume | +| `status`, `outcome`, `family`, `module` | the exact persisted row: no status laundering, no outcome substitution, no re-homing | consume (M32) | +| `analyzer` | the semantics the authorization is valid under | consume (M33) | +| `populationDigest` | the projection hash of the **complete canonical fresh population** the admission is part of | consume (M34) | +| `fromBaseSha` | the exact authority base the authorization was first proposed against | **mint only** (M35), immutable thereafter | +| `reason`, `issue`, `targetPhase` | the recorded adjudication rationale | mint and while pending (M36) | +| `authorizedBy`, `authorizedAt` | **audit only — no enforcement value** | never | + +**`fromBaseSha` is deliberately not compared at consumption.** The verifier sees only base vs +candidate populations and has no git ancestry or lineage knowledge, and a pending authorization +must legitimately survive intermediate merges. It is enforced on introduction (an old +authorization payload cannot be replayed onto a different authority base) and then required to +remain byte-identical. The security of consumption comes from base-side existence, immutability, +the exact row, the exact analyzer semantics and the trusted measurement digest — not from the SHA. + +**`populationDigest` binds the whole transition, not one identity.** This is what "binds the +neighbours" means: if any other identity in the population moves between minting and consumption, +the digest no longer matches, the authorization cannot be consumed, and it must be re-minted against +the new measurement. The hash compared at consumption is the verifier's **own** fresh measurement +(`MutationEvolutionEvidence.proof.projectionHash`), never a value read from the ledger, so a +candidate can never construct the evidence that authorizes itself (§6.2). At mint the digest is a +commitment and has no authority: an incorrect digest simply produces an authorization that can +never be consumed. When no trusted measurement proof exists at all, admission **fails closed**. + +## 3. Rule table + +| # | Scenario | Expected | Rule | +|---|---|---|---| +| 1 | base-authorized exact appearing row, row identical, consumed | PASS | M30 | +| 2 | same-transition authorization + population addition | FAIL | M31 | +| 3 | no base authorization + new NON_KILLED identity | FAIL | **M06, unchanged** | +| 4 | authorization by identity only, row changed | FAIL | M32 | +| 5 | raw status changed from the authorized value | FAIL | M32 | +| 6 | canonical outcome changed | FAIL | M32 | +| 7 | authorized row absent from the fresh measurement | never admitted, never silently satisfied | M37 | +| 8 | authorized X **plus** unauthorized NON_KILLED Y | **FAIL for Y** | M06 + M30 | +| 9 | new KILLED identity | PASS | M07 | +| 10 | base-only disappearance without an M21 record | FAIL | M21 | +| 11 | valid M21 removal + valid admission in one transition | PASS (compositional) | M21 + M30 | +| 12 | candidate population ≠ the exact fresh measurement | FAIL | M21 proof | +| 13 | authorization retained after consumption | FAIL | M38 | +| 14 | authorization rewritten before consumption | FAIL | M36 | +| 15 | authorized identity becomes KILLED before consumption | cleanable, never forced | M37 exception / M39 advisory | +| 16 | every passing transition usable as the next base | PASS by construction | invariant test | + +Family and module substitution (rows 4–6) is additionally unreachable by construction: the +canonical identity is computed over family and module, so a different family or module is a +different identity, and a *forged* row is already rejected by the M20 row self-validation. Those +clauses of M32 are defence in depth, not the load-bearing check. + +## 4. Loader vs ceremony + +The loader (`MutationPopulationAdmissionLoader`) establishes **structural** validity only: +canonical identity, commit-shaped `fromBaseSha`, 64-hex digest, analyzer fields structurally +complete (non-blank plugin/engine, non-empty mutators, positive `timeoutConst`), required fields +present, duplicates rejected. It deliberately does **not** try to establish that a digest +corresponds to a real campaign — that is the verifier's job at consumption, against its own +measurement. An absent ledger is `NONE`, the most restrictive state; a malformed one is a hard +failure, never a silent degrade to "no authorizations". + +## 5. Sequencing consequence (mandatory order) + +The digest binds the complete future population, so admission eligibility and admission readiness +are different states: + +``` +58 = admission-eligible by adjudication (12 EQUIVALENT + 46 TOOLING_LIMITATION) +58 ≠ ready for P1 population authorization +``` + +Authorizing them now, against today's digest `D0`, and then resolving even one of the 118 +undetermined identities would change the projection to `D1` and render all 58 unusable — correctly, +because that is what binding the neighbours means. The required order is therefore: + +``` +g1G3 mechanism → resolve the 118 → take the final canonical fresh measurement + → P1 authorize every remaining admission against THAT digest → merge P1 + → P2 exact population transition + M21 + RECORDED_EVOLUTION → freeze +``` + +No test, configuration or ledger entry may be added to the empty admissions list before that +sequence reaches its P1 step. + +## 6. Endpoint of this slice + +| Required | State | +|---|---| +| mechanism implemented | YES | +| M30+ discriminator suite | as recorded in `MutationPopulationAdmissionCeremonyTest` | +| unauthorized Y still fails M06 | proven (`findingId == Y`) | +| same-transition self-authorization impossible | proven (M31) | +| row / analyzer / digest substitution impossible | proven (M32/M33/M34) | +| single-use lifecycle | proven (M37/M38) | +| next-base invariant | proven | +| `population` admissions written | **0** | +| preauthorizations written | **0** | +| baseline / M21 / production changes | **0** | diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json new file mode 100644 index 000000000..59af7602d --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json @@ -0,0 +1,262 @@ +{ + "schemaVersion": 1, + "task": "TASK-0.7.1g1G4 (increments g1G4f and g1G4g)", + "purpose": "evidence only - NOT mutation authority: no classification, admission, baseline, timeout or gate authority is granted by this file", + "base": "16f4cbadad4205a8917fdf84d8e4309e781b7706", + "parentManifest": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "residualDigest": "6b1e6e22472013a27f112a10e7c8fc4d46913c4ce1133df43182cab077fded10", + "residualCount": 11, + "baseCensus": { + "SURVIVED": 11 + }, + "adjudication": { + "killed": 8, + "equivalent": 3, + "unreachable": 0, + "toolingLimitation": 0, + "undetermined": 0 + }, + "identities": [ + { + "identity": "63e5372ba37bc3b097def60c22bddd050404d168b26c2fa73bc94a99a6705c1e", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 55, + "index": 292, + "pc": 531, + "case": 4, + "controlStatus": "SURVIVED", + "candidateStatus": "SURVIVED", + "killingTest": null, + "disposition": "EQUIVALENT", + "numberOfTestsRun": "5", + "proof": "EQUIVALENT: forwarded to resume case-2 check pc160 (ceba69cd43), which precedes the checkcast at pc165", + "campaign": "g1G4f" + }, + { + "identity": "5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 56, + "index": 395, + "pc": 701, + "case": 5, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "ApprovalResumeSuspensionContractTest.a ordinary executor failure after suspension inside the governed resume reaches the caller", + "disposition": "KILLED", + "numberOfTestsRun": "1", + "proof": "KILLED: governed withContext branch selected (observedIdentity != null); executor entered, suspended and resumed; ordinary RuntimeException delivered as a resume value to case 5 and propagated to the public caller", + "campaign": "g1G4g" + }, + { + "identity": "ef13cbec0d70fb8ec493cc6ed0709275f5ea9d9d73b2a661ef9c4d782f531af3", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 69, + "index": 501, + "pc": 879, + "case": 6, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "dev.tramai.engine.approval.ApprovalResumeSuspensionContractTest.[engine:junit-jupiter]/[class:dev.tramai.engine.approval.ApprovalResumeSuspensionContractTest]/[method:a cancellation resumed into the nested-approval uncertain path is not replaced by the primary failure()]", + "disposition": "KILLED", + "numberOfTestsRun": "2", + "campaign": "g1G4f" + }, + { + "identity": "ecb1e83af59393d1b5232a58ff15d691a148b163c854160584245a09ca6b31a5", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 87, + "index": 617, + "pc": 1079, + "case": 7, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "dev.tramai.engine.approval.ApprovalResumeSuspensionContractTest.[engine:junit-jupiter]/[class:dev.tramai.engine.approval.ApprovalResumeSuspensionContractTest]/[method:a cancellation resumed into the structured-parse uncertain path is not replaced by the primary failure()]", + "disposition": "KILLED", + "numberOfTestsRun": "1", + "campaign": "g1G4f" + }, + { + "identity": "9026b993fd997eb85a94ed6bde3b111ec4e411a59c6362b6fb0af2e99bca27bf", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 107, + "index": 746, + "pc": 1292, + "case": 8, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "dev.tramai.engine.approval.ApprovalResumeSuspensionContractTest.[engine:junit-jupiter]/[class:dev.tramai.engine.approval.ApprovalResumeSuspensionContractTest]/[method:a cancellation resumed into the generic resume-failure uncertain path is not replaced()]", + "disposition": "KILLED", + "numberOfTestsRun": "1", + "campaign": "g1G4f" + }, + { + "identity": "62911071da6c7a9fe85f76a41c5a23d56b1156e2d1447dd96ccce962aabeff3b", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 33, + "index": 205, + "pc": 386, + "case": 2, + "controlStatus": "SURVIVED", + "candidateStatus": "SURVIVED", + "killingTest": null, + "disposition": "EQUIVALENT", + "numberOfTestsRun": "1", + "proof": "EQUIVALENT: the frame's only child is emitResumeUncertainOutcomeOnce, whose only escape is CancellationException (it catches Exception); removal is measured unobservable for the cancellation that can arrive", + "campaign": "g1G4f" + }, + { + "identity": "492f9a132e141f512c5690d8b0d43c6e5925b488de13d80ef71504bcf61b62d4", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 7, + "index": 40, + "pc": 75, + "case": 1, + "controlStatus": "SURVIVED", + "candidateStatus": "SURVIVED", + "killingTest": null, + "disposition": "EQUIVALENT", + "numberOfTestsRun": "2", + "campaign": "g1G4f" + }, + { + "identity": "887313eb0142a0add13da39b7d198c8bcc351ac8cd2dd1639028d47d7a2fd38e", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 22, + "index": 196, + "pc": 390, + "case": 2, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest.[engine:junit-jupiter]/[class:dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest]/[method:a cancellation delivered to the second compensation action after suspension reaches the caller()]", + "disposition": "KILLED", + "numberOfTestsRun": "2", + "campaign": "g1G4f" + }, + { + "identity": "4fc586ff85c68cfdeca566abb58fcfc7986945efb5540844990d16a75ed127bb", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 32, + "index": 271, + "pc": 538, + "case": 3, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest.[engine:junit-jupiter]/[class:dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest]/[method:a cancellation delivered to the third compensation action after suspension reaches the caller()]", + "disposition": "KILLED", + "numberOfTestsRun": "3", + "campaign": "g1G4f" + }, + { + "identity": "1c4ca901fd8d56ace18e167d294242db6cfdd8a6f551c7860dfaa934ea26ce54", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 6, + "index": 37, + "pc": 70, + "case": 1, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest.[engine:junit-jupiter]/[class:dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest]/[method:a cancellation delivered to the second compensation action after suspension reaches the caller()]", + "disposition": "KILLED", + "numberOfTestsRun": "2", + "campaign": "g1G4f" + }, + { + "identity": "a15f9807befc68bd1e00747e77a09e147656195cac0833b68904cb326ccf0a5b", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 6, + "index": 37, + "pc": 69, + "case": 1, + "controlStatus": "SURVIVED", + "candidateStatus": "KILLED", + "killingTest": "dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest.[engine:junit-jupiter]/[class:dev.tramai.engine.approval.ApprovalSuspensionSagaContractTest]/[method:a cancellation delivered to the third compensation action after suspension reaches the caller()]", + "disposition": "KILLED", + "numberOfTestsRun": "3", + "campaign": "g1G4f" + } + ], + "provenance": "Two certified campaigns, not one. The ten identities other than 5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930 are certified by the g1G4f campaign: control 16f4cbadad4205a8917fdf84d8e4309e781b7706 / e20cc29b, certified candidate at test commit 382ec7146b1f92683c8e84970c5ef6ed286a88ff / f1a4ba66. 5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930 is certified separately by the g1G4g campaign: control 3e32020797ec1dcff037f140a1f674f582ab573c / f118383f (the post-#458 epic tip), certified candidate at test commit e04037bf06d62dd16ad0c5254cfd54d447411251 / e745d320, where it moved SURVIVED -> KILLED with numberOfTestsRun = 1. The g1G4g campaign supersedes ONLY the status and disposition evidence for 5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930; it does not supersede, replace or re-measure the prior certification of the other ten identities. The top-level `base` field records the frozen residual-11 cohort's original base and remains historically legitimate as the cohort's provenance.", + "secondWave": { + "commits": [ + "e110c1dc4a47d7981c24333c3d726f3c3372a54c", + "271b34f1e0e6ab4302a83bd07d51b705740ea2dd" + ], + "result": "3 durable tests pass; 0 new kills; cancellation cannot discriminate a state-machine check because the coroutine machinery delivers it out-of-band" + }, + "parentClosed": "TASK-0.7.1g1G4 closed at 11/11 settled (8 KILLED, 3 EQUIVALENT, 0 UNDETERMINED)", + "campaigns": { + "g1G4f": { + "scope": "the ten identities other than 5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930", + "control": { + "commit": "16f4cbadad4205a8917fdf84d8e4309e781b7706", + "narrowing": "e20cc29b" + }, + "certifiedCandidate": { + "testCommit": "382ec7146b1f92683c8e84970c5ef6ed286a88ff", + "narrowing": "f1a4ba66" + }, + "testCommitHistory": [ + "7e5c055369898d3bc163955fd029eb1127c93d9b", + "324b256cfce379b5451e9ce9b7d95218dbe9d218", + "24de7e9770b5a09299e102cb9e5a7a4faa9027b8", + "382ec7146b1f92683c8e84970c5ef6ed286a88ff" + ], + "supersedes": "324b256cfce379b5451e9ce9b7d95218dbe9d218 / 04c0a936: two compensation tests were silently skipped there and it is not citable" + }, + "g1G4g": { + "scope": "5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930", + "control": { + "commit": "3e32020797ec1dcff037f140a1f674f582ab573c", + "narrowing": "f118383f", + "note": "post-#458 epic tip" + }, + "certifiedCandidate": { + "testCommit": "e04037bf06d62dd16ad0c5254cfd54d447411251", + "narrowing": "e745d320" + }, + "transition": "SURVIVED -> KILLED, numberOfTestsRun = 1" + } + }, + "testCommitHistory": [ + "7e5c055369898d3bc163955fd029eb1127c93d9b", + "324b256cfce379b5451e9ce9b7d95218dbe9d218", + "24de7e9770b5a09299e102cb9e5a7a4faa9027b8", + "382ec7146b1f92683c8e84970c5ef6ed286a88ff", + "e110c1dc4a47d7981c24333c3d726f3c3372a54c", + "271b34f1e0e6ab4302a83bd07d51b705740ea2dd", + "e04037bf06d62dd16ad0c5254cfd54d447411251" + ] +} diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json new file mode 100644 index 000000000..2267555c2 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json @@ -0,0 +1,358 @@ +{ + "schemaVersion": 1, + "task": "TASK-0.7.1g1G4e", + "purpose": "adjudication evidence only - NOT mutation authority: no classification, admission, baseline, timeout or gate authority is granted by this file", + "base": "36923eeee79145e63281e6378b114404e2cddba5", + "testCommit": "29ead0226fcfbdb59cce0ecdea279f7e51a74c3f", + "parentManifest": "TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json", + "parentManifestDigest": "0c2d107def8f30c564e648c4a86a11049c4a7cef4bfed5c78e07d3f95b9c77ea", + "derivation": "original 118 minus every identity firmly disposed by g1G4a, g1G4b, g1G4c and g1G4d, verified by digest", + "residualDigest": "4cd9b668d37657b42e11c4d36836be68ddb36916cb4b27aaaa4e015bacaa8f5c", + "digestAlgorithm": "sha256 of the sorted canonical identities joined by newline with a trailing newline", + "residualCount": 25, + "baseCensus": { + "SURVIVED": 25, + "note": "one base KILL was reported for eec481d97a by the broader control run and did not reproduce in the single-class run at the same commit (remediation doc 12.2/12.13)" + }, + "identities": [ + { + "identity": "63e5372ba37bc3b097def60c22bddd050404d168b26c2fa73bc94a99a6705c1e", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 55, + "index": 292, + "line": 144, + "pc": 531, + "case": 4, + "disposition": "UNDETERMINED" + }, + { + "identity": "ab78ed8577ff2073f4690730365919999ad7f76ddb68bef5c56a08deeef15c31", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 7, + "index": 44, + "line": 119, + "pc": 106, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 56, + "index": 395, + "line": 64, + "pc": 879, + "case": 6, + "disposition": "UNDETERMINED" + }, + { + "identity": "ef13cbec0d70fb8ec493cc6ed0709275f5ea9d9d73b2a661ef9c4d782f531af3", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 69, + "index": 501, + "line": 64, + "pc": 1079, + "case": 7, + "disposition": "UNDETERMINED" + }, + { + "identity": "ecb1e83af59393d1b5232a58ff15d691a148b163c854160584245a09ca6b31a5", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 87, + "index": 617, + "line": 64, + "pc": 1292, + "case": 8, + "disposition": "UNDETERMINED" + }, + { + "identity": "9026b993fd997eb85a94ed6bde3b111ec4e411a59c6362b6fb0af2e99bca27bf", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 107, + "index": 746, + "line": 64, + "pc": null, + "case": null, + "disposition": "UNDETERMINED" + }, + { + "identity": "62911071da6c7a9fe85f76a41c5a23d56b1156e2d1447dd96ccce962aabeff3b", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 33, + "index": 205, + "line": 212, + "pc": 386, + "case": 2, + "disposition": "UNDETERMINED" + }, + { + "identity": "e0ae69141eabff4855f7acc9e498139795c71ae3aca5a096d468101f3ceca1f0", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 2, + "index": 12, + "line": 85, + "pc": 33, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "492f9a132e141f512c5690d8b0d43c6e5925b488de13d80ef71504bcf61b62d4", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 7, + "index": 40, + "line": 85, + "pc": 75, + "case": 1, + "disposition": "UNDETERMINED" + }, + { + "identity": "60f7b020e009cc99a8a80e19e34a956e570614449294444db057993e433b1ac6", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 7, + "index": 44, + "line": 299, + "pc": 90, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "f60db12bdb9ea3f1b61d5c01867a18b9ebe7f2a8726a8287f8129dad183a4625", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 7, + "index": 44, + "line": 286, + "pc": 102, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "887313eb0142a0add13da39b7d198c8bcc351ac8cd2dd1639028d47d7a2fd38e", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 22, + "index": 196, + "line": 286, + "pc": 390, + "case": 2, + "disposition": "UNDETERMINED" + }, + { + "identity": "4fc586ff85c68cfdeca566abb58fcfc7986945efb5540844990d16a75ed127bb", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 32, + "index": 271, + "line": 286, + "pc": 538, + "case": 3, + "disposition": "UNDETERMINED" + }, + { + "identity": "ebc96b3e8599f671eb60c470b2a259a474aa994c2599547731b9944800e60b57", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 2, + "index": 12, + "line": 293, + "pc": 33, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "f802165800d8737a082118c89e9c5eae4e7d8470cd15c54c57bc19476be9a15c", + "group": "NullReturnVals", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 9, + "index": 43, + "line": 293, + "pc": null, + "case": null, + "disposition": "EQUIVALENT (discarded lambda result)" + }, + { + "identity": "6cef3fd2e23d3df1571d390564b8274596c70c97965bc5719603d39116b74b8c", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 2, + "index": 12, + "line": 294, + "pc": 33, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "1c4ca901fd8d56ace18e167d294242db6cfdd8a6f551c7860dfaa934ea26ce54", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 6, + "index": 37, + "line": 294, + "pc": 70, + "case": 1, + "disposition": "UNDETERMINED" + }, + { + "identity": "f175e8698db5459d85e11e56b04155c7b963d148d906f57908c6440dd533fead", + "group": "NullReturnVals", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 8, + "index": 44, + "line": 294, + "pc": null, + "case": null, + "disposition": "EQUIVALENT (discarded lambda result)" + }, + { + "identity": "eec481d97a434f85884bdc18877d8a54801c8ba289658e0fb49d92e286cad2f7", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 2, + "index": 12, + "line": 295, + "pc": 33, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "a15f9807befc68bd1e00747e77a09e147656195cac0833b68904cb326ccf0a5b", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 6, + "index": 37, + "line": 295, + "pc": 69, + "case": 1, + "disposition": "UNDETERMINED" + }, + { + "identity": "70d345f72064e7cea4ebb9cff97f74946f00d582ba38a4e62c5498261494b0a0", + "group": "NullReturnVals", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "block": 8, + "index": 44, + "line": 295, + "pc": null, + "case": null, + "disposition": "EQUIVALENT (discarded lambda result)" + }, + { + "identity": "d5b70d305cec04c7aca39a5a9c629221c88509f4a8ee07d9643b2f8e37d1277f", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 7, + "index": 44, + "line": 244, + "pc": 98, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "75ec22b0e703bb8a54ddbde9d6edab92e1e3fbbff3318021b9a6d11eccca21ec", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 33, + "index": 197, + "line": 244, + "pc": 332, + "case": 3, + "disposition": "KILLED" + }, + { + "identity": "098258d70a2379a58e39d24f5f85824705998bb6d3e20cf0bec54ffecc4db0a6", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 7, + "index": 44, + "line": 222, + "pc": 97, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + }, + { + "identity": "eb8827c4f4ca1d6b1f90686e5335ffb189d78b4694548a6843f7ebc5d1d95a79", + "group": "VoidMethodCall", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "block": 7, + "index": 44, + "line": 201, + "pc": 90, + "case": 0, + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "adjudication": { + "killed": 1, + "equivalent": 13, + "unreachable": 0, + "toolingLimitation": 0, + "undetermined": 11, + "alreadyDisposedAtBase": 0, + "note": "evidence only: no classification, admission or baseline authority is granted by this file" + }, + "reproductionRuns": { + "control": "36923eee", + "singleClass": "36923eee", + "note": "same commit; eec481d97a KILLED in the control narrowing and SURVIVED in the single-class narrowing" + } +} diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-REMEDIATION.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-REMEDIATION.md new file mode 100644 index 000000000..01972a892 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-REMEDIATION.md @@ -0,0 +1,1395 @@ +# TASK-0.7.1g1G4 — Residual Mutation Remediation and Final Adjudication + +**Status:** `ACTIVE`. Increment 1 (g1G4a) closed 2 of 118 by measured kills; increment 2 (g1G4b, §9) closed 23 +of the 27 `DefaultApprovalGateway` identities (21 by measured kills, 2 by instruction-level unreachability), leaving +**93 of the original 118 `UNDETERMINED`** (`ApprovalResumeCoordinator` 50, `ApprovalSuspensionCoordinator` 39, +`DefaultApprovalGateway` 4). g1G4a's measurements in §5 are preserved as the historical record of that increment and +are not restated with newer values. This record does **not** complete g1G4: the parent objective is the final +identity-exact adjudication of the whole residual cohort, and the roadmap must not mark g1G4 complete while any +identity lacks a supported final disposition. +**Branch:** `task/0.7.1g1g4-residual-remediation` +**Exact base:** `80d6847f7ef639fc830f3edcaedb988d8b1d9d20` (Epic tip, verified before branching) +**Working tree at start:** clean (`git status --porcelain` empty) +**Scope:** the 118 `UNDETERMINED` identities left by `TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md`, remediated by test +and adjudicated by measurement rather than by inference. +**Does not authorise:** any mutation-population admission, any `config/quality/mutation-classifications.yml` +write, any M21 population transition, any canonical `config/quality/mutation-baseline.json` update, any PIT policy +change, and any production change made to raise a mutation score. See §7. + +## 1. Phase A — Custody of the 118 (frozen before any test was written) + +The 118 are a **semantic disposition cohort**, not a status predicate over the raw census: they are the +g1G2 residual minus its firm dispositions. The raw census cannot reconstruct them, because the 8 Unit-return +`NullReturnVals` rows, the 2 compiler-guard `VoidMethodCall` rows and the deferred `NegateConditionals` identity +were adjudications layered on top of the measurement. + +| Quantity | Value | +|---|---| +| g1G2 residual | 177 | +| `EQUIVALENT` | 12 | +| `TOOLING_LIMITATION` | 46 | +| `DEFERRED_STRUCTURAL` | 1 | +| **`UNDETERMINED` (this slice's input)** | **118** | + +Decomposition of the 118, and the class distribution that follows from it: + +| Group | Count | Sites | +|---|---|---| +| `NullReturnVals` | 53 | `ApprovalResumeCoordinator.kt` 22, `ApprovalSuspensionCoordinator.kt` 18, `DefaultApprovalGateway.kt` 13 | +| `VoidMethodCall` | 61 | `ApprovalResumeCoordinator.kt` 26, `ApprovalSuspensionCoordinator.kt` 19, `DefaultApprovalGateway.kt` 16 | +| `NegateConditionals` | 4 | `ApprovalResumeCoordinator.kt` 2, `ApprovalSuspensionCoordinator.kt` 2 | +| **Total** | **118** | three files, all in `:tramai-engine` | + +All 118 are `NON_KILLED` in the frozen census: 101 `NO_COVERAGE` and 17 `SURVIVED`. + +### 1.1 Committed manifest and digests + +The canonical identities are committed as +[`TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json`](./TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json) +(identity, group, mutator, class, method, descriptor, module, source file, line, block, index, census bucket +and census status), with a deterministic reconstruction procedure in the file itself. Raw PIT XML stays outside +the repository by existing convention; the identity manifest does not. + +| Digest (SHA-256) | Value | +|---|---| +| UNDETERMINED 118 | `0c2d107def8f30c564e648c4a86a11049c4a7cef4bfed5c78e07d3f95b9c77ea` | +| `NullReturnVals` 53 | `7a94709d497890ed2d65ca31f9fd4a7cf254e5ad6823d767f9a1895efc40c2be` | +| `VoidMethodCall` 61 | `c4dbb18ae4818bf7831d030872561242101236568eaee44b87d1c4da9f1102b1` | +| `NegateConditionals` 4 | `adece18ff4747d464817aa65bfd487900a33687c31edc4c7b7078e0ef958c828` | + +Digest algorithm: sort the identities lexicographically, join with `\n`, append a trailing `\n`, SHA-256. + +### 1.2 Custody ledger + +``` +input residual 177 + EQUIVALENT 12 (8 Unit-return NullReturnVals + 2 compiler guards + 2 ApprovalRunAttribution negations) + TOOLING_LIMITATION 46 (all NegateConditionals, timed_out bucket) + DEFERRED_STRUCTURAL 1 (ApprovalResumeCoordinator.kt block 76 / index 533 = line 102) + UNDETERMINED 118 + ------------------------------ + sum 177 accounted 177/177 +118 = 53 NullReturnVals + 61 VoidMethodCall + 4 NegateConditionals +subset of the residual: yes duplicates: 0 lost: 0 unexplained: 0 +none in TOOLING_LIMITATION: yes none in EQUIVALENT: yes line 102 excluded: yes +``` + +## 2. Phase B — g1G2 erratum (explicit, not silent) + +Recorded as `## 8. Erratum` in `TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md`, preserving the original §3 text. Two +corrections: the `VoidMethodCall` sub-population split, and the mutator's full name. + +| Old (PIT line attribution) | Instruction-exact supersession | +|---|---| +| 43 `SUSPENSION_POINT_RETHROW` | 59 compiler-generated `kotlin/ResultKt::throwOnFailure` removals | +| 20 `OTHER_VOID_CALL` | 2 source-level removed calls | +| `NullReturnValsMutator` | `org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator` | + +The old grouping was produced by attributing instructions to source lines: a line can carry both the +compiler-generated suspension machinery and ordinary calls from the same expression. The merged record's own +§7 step 4 already warned about this; the split in §3 is superseded for reasoning, the method caveat stands. + +## 3. Phase C — the two harness patterns, productionised + +The throwaway probe worktrees proved two mechanisms and were discarded. The patterns — not the probe files — +are now durable tests in `tramai-engine`: + +### 3.1 What a synchronous fake hides + +Every collaborator wired in the engine's own approval tests is in-memory and completes without suspending. The +generated code around a suspend call is then never taken: + +``` +val r = approvalStore.get(id, this) // nested suspend call +if (r === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED // suspension protocol +... on the resume path: ResultKt.throwOnFailure(result) // failure protocol +``` + +Two mutators target exactly those instructions, and each requires a different test obligation: + +| Mutator instruction | Obligation to distinguish it | +|---|---| +| `areturn` of the suspension sentinel replaced by `null` (`NullReturnVals`) | suspend genuinely, resume successfully, observe the returned value | +| removal of `ResultKt::throwOnFailure` on the resume path (`VoidMethodCall`) | suspend genuinely, resume **exceptionally**, observe the propagated failure | + +### 3.2 Durable tests added + +`DefaultApprovalGatewayTest` gains one delegating store and two behavioural tests (test 11), reusing the file's +existing harness rather than a new fixture: + +- `SuspendingApprovalStore` — `ApprovalStore by delegate`, overriding `get` to `delay(1)` before delegating, + with an optional `failAfterResume` failure. +- `requestApproval resumes across a genuinely suspending approval store get` — asserts the resumed value + (`Suspended`, approval id, store state) with `withTimeout(2_000)`, below PIT's `timeoutConstInMillis`. +- `a failure that resumes a genuinely suspending approval store get reaches the caller unchanged` — asserts the + store's own failure reaches the caller unwrapped (type and message), and that a failed resume leaves no + durable trace (approval, continuation, suspension all empty). + +The second test is not a stylistic variant of the first: the same exact mutant moved +`NO_COVERAGE → SURVIVED` under a successful-resume harness and `NO_COVERAGE → KILLED` under an +exceptional-resume harness. That progression is the causal evidence that the missing obligation was the failure +protocol, not reachability. + +## 4. Phase D — Measurement + +Measurement scaffolding (a `mutation.targetFamilies` narrowing) is **not** part of this branch: it is applied in a +throwaway worktree at the commit that carries the durable tests, and the measurement is joined against the +committed manifest. See §5. + +## 5. Identity-exact results (Phase D measured) + +Measurement provenance: throwaway worktree at the tests commit `4fc5c37c` plus one committed narrowing of +`mutation.targetFamilies` (measurement commit `39599f6a`, not part of this branch). Scope: the three classes +owning the cohort (`ApprovalResumeCoordinator*`, `ApprovalSuspensionCoordinator*`, `DefaultApprovalGateway*`, +`:tramai-engine`). 344 mutants, 5 m 29 s, identity census **336 shared with the frozen census, 0 lost, 8 new** +(additional generated classes matched by the wildcard, outside both the census and the cohort). + +Cohort movement, identity-exact: + +| | Count | +|---|---| +| `NO_COVERAGE` → `KILLED` | **2** | +| `NO_COVERAGE` → `NO_COVERAGE` | 99 | +| `SURVIVED` → `SURVIVED` | 17 | +| newly `TIMED_OUT` | 0 | +| remaining after this slice | **116** (52 `NullReturnVals`, 60 `VoidMethodCall`, 4 `NegateConditionals`) | + +The two settled identities, both closed by tests added in §3.2: + +| Identity | Mutator | Site | Before → after | +|---|---|---|---| +| `8b52a478cb33035b2af3c59f099290ab6fa8c3812888c33b9893160ce0fa8d22` | `NullReturnVals` | `DefaultApprovalGateway.kt:73`, block 36 / index 247 | `NO_COVERAGE` → `KILLED` (1 test) | +| `d77706fde05eb954a18854bdc62e90721dca630d7edd3b681c1ca599046c0eb9` | `VoidMethodCall` | `DefaultApprovalGateway.kt:73`, block 37 / index 279 (`ResultKt::throwOnFailure`) | `NO_COVERAGE` → `KILLED` (1 test) | + +Controls, all of which hold: + +- **regressions `KILLED` → non-`KILLED`: 0** (110 `KILLED` identities stayed `KILLED`); +- the 46 `TOOLING_LIMITATION` identities are still `TIMED_OUT` (46/46) — the existing adjudication is unchanged; +- the `DEFERRED_STRUCTURAL` identity (`ApprovalResumeCoordinator` block 76 / index 533) is untouched as + `SURVIVED` and is not part of the cohort; +- no identity was added or lost inside the census population. + +**`UNDETERMINED` is therefore 116, not 0.** The exit target of §1 is not met by this slice and no identity was +forced into a convenient category to meet it. + +## 5.1 Raw-status transition matrices (movement accounting) + +Movement is recorded in full, not only as a regression check: an unexpected `NO_COVERAGE → SURVIVED` would not be a +regression but would still belong in an identity-custody record. Both matrices cover the measurement in §5. + +All 344 measured identities (336 census-shared + 8 generated-class mutants new to this measurement): + +| Before | After | Count | +|---|---|---| +| `KILLED` | `KILLED` | 110 | +| `NO_COVERAGE` | `KILLED` | **2** | +| `NO_COVERAGE` | `NO_COVERAGE` | 126 | +| `SURVIVED` | `SURVIVED` | 51 | +| `TIMED_OUT` | `TIMED_OUT` | 55 | +| any other transition | — | **0** | + +Restricted to the 177 residual cohort (175 measured; see below): + +| Before | After | Count | +|---|---|---| +| `NO_COVERAGE` | `KILLED` | **2** | +| `NO_COVERAGE` | `NO_COVERAGE` | 99 | +| `SURVIVED` | `SURVIVED` | 28 | +| `TIMED_OUT` | `TIMED_OUT` | 46 | +| any other transition | — | **0** | + +The only movement in the residual cohort is the two kills of §5. Both are `NullReturnVals` / +`VoidMethodCall` on the same suspension point of `DefaultApprovalGateway.requestApproval`. + +Cohort-scope note: 2 of the 177 residual identities are **not** in this measurement — +`8e18b4b48968d65f…` and `d38ac52f2843448a…`, both `NegateConditionals` in +`ApprovalRunAttributionKt.decodeApprovalAttribution` (line 174). They are the two `EQUIVALENT` inlined +`Iterable.filter` negations adjudicated in `TASK-0.7.1g1G2-RESIDUAL-TAXONOMY.md` §3.2, they are **not** part of the +118, and the measurement scope deliberately covered the three classes that own cohort identities. They therefore +carry no transition this increment; their disposition is unchanged. + +The two matrices reconcile exactly: the 169 measured identities outside the residual cohort contribute +`KILLED`→`KILLED` 110, `NO_COVERAGE`→`NO_COVERAGE` 27, `SURVIVED`→`SURVIVED` 23 and `TIMED_OUT`→`TIMED_OUT` 9 +(110 + 27 + 23 + 9 = 169), so 175 + 169 = 344 with no transition left unaccounted for. + +## 5.2 What the remaining 116 need (grouped by production scenario, not by identity) + +| Remaining | Class | Methods (identities) | Required harness | +|---|---|---|---| +| 12 `NullReturnVals`, 15 `VoidMethodCall` | `DefaultApprovalGateway` | `requestApproval` (5+5), `persistUngoverned` (3+4), `persistGoverned` (3+4), `requireExistingAttributionMatches` (1+2) | the pattern of §3.2 applied to the remaining seams: suspending `ApprovalGatewayRequestFactory.createRequest`, `SuspendedInvocationStore.create`, `ApprovalContinuationStore.create`, and the governed stores (`createGovernedApproval`, `attributionOf`, `createGoverned`) | +| 22 `NullReturnVals`, 26 `VoidMethodCall`, 2 `NegateConditionals` | `ApprovalResumeCoordinator` | `resume` (8+9), `prepareResume` (5+6), `authorizeResume` (4+5 +2 NC), `executeClaimedResume` (3+3), `revealAndValidateReplayPayload` (1+1), `$resume$2.invokeSuspend` (1+2) | drive `resume()` with genuinely suspending/failing `ApprovalContinuationStore`, `SuspendedInvocationStore`, `ReplayAuthorizationService`, `ContinuationClaimService` and `ClaimedResumeExecutor` | +| 18 `NullReturnVals`, 19 `VoidMethodCall`, 2 `NegateConditionals` | `ApprovalSuspensionCoordinator` | `suspendToolExecution` (6+6), `compensateSuspension` (3+4 + 3×2 lambda pairs), `persistSuspendedInvocation` (2+2 +2 NC), `compensateStep` (1+3) | tool-suspension and compensation pipelines with a suspending collaborator; the `compensateSuspension$2$*` lambdas need the compensation path itself to suspend | +| 4 `NegateConditionals` | both coordinators | `authorizeResume` ×2, `persistSuspendedInvocation` ×2 | per-site analysis only; the two `Nothing`-callee sentinel cases (`:151`, `:155`) may be structurally unreachable rather than uncovered — do not fold into either coroutine harness | + + +## 6. What this slice does not settle + +- 116 of the 118 identities have no durable test in this commit (see §5.2 for the per-scenario grouping). Each remaining site needs the harness that + matches its own semantic obligation and its own collaborator seam; the per-group scenarios are listed in §5. +- The 4 `NegateConditionals` identities are deliberately excluded from both coroutine harnesses. Two are the + `Nothing`-callee sentinel cases (`ApprovalResumeCoordinator:151`, `:155`): the post-call comparison can only be + reached if a normally non-returning callee returns, which may be structurally unreachable under the production + contract rather than merely uncovered. Two are `ApprovalSuspensionCoordinator:280`, `:282` survivors needing + individual behavioural analysis. +- The 46 existing `TIMED_OUT` `NegateConditionals` identities are untouched: both new harnesses leave the + representative `TIMED_OUT`, which strengthens rather than weakens the recorded tooling-limitation + adjudication. + +## 7. What this record does not authorise + +- **No admissions, no classifications.** `config/quality/mutation-classifications.yml` and the population + admission ledger stay untouched; the M22–M29 / M30–M39 ceremonies are unaffected. +- **No baseline or policy change.** `config/quality/mutation-baseline.json` is not regenerated by this slice, + no deviation ceiling moves, and PIT selection/timeout policy is unchanged. +- **No production change for a mutation score.** If a later slice acts on a `KILLABLE` candidate it must preserve + identity census, regression accounting, provenance and a positive control. +- **No inference from neighbours.** An identity is settled only by its own measurement or its own + instruction-level equivalence proof. Being compiler-generated is not itself evidence of equivalence: for the + two constructs above, the generated bytecode carried a real, application-observable contract. + +## 8. Reproduction + +1. Read the cohort from `TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json` and verify the digests in §1.1. +2. Recovery (if the manifest must be rebuilt): `/tmp/g1g2-residual.json` minus its firm dispositions → + 53 + 61 + 4; join to the census on the canonical identity, never on `(file, line, block, index)`. +3. Positive control: `./gradlew :tramai-engine:test --tests "dev.tramai.engine.approval.DefaultApprovalGatewayTest"` + must pass on the unmutated tree, and the suspending-store test must actually suspend (`resumedGets == 1`). +4. Measurement: throwaway worktree at the tests commit, narrow `mutation.targetFamilies` to the classes owning + the cohort, commit the narrowing, run `generateCriticalMutationBaseline`, then confirm `measuredCommit`, + identity-set equality with the census, and the exact per-identity status movement. + +## 9. Increment 2 — TASK-0.7.1g1G4b: DefaultApprovalGateway residuals + +**Exact base:** `7703685ca78821c6787077763ed6d02143d5da6f` (equal to `origin/epic/0.7.1-control-plane-authority` at branch time). +**Preconditions verified:** clean worktree; the committed 118-manifest recomputed to `0c2d107d…`; the input cohort derived +from that manifest **minus** the two identities g1G4a killed. + +### 9.1 Input cohort — 27 identities, derived not counted + +| Group | Count | Methods | +|---|---|---| +| `NullReturnVals` | 12 | `requestApproval` 5, `persistUngoverned` 3, `persistGoverned` 3, `requireExistingAttributionMatches` 1 | +| `VoidMethodCall` | 15 | `requestApproval` 5, `persistUngoverned` 4, `persistGoverned` 4, `requireExistingAttributionMatches` 2 | +| `NegateConditionals` | 0 | — | + +The two g1G4a identities (`8b52a478…` block 36/247, `d77706fd…` block 37/279) are excluded by measurement, not by +line matching: they are removed because the g1G4a campaign recorded them `KILLED`. + +### 9.2 Instruction mapping (`javap -p -c -l` at the exact base) + +Sentinel `areturn`s per method, each after the guarded call, with the paired resume-path `ResultKt::throwOnFailure`: + +| Method | Guarded call | sentinel pc | PIT pair | +|---|---|---|---| +| `requestApproval` | `resolveGovernedIdentity` | 164 | blocks 10 / 11 | +| `requestApproval` | `ApprovalGatewayRequestFactory.createRequest` | 309 | blocks 24 / 25 | +| `requestApproval` | `ApprovalStore.get` | 462 | blocks 36 / 37 *(killed in g1G4a)* | +| `requestApproval` | `requireExistingAttributionMatches` | 623 | blocks 47 / 48 | +| `requestApproval` | `persistUngoverned` | 804 | blocks 61 / 62 | +| `requestApproval` | `persistGoverned` | 973 | blocks 73 / 74 | +| `persistUngoverned` | `ApprovalStore.create` | 135 | blocks 11 / 12 | +| `persistUngoverned` | `SuspendedInvocationStore.create` | 190 | blocks 18 / 19 | +| `persistUngoverned` | `ApprovalContinuationStore.create` | 248 | blocks 26 / 27 | +| `persistGoverned` | `GovernedApprovalStore.createGovernedApproval` | 150 | blocks 13 / 14 | +| `persistGoverned` | `GovernedSuspendedInvocationStore.createGoverned` | 233 | blocks 23 / 24 | +| `persistGoverned` | `ApprovalContinuationStore.create` | 311 | blocks 32 / 33 | +| `requireExistingAttributionMatches` | `GovernedApprovalStore.attributionOf` | 156 | blocks 15 / 16 | + +Mapping method: PIT block/index pairs are ordered as the sentinel `areturn`s are ordered in the bytecode, and the +correspondence is confirmed twice — g1G4a's killed identity at block 36 / index 247 is the `ApprovalStore.get` +sentinel at pc 462, and the artifact's own obligation text for block 24 / index 164 names +`ApprovalGatewayRequestFactory.createRequest` (pc 309). Each method additionally carries a `VoidMethodCall` row at +block 7 / index 44, the state-machine *entry* rethrow (`javap` pc 97 in `persistUngoverned`), which executes on every +resume rather than on one seam. + +### 9.3 One identity pair settled by proof, not by test — blocks 10 / 11 + +`resolveGovernedIdentity` is a `suspend` function whose compiled body is 32 instructions and contains **no** +`IntrinsicsKt.getCOROUTINE_SUSPENDED`, **no** `ResultKt::throwOnFailure`, and no invocation that can suspend +(`Continuation.getContext`, `GovernedRunScope$Key.resolve`, `Intrinsics.areEqual`, string concat, and +`GovernedRunContinuityException.`/`athrow` only). It therefore can never return the suspension sentinel, so in +`requestApproval` the guard `if_acmpne` after its call always branches past the sentinel `areturn` at pc 164, and the +resume path that the block-11 rethrow belongs to is never entered. Both identities are recorded **UNREACHABLE** on +that instruction-level proof; the measurement agrees (`NO_COVERAGE`, `numberOfTestsRun = 0`, unchanged). + +### 9.4 Durable tests added (10) + +`tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GatewaySuspensionContractTest.kt` — a new class rather than +edits inside the released suite, reusing its `internal` fakes and the governed suite's `TestGoverned*` stores. Each +test states a contract, not a mutation: + +- ungoverned: every persistence collaborator suspends and the saga completes; factory / approval-store / + suspended-invocation / continuation failures after suspension each reach the caller and stop the chain; +- governed: every governed collaborator suspends and the saga completes; governed-approval and governed-suspension + failures after suspension reach the caller and write nothing further; an existing governed approval is re-validated + across a suspending attribution read; a failure resuming that read reaches the caller. + +Four delegating helpers, each with at most one `failAfter…` parameter, express "a collaborator that suspends and then +fails" — no per-mutation knobs, no PIT-shaped API. `delay(1)` provides the suspension; every call is bounded by +`withTimeout(2_000)`, below PIT's `timeoutConstInMillis`. No production code was touched, and no identity was pursued +by restructuring production. + +### 9.5 Measurement provenance + +Test commit `aa5b003c`; measurement worktree at that commit with one committed narrowing (measurement commit +`4bb108c7`, not part of the branch); scope `:tramai-engine` / `dev.tramai.engine.approval.DefaultApprovalGateway*`; +3 m 49 s; **79 mutants, identity set identical to the frozen census (79/79 shared, 0 lost, 0 new)**. + +Full transition matrix (`census` → g1G4b): + +| Before | After | Count | +|---|---|---| +| `KILLED` | `KILLED` | 32 | +| `NO_COVERAGE` | `KILLED` | **23** | +| `NO_COVERAGE` | `NO_COVERAGE` | 2 | +| `NO_COVERAGE` | `SURVIVED` | **1** | +| `SURVIVED` | `SURVIVED` | 8 | +| `TIMED_OUT` | `TIMED_OUT` | 13 | +| any other transition | — | 0 | + +Restricted to the 27-identity cohort: `NO_COVERAGE → KILLED` **21**, `NO_COVERAGE → NO_COVERAGE` 2, +`NO_COVERAGE → SURVIVED` **1**, `SURVIVED → SURVIVED` 3, other transitions 0. Every input identity is accounted for. + +Controls: `KILLED → non-KILLED` regressions **0**; new `TIMED_OUT` **0** (13 `TIMED_OUT` unchanged); no identity left +the scoped cohort; every new kill names its killing test (all `numberOfTestsRun = 1`). + +Explicitly reported movement: `NO_COVERAGE → SURVIVED` — `75ec22b0e703bb8a54ddbde9d6edab92e1e3fbbff3318021b9a6d11eccca21ec` +(`persistGoverned` block 33 / index 197, `removed call to kotlin/ResultKt::throwOnFailure`). It is the resume path of +the governed continuation write; it now executes (`numberOfTestsRun = 1`) but is not detected, because the governed +failure tests inject their failure at the first two governed writes and the governed continuation write is exercised +only across a successful resume. It stays `UNDETERMINED`; the missing obligation is a governed-continuation-write +failure test, which this increment did not add. + +### 9.6 Identity-exact dispositions + +**KILLED this increment — 21** (all `NO_COVERAGE → KILLED`, each credited to exactly one test, +`numberOfTestsRun = 1`): + +| Identity (prefix) | Mutator | Site | Killing test | +|---|---|---|---| +| `7b156f3a3c6265cf` | NV | `persistUngoverned` 11/65 | an approval write failure after suspension reaches the caller and stops the chain() | +| `1b309f9a702a4e6c` | VMC | `persistUngoverned` 12/73 | an approval write failure after suspension reaches the caller and stops the chain() | +| `1e85193edcd55874` | NV | `persistGoverned` 13/78 | a governed approval write failure after suspension reaches the caller and writes nothing() | +| `f5c807b43f9fe724` | NV | `requireExistingAttributionMatches` 15/83 | a failure resuming the attribution read reaches the caller() | +| `9c4c3454c7f77259` | VMC | `persistGoverned` 14/90 | a governed approval write failure after suspension reaches the caller and writes nothing() | +| `5ac56e0a701ab6fc` | VMC | `requireExistingAttributionMatches` 16/100 | a failure resuming the attribution read reaches the caller() | +| `a4dbacf129987194` | NV | `persistUngoverned` 18/105 | a suspended-invocation write failure after suspension reaches the caller and stops the chain() | +| `84224c763190937a` | VMC | `persistUngoverned` 19/113 | a suspended-invocation write failure after suspension reaches the caller and stops the chain() | +| `7e799e77e8c7396e` | NV | `persistGoverned` 23/136 | a governed suspension write failure after suspension reaches the caller and writes no continuation() | +| `e8276b8096040f41` | NV | `persistUngoverned` 26/146 | a continuation write failure after suspension reaches the caller and leaves no continuation() | +| `d8e1290f30007d72` | VMC | `persistGoverned` 24/148 | a governed suspension write failure after suspension reaches the caller and writes no continuation() | +| `15c7def4f5c98cbd` | VMC | `persistUngoverned` 27/154 | a continuation write failure after suspension reaches the caller and leaves no continuation() | +| `3883b3c127aed304` | NV | `requestApproval-Atj0Sqo` 24/164 | a request factory failure after suspension reaches the caller and writes nothing() | +| `7d3fc3ab2af8aff6` | NV | `persistGoverned` 32/185 | a governed request completes when every governed persistence collaborator genuinely suspends() | +| `6a33ba5d985ae4e6` | VMC | `requestApproval-Atj0Sqo` 25/189 | a request factory failure after suspension reaches the caller and writes nothing() | +| `4dec4f43d11411f4` | NV | `requestApproval-Atj0Sqo` 47/332 | a failure resuming the attribution read reaches the caller() | +| `8210e97a204f7064` | VMC | `requestApproval-Atj0Sqo` 48/369 | a failure resuming the attribution read reaches the caller() | +| `5c2981ad80f33214` | NV | `requestApproval-Atj0Sqo` 61/430 | an approval write failure after suspension reaches the caller and stops the chain() | +| `6dcb09ca1cc8e2bb` | VMC | `requestApproval-Atj0Sqo` 62/467 | an approval write failure after suspension reaches the caller and stops the chain() | +| `862220d0ea233a54` | NV | `requestApproval-Atj0Sqo` 73/519 | a governed approval write failure after suspension reaches the caller and writes nothing() | +| `8dc069d5a0f38543` | VMC | `requestApproval-Atj0Sqo` 74/556 | a governed approval write failure after suspension reaches the caller and writes nothing() | + +**Proven UNREACHABLE — 2:** `NullReturnVals` `requestApproval` 10/72 and `VoidMethodCall` `requestApproval` 11/92 (§9.3). + +**Still UNDETERMINED — 4:** + +| Identity (prefix) | Mutator | Site | Observed | +|---|---|---|---| +| `75ec22b0e703bb8a` | VMC | `persistGoverned` 33/197 | `NO_COVERAGE → SURVIVED`, executes on successful resume | +| — | VMC | `persistUngoverned` 7/44 | `SURVIVED → SURVIVED`, 11 tests run | +| — | VMC | `persistGoverned` 7/44 | `SURVIVED → SURVIVED`, 5 tests run | +| — | VMC | `requireExistingAttributionMatches` 7/44 | `SURVIVED → SURVIVED`, 11 tests run | + +The three entry-rethrow rows are executed on every resume yet their removal is undetected by any test in this slice, +including the failing-resume tests. That is stated as an observation, not a disposition: it is consistent with the +nested frame rethrowing first (so the delivered resume value never carries a failure) and would make them +equivalence candidates, but no equivalence argument is claimed here. + +**`EQUIVALENT` proven this increment: 0. `TOOLING_LIMITATION` proven this increment: 0. `UNREACHABLE` proven: 2.** + +### 9.7 Accounting + +``` +g1G4 parent UNDETERMINED before g1G4b 116 +g1G4b DefaultApprovalGateway input 27 + KILLED this increment 21 + EQUIVALENT proven 0 + UNREACHABLE proven 2 + TOOLING_LIMITATION proven 0 + still UNDETERMINED 4 + lost / duplicate / unexplained 0 / 0 / 0 +g1G4 parent UNDETERMINED after g1G4b 116 - 23 = 93 +``` + +Parent remainder by class: `ApprovalResumeCoordinator` 50, `ApprovalSuspensionCoordinator` 39, +`DefaultApprovalGateway` 4. Test added is not equated with identity settled: the 21 kills are measured transitions, +the 2 unreachability findings rest on the bytecode proof in §9.3, and the 4 survivors stay unresolved. + +### 9.8 Reproduction + +1. Derive the cohort: committed 118-manifest ∩ `className == dev.tramai.engine.approval.DefaultApprovalGateway` + minus the identities the previous increment measured `KILLED`. +2. Positive control: `./gradlew :tramai-engine:test --tests "dev.tramai.engine.approval.GatewaySuspensionContractTest"` + — 10 tests, 0 failures, and the `…Resumes` counters must be non-zero (a synchronous fake cannot reach the state). +3. Gates: `spotlessApply` **before** `verifyStaticAnalysis`; `:tramai-engine:test`; `spotlessCheck`; + `verifyStaticAnalysis`; `verifyChangePolicy -PchangePolicyBase=7703685c… -PchangeClass=runtime-behaviour`. +4. Measurement: throwaway worktree at the test commit, narrow `mutation.targetFamilies` to + `dev.tramai.engine.approval.DefaultApprovalGateway*`, commit the narrowing, run + `generateCriticalMutationBaseline`, compare identity sets with the census, then regenerate the two matrices in §9.5. + +--- + +## 10. Increment 3 - TASK-0.7.1g1G4c: ApprovalResumeCoordinator residuals (50) + +**Exact base:** `6d798f80dd429370ebc03908ea06652366a918be` (equal to `origin/epic/0.7.1-control-plane-authority` at +branch time; #454 merged into the Epic as this commit). +**Preconditions verified:** #454 read back as `state=closed`, `merged=true`, `merge_commit_sha=6d798f80...`; clean +worktree; the committed 118-manifest recomputed to `0c2d107d...`. + +### 10.1 Input cohort - 50 identities, derived not counted + +Joined on the **full canonical identity**. The cohort is every manifest row whose `className` *starts with* +`dev.tramai.engine.approval.ApprovalResumeCoordinator`: the three `$resume$2` rows carry the generated class name, and an +equality filter loses exactly those three. + +| Group | Count | +|---|---| +| `NullReturnVals` | 22 | +| `VoidMethodCall` | 26 | +| `NegateConditionals` | 2 | + +Initial census state: **46 `NO_COVERAGE` + 4 `SURVIVED`**; decomposition `resume` 17, `prepareResume` 11, +`authorizeResume` 11, `executeClaimedResume` 6, `revealAndValidateReplayPayload` 2, `$resume$2.invokeSuspend` 3, all +reproduced exactly. `ApprovalResumeCoordinator.kt` is bytecode-unchanged since the census commit `a47a759f`, so the +frozen block/index tuples remain comparable; the manifest's stale *line* numbers were not used for derivation. + +**Identity schema.** The manifest documents `module / className / method / methodDescription / mutator / description / +block / index`; the implemented separator is **`0x1f`**, not the `U+241F` glyph the manifest renders. Recomputed +identities reproduce both the committed population and the manifest cohort exactly (50/50 join), which is what makes the +measurement joinable at all. + +### 10.2 Instruction mapping (`javap -p -c -l` at the exact base) + +Every cohort row is a compiler-generated glue instruction with an exact seam: + +- **`NullReturnVals` (22)** - the sentinel `areturn` a suspend call leaves behind (`invoke; dup; if_acmpne L; aload N; + areturn`). Executed **only when the callee actually suspends**, which is why a synchronous fake leaves it + `NO_COVERAGE`. +- **`VoidMethodCall` (26)** - `ResultKt::throwOnFailure` at the entry of a generated state-machine case (the rethrow + that unwraps a failure delivered on the resumed frame), plus one `CancellationKt::rethrowIfCancellation` call site. +- **`NegateConditionals` (2)** - `if_acmpne` suspension checks after `denyAndCancel` / `cancelForNestedApproval`. + +Seam to callee, read off the `invoke` preceding each sentinel: + +| Method | Sentinels (pc) and their guarded call | +|---|---| +| `resume` | 148 `prepareResume`, 203 `authorizeResume`, 308 `ContinuationClaimService.claim`, 470 `executeClaimedResume`, 639 `withContext`, 808/1008/1221 `emitResumeUncertainOutcomeOnce` | +| `prepareResume` | 152 `ApprovalContinuationStore.get`, 262 `SuspendedInvocationStore.get`, 406 `governedRunIdentity`, 573 `loadPendingForResume`, 743 `validateToken` | +| `authorizeResume` | 149 `decideResumePolicy`, 260 `denyAndCancel`, 376 `cancelForNestedApproval`, 500 `authorize` | +| `executeClaimedResume` | 181 `revealAndValidateReplayPayload`, 313 `validateClaimedResumeArguments`, 572 `execute`, 809 `completeClaimedResume` | +| `revealAndValidateReplayPayload` | 148 `revealReplayEnvelope` | +| `$resume$2.invokeSuspend` | 73 `access$executeClaimedResume` | + +The mapping is order-checked, not guessed: within each `(method, line, mutator)` the measurement's block/index order is +monotone in bytecode pc, and the non-cohort rows fall out consistently (`resume` block 7 is the case-0 entry rethrow; +`executeClaimedResume` blocks 13/14; `revealAndValidateReplayPayload` blocks 11/12). + +**Reachability finding: the two `NegateConditionals` are NOT unreachable.** They are the standard suspension checks after +`ReplayAuthorizationService.denyAndCancel` / `cancelForNestedApproval`. Both callees are declared `Nothing`, but +`cancelState` reaches `ApprovalContinuationStore.cancel`, so the callee **can** suspend: suspension is a *normal* return +of the sentinel, and the method then throws from the resumed frame. Inverting the check sends a suspending cancellation +to the `KotlinNothingValueException` path instead of returning the sentinel, so the caller would see a compiler artefact +instead of the domain exception. Both were **killed by measurement**, not written off as compiler-shaped. + +### 10.3 Durable tests added (22) + +`tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalResumeSuspensionContractTest.kt` - a new class rather +than edits inside the released suite. Every collaborator is a delegating double that genuinely `delay(1)`-suspends and +optionally fails **on the resumed frame**, each with at most one failure per operation it performs: + +- a resume completes when every collaborator genuinely suspends (the resumed counters are the positive control: a + synchronous fake cannot produce them); a governed resume completes and executes inside the recovered run scope; +- a failure after suspension at each seam reaches the caller unchanged and stops the chain: continuation eligibility + read, claim-time read, metadata load, governed identity read, token validation, authorization, claim write, replay + reveal, claimed-arguments integrity mismatch, executor, completion write, policy-decision audit; +- uncertain-outcome contracts: a `StructuredOutputException` and a `NestedApprovalNotSupportedException` raised after + suspension are reported uncertain and propagated; an uncertain-outcome audit failure does not replace the primary + failure; a completion-audit failure is recorded and does not fail the resume; +- denial and nested-approval requirements still cancel and reach the caller **when the cancellation itself suspends** + (the two `NegateConditionals`); +- a replay-envelope digest mismatch is rejected and reports the mismatch before claiming. + +Two contract facts the harness forced: kotlinx's stack-trace recovery delivers a *copy* of the failure, so identity of +the instance is not the contract (the tests assert type plus a per-test unique message); and the denied/nested paths run +**before** the claim, so they emit no uncertain outcome at all (asserted as zero). + +### 10.4 Measurement provenance + +Measured test commits: `4d772e81` (the original harness) and `423779b2` (after the review fix), each in a throwaway +worktree with one committed narrowing (`f01d296e`, `f9345b2c`). + +**Certified.** The measurement was re-run at every test head that mattered: `36c50804` (narrowing `bfd061af`, +5 m 10 s) and, after the g1G4c review fix, at the shipped head `423779b2` (narrowing `f9345b2c`, 5 m 6 s). +Both runs: identical identity set (161/161), cohort reproduced exactly - 39 `NO_COVERAGE -> KILLED`, +1 `SURVIVED -> KILLED`, 7 `NO_COVERAGE -> SURVIVED`, 3 `SURVIVED -> SURVIVED` - zero status drift, +0 regressions, 0 new `TIMED_OUT`, 0 identity loss or gain. An intermediate head (`c606501c`) did not compile; +the nested measurement caught it before certification. The review finding - the continuation-store double returned +copies from claim/complete/cancel without adopting them, so the coordinator could pass against states no real store +produces - was fixed at `423779b2`, where the double evolves PENDING v3 -> CLAIMED v4 -> COMPLETED v5 (or +PENDING -> CANCELLED) and the tests assert the resulting state. The outcome did not change; that is recorded, not +assumed. + +Deviation, stated explicitly: the narrowing of `mutation.targetFamilies` was applied by pattern over the `approval` +family and removed the unrelated families entirely rather than only narrowing `approval`. Candidate and control used the +**identical** narrowing, so the comparison stays scope-matched, and the joined scope is the coordinator's own mutants. + +The aggregate `config/quality/mutation-baseline.json` write is admission-gated in this repository state and was not +produced by either run, so the join is made from PIT's own `mutations.xml` (raw tool output: per-mutant status, +`numberOfTestsRun`, `killingTest`), with per-mutant identities recomputed from that XML using the committed schema. + +### 10.5 Identity-exact results + +Full scoped transition matrix (control at base -> candidate), 161 shared identities: + +| Before | After | Count | +|---|---|---| +| `KILLED` | `KILLED` | 38 | +| `NO_COVERAGE` | `KILLED` | 53 | +| `NO_COVERAGE` | `NO_COVERAGE` | 6 | +| `NO_COVERAGE` | `SURVIVED` | 11 | +| `SURVIVED` | `KILLED` | 4 | +| `SURVIVED` | `SURVIVED` | 20 | +| `TIMED_OUT` | `KILLED` | 7 | +| `TIMED_OUT` | `TIMED_OUT` | 22 | + +Controls: `KILLED -> non-KILLED` regressions **0**; identity loss **0**; new identities **0**; new `TIMED_OUT` **0** +(22 pre-existing `TIMED_OUT` unchanged, none of them a cohort identity). + +Cohort matrix (frozen manifest -> candidate), every one of the 50 exactly once: + +| Before | After | Count | +|---|---|---| +| `NO_COVERAGE` | `KILLED` | 39 | +| `NO_COVERAGE` | `SURVIVED` | 7 | +| `SURVIVED` | `KILLED` | 1 | +| `SURVIVED` | `SURVIVED` | 3 | + +`NO_COVERAGE -> SURVIVED` is reported explicitly rather than disguised as progress: **7**. + +### 10.6 Identity-exact dispositions + +**KILLED this increment - 40** (39 `NO_COVERAGE -> KILLED`, 1 `SURVIVED -> KILLED`), each naming its killing test and +`numberOfTestsRun`: + +| Identity | Mutator | Site | Killing test | tests run | +|---|---|---|---|---| +| `cb90dfa953` | VoidMethodCall | `authorizeResume` 7/44 | deny policy cancels state and never claims or executes() | 1 | +| `2d1f9e3730` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `authorizeResume` 12/71 | a policy-decision audit failure after suspension reaches the caller() | 1 | +| `eacdfd8c8a` | VoidMethodCall | `authorizeResume` 13/83 | a policy-decision audit failure after suspension reaches the caller() | 1 | +| `b36fc3ca50` | NegateConditionals | `authorizeResume` 23/129 | a denied resume cancels and reaches the caller when the cancellation suspends() | 1 | +| `64b71d7ed9` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `authorizeResume` 24/133 | a denied resume cancels and reaches the caller when the cancellation suspends() | 1 | +| `9516ca2fa7` | VoidMethodCall | `authorizeResume` 25/150 | a denied resume cancels and reaches the caller when the cancellation suspends() | 1 | +| `4be603cfac` | NegateConditionals | `authorizeResume` 37/195 | a nested-approval requirement cancels and reaches the caller when the cancellation suspends() | 1 | +| `6759437252` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `authorizeResume` 38/199 | a nested-approval requirement cancels and reaches the caller when the cancellation suspends() | 1 | +| `b1d24bc23a` | VoidMethodCall | `authorizeResume` 39/216 | a nested-approval requirement cancels and reaches the caller when the cancellation suspends() | 1 | +| `5cae2063c9` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `authorizeResume` 54/275 | an authorization failure after suspension reaches the caller before the claim() | 1 | +| `ef7dc1d6b1` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `executeClaimedResume` 20/171 | a claimed-arguments integrity mismatch after suspension is rejected() | 1 | +| `577c07309b` | VoidMethodCall | `executeClaimedResume` 21/202 | a claimed-arguments integrity mismatch after suspension is rejected() | 1 | +| `04c94b4890` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `executeClaimedResume` 40/327 | an executor failure after suspension reaches the caller and completes nothing() | 1 | +| `021b3c457a` | VoidMethodCall | `executeClaimedResume` 41/378 | an executor failure after suspension reaches the caller and completes nothing() | 1 | +| `4b1bdb9fc7` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `executeClaimedResume` 55/445 | a completion write failure after suspension reaches the caller() | 1 | +| `b280cd7e98` | VoidMethodCall | `executeClaimedResume` 56/500 | a completion write failure after suspension reaches the caller() | 1 | +| `09642639c5` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `invokeSuspend` 6/36 | a governed executor failure after suspension reaches the caller() | 1 | +| `f8bdda993a` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `prepareResume` 12/72 | a continuation read failure after suspension reaches the caller() | 1 | +| `f20df64ded` | VoidMethodCall | `prepareResume` 13/85 | a continuation read failure after suspension reaches the caller() | 1 | +| `8cdddbe077` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `prepareResume` 25/135 | a metadata load failure after suspension reaches the caller() | 1 | +| `b545c2ae14` | VoidMethodCall | `prepareResume` 26/153 | a metadata load failure after suspension reaches the caller() | 1 | +| `4cca31fe70` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `prepareResume` 40/228 | a governed identity read failure after suspension reaches the caller before any claim() | 1 | +| `8510790e92` | VoidMethodCall | `prepareResume` 41/251 | a governed identity read failure after suspension reaches the caller before any claim() | 1 | +| `e2f022edd7` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `prepareResume` 54/336 | a claim-time read failure after suspension reaches the caller without claiming() | 1 | +| `613bf2d479` | VoidMethodCall | `prepareResume` 55/369 | a claim-time read failure after suspension reaches the caller without claiming() | 1 | +| `39f37feb8a` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `prepareResume` 62/427 | a token validation failure after suspension reaches the caller() | 1 | +| `2f92ef797c` | VoidMethodCall | `prepareResume` 63/470 | a token validation failure after suspension reaches the caller() | 1 | +| `d3f812e883` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 10/63 | a continuation read failure after suspension reaches the caller() | 1 | +| `ceba69cd43` | VoidMethodCall | `resume` 11/71 | a continuation read failure after suspension reaches the caller() | 1 | +| `18ac9e9b9d` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 15/99 | an authorization failure after suspension reaches the caller before the claim() | 1 | +| `441d334356` | VoidMethodCall | `resume` 16/112 | an authorization failure after suspension reaches the caller before the claim() | 1 | +| `7cd98a9030` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 27/159 | a claim write failure after suspension reaches the caller() | 1 | +| `ece7d1190f` | VoidMethodCall | `resume` 28/177 | a claim write failure after suspension reaches the caller() | 1 | +| `f0effc0ace` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 43/268 | a replay reveal failure after suspension reaches the caller and completes nothing() | 1 | +| `92b019e1ba` | VoidMethodCall | `resume` 44/303 | a nested-approval requirement raised after suspension is reported uncertain and propagated() | 3 | +| `13586a6301` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 55/360 | a governed executor failure after suspension reaches the caller() | 1 | +| `2d5928356b` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 68/463 | a nested-approval requirement raised after suspension is reported uncertain and propagated() | 1 | +| `5170395637` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 86/579 | a structured-output failure after suspension is reported uncertain and propagated() | 1 | +| `d20dbac385` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `resume` 106/708 | an uncertain-outcome audit failure after suspension does not replace the primary failure() | 1 | +| `20ca1a1d8d` | org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator | `revealAndValidateReplayPayload` 32/174 | a replay-envelope digest mismatch is rejected and reports the mismatch before claiming() | 1 | + +One of the 40 (`cb90dfa953`, `authorizeResume` 7/44) was already detected by the released +`ApprovalResumeCoordinatorTest.deny policy cancels state and never claims or executes`; the rest are credited to the new +contract tests. + +**EQUIVALENT proven - 1:** `1ce5967ee6` - `resume` block 91/index 641, line 108, `removed call to +CancellationKt::rethrowIfCancellation`. The mutated call sits in the **last** catch clause (`catch (e: Exception)`), and +`rethrowIfCancellation` is exactly `if (this is CancellationException) throw this`. The clause immediately above it is +`catch (e: CancellationException)` rethrowing `e`, and Kotlin matches catch clauses in order, so no reachable value of +`e` can be a `CancellationException` or any subclass (`kotlinx.coroutines.CancellationException` is +`java.util.concurrent.CancellationException` on the JVM). The call is a no-op for every value the site can receive, so +removing it cannot change behaviour. The measurement agrees (`SURVIVED -> SURVIVED`, 17 tests run). + +**UNREACHABLE proven - 0. `TOOLING_LIMITATION` proven - 0.** + +**Still UNDETERMINED - 9**, all of them the same construct, and all of them *executed*: + +| Identity | Site | Observed | tests run | Mutation | +|---|---|---|---|---| +| `63e5372ba3` | `authorizeResume` 55/292 line 144 | NO_COVERAGE -> SURVIVED | 5 | removed call to kotlin/ResultKt::throwOnFailure | +| `e0ae69141e` | `invokeSuspend` 2/12 line 85 | SURVIVED -> SURVIVED | 4 | removed call to kotlin/ResultKt::throwOnFailure | +| `492f9a132e` | `invokeSuspend` 7/40 line 85 | NO_COVERAGE -> SURVIVED | 2 | removed call to kotlin/ResultKt::throwOnFailure | +| `ab78ed8577` | `prepareResume` 7/44 line 119 | SURVIVED -> SURVIVED | 99 | removed call to kotlin/ResultKt::throwOnFailure | +| `5397c6bf59` | `resume` 56/395 line 64 | NO_COVERAGE -> SURVIVED | 2 | removed call to kotlin/ResultKt::throwOnFailure | +| `ef13cbec0d` | `resume` 69/501 line 64 | NO_COVERAGE -> SURVIVED | 1 | removed call to kotlin/ResultKt::throwOnFailure | +| `ecb1e83af5` | `resume` 87/617 line 64 | NO_COVERAGE -> SURVIVED | 1 | removed call to kotlin/ResultKt::throwOnFailure | +| `9026b993fd` | `resume` 107/746 line 64 | NO_COVERAGE -> SURVIVED | 2 | removed call to kotlin/ResultKt::throwOnFailure | +| `62911071da` | `revealAndValidateReplayPayload` 33/205 line 212 | NO_COVERAGE -> SURVIVED | 1 | removed call to kotlin/ResultKt::throwOnFailure | + +These are the state-machine **case-entry rethrows**: removing the unwrap of a failure delivered on the resumed frame is +not detected by any test in the slice, including the failing-resume tests. That is recorded as the observation it is - +it is consistent with the resumed frame delivering the failure through the nested call rather than through the case +entry - but no equivalence argument is claimed, so they stay `UNDETERMINED`. It is the same family the previous +increment observed on `DefaultApprovalGateway`; a repeatable pattern is not evidence of equivalence. + +### 10.7 Accounting + +``` +g1G4 parent UNDETERMINED before g1G4c 93 +g1G4c ApprovalResumeCoordinator input 50 + KILLED this increment 40 + EQUIVALENT proven 1 + UNREACHABLE proven 0 + TOOLING_LIMITATION proven 0 + still UNDETERMINED 9 + lost / duplicate / unexplained 0 / 0 / 0 +g1G4 parent UNDETERMINED after g1G4c 93 - 41 = 52 +``` + +Parent remainder by class: `ApprovalSuspensionCoordinator` 39, `DefaultApprovalGateway` 4, `ApprovalResumeCoordinator` 9. +g1G4d/g1G4e remain later work; parent g1G4 is **not** complete. + +No production code was touched, no baseline regenerated, no classification or admission file edited, no mutator set, +timeout policy, deviation ceiling or workflow weakened. None of the 50 identities was admitted through the g1G4z2 +population-admission mechanism. + +### 10.8 Reproduction + +1. Derive the cohort: committed 118-manifest, `className` **prefix** `dev.tramai.engine.approval.ApprovalResumeCoordinator`. +2. Positive control: `./gradlew :tramai-engine:test --tests "dev.tramai.engine.approval.ApprovalResumeSuspensionContractTest"` + - 22 tests, 0 failures. +3. Measurement: throwaway worktree at `4d772e81` (or the shipped head), narrow `mutation.targetFamilies` (measurement commit `f01d296e`), + `./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks`, then join + `build/reports/maintainability/mutation/approval/tramai-engine/mutations.xml` to the frozen cohort by full canonical + identity. Control: the same narrowing at `6d798f80`. +4. Gates: `verifyChangePolicy -PchangeClass=runtime-behaviour -PchangePolicyBase=6d798f80...`; `:tramai-engine:test`; + `spotlessCheck verifyStaticAnalysis verifyStaticSafetyGuards verifyJUnitTestSignatures`. + +## 11. Increment 4 -- TASK-0.7.1g1G4d: ApprovalSuspensionCoordinator residuals + +### 11.1 Start gate + +- **task base**: `19c3d939291194e849dc42515a516b09cebec7a3` -- the squash merge of #455, verified as the exact `origin/epic/0.7.1-control-plane-authority` tip (0 commits after it, clean tree, `#455 merged=true`, merge commit equal to that SHA). +- **test commit**: `3aeff5f0b3d86bbed09c604a5d166673869cba42` +- superseded test commit: `8850c27e040597871e1def7e8177c073976b0df8` (identical tests plus one unused `ResumeOperationReference` import, removed after review; the ledger was recertified at the cleanup commit, see 11.5) +- **measurement narrowings**: candidate `d6e92f67`, control `5391ee4a` (each committed in its throwaway worktree) +- branch: `task/0.7.1g1G4d-approval-suspension-residuals` + +### 11.2 Frozen input + +`docs/roadmap/0.7.0/TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json`, digest recomputed with the manifest's own recipe (sha256 of the sorted identities joined by newline **with a trailing newline**): + +``` +0c2d107def8f30c564e648c4a86a11049c4a7cef4bfed5c78e07d3f95b9c77ea MATCH +``` + +The recipe matters: the same list joined without the trailing newline hashes to `2aef6973...`. The cohort is derived by full canonical identity with the class-name **prefix** `dev.tramai.engine.approval.ApprovalSuspensionCoordinator`, which is what pulls in the generated `$compensateSuspension$2$*` lambdas. Result: **39 rows -- 18 NullReturnVals / 19 VoidMethodCall / 2 NegateConditionals**, census **29 NO_COVERAGE + 10 SURVIVED**, area split 12 / 7 / 12 / 4 / 4 exactly as specified. The two `NegateConditionals` are `5d4ca8196b...` and `6dc7341455...`, both in `persistSuspendedInvocation`, both SURVIVED. + +### 11.3 Instruction mapping + +`javap -p -c -l` on the compiled base for `ApprovalSuspensionCoordinator` and the three lambdas. The mapping below is by block/index order against pc order, and it reconciles exactly: + +| area | NV | VMC | NC | sentinel `areturn`s | `throwOnFailure` | reads as | +|---|---:|---:|---:|---|---|---| +| `suspendToolExecution` | 6 | 6 | 0 | 6 | 7 (1 excluded) | the six suspend seams, each a sentinel plus its case-entry rethrow | +| `compensateSuspension` | 3 | 4 | 0 | 4 (1 excluded) | 4 | the three `compensateStep` calls plus the method's entry rethrow | +| `$compensateSuspension$2$1/2/3` | 6 | 6 | 0 | 2 each | 2 each | one underlying store/gate call per lambda | +| `compensateStep` | 1 | 3 | 0 | 2 (1 excluded) | 2 + 1 call | the action invocation, its case rethrow, and `rethrowIfCancellation()` | +| `persistSuspendedInvocation` | 2 | 0 | 2 | 4 (2 excluded) | 0 | `createGoverned` and `create` sentinels; the two branch conditions | + +The six `suspendToolExecution` seams, in pc order, are: `resolveGovernedSuspension()`, `approvalGateCoordinator.createApproval(...)`, `approvalContinuationStore.create(...)`, `persistSuspendedInvocation(...)`, `approvalLifecycleAuditEmitter.onToolExecutionSuspended(...)`, `compensateSuspension(...)`. Instructions excluded from the cohort are the ones the frozen census already classified elsewhere; nothing was inferred from source-line attribution (the manifest's lines are historical, and the class was recompiled uneventfully since). + +### 11.4 Durable tests added + +`tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalSuspensionSagaContractTest.kt` -- **14 tests**, green. Every collaborator suspends (`delay`) before it answers and can fail on the resumed frame; a single ordered event log proves ordering rather than inference. + +Contracts asserted, in domain terms: + +- the intended suspension path: challenge created, PENDING continuation persisted, suspended invocation persisted, suspension audit emitted, `ApprovalSuspendedException` carries approval/continuation state, and **compensation never runs for it** (suspension is the outcome, not a failure); +- governed vs ungoverned persistence: the same dual-SPI store proves `createGoverned` is used when a governed run scope is present and `create` when it is not; +- the ordinary-failure gradient: refusal before the challenge (nothing to compensate), failure after the challenge but before the continuation, after the continuation, and during the audit; +- compensation runs in reverse order (`suspendedInvocationStore.remove`, `continuationStore.cancel`, `gate.cancelApproval`) and the initiating failure stays primary when a compensation step fails; +- a failure on the middle compensation step still runs the last step; a failure on the last step is swallowed; +- a `CancellationException` as the *initiating* failure propagates without compensation, and one delivered on a compensation action's resumed frame propagates immediately instead of being swallowed, so the later steps do not run; +- each compensation action genuinely suspends before it runs (resume counters, which a synchronous double cannot produce); +- a governed scope with a store that cannot persist the governed record is refused before anything is created. + +Test-double fidelity, per the #455 lesson: `SagaContinuationStore` is a state machine (PENDING v0 -> CANCELLED v1), refuses a stale expectation and a non-PENDING status with the production exceptions, and refuses cancellation of a continuation that was never persisted. One correction was needed during development and is recorded: the doubles log **after** they act, so a failing injected step logs nothing on the delegate; the failure wrappers therefore count *attempts* explicitly rather than letting a missing event read as a missing attempt. + +### 11.5 Measurement + +``` +./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks +``` + +Run in throwaway worktrees at the exact commits, each with a committed narrowing (`mutation.targetFamilies.approval` -> `:tramai-engine` + `dev.tramai.engine.approval.ApprovalSuspensionCoordinator*`; 2 insertions, 3 deletions, no other family touched). Raw `mutations.xml` consumed; canonical identities recomputed with the repository schema (`\x1f` separator, module `:tramai-engine`); candidate, control and frozen cohort joined by full canonical identity. + +- candidate `3aeff5f0` (the cleanup head), narrowing `afe53b5a`: **BUILD SUCCESSFUL in 4 m 2 s**, 104 mutants +- superseded candidate `8850c27e`, narrowing `d6e92f67`: **BUILD SUCCESSFUL in 3 m 39 s**, 104 mutants +- control `19c3d939`, narrowing `5391ee4a`: **BUILD SUCCESSFUL in 3 m 41 s**, 104 mutants +- 104 shared identities, **0 lost, 0 new**, 39/39 cohort joined + +The cleanup was recertified rather than assumed, and it did not need a bytecode-identity argument: the class-file hashes of the test class before and after differ (`790e34ae...` vs `47c72b4b...`) because removing line 42 shifts every following debug line number, which is exactly why a hash comparison would have been the wrong proof. Measured instead: recertified candidate vs pre-cleanup candidate -- **104/104 shared, 0 status changes, zero drift**; every matrix in 11.6 and 11.7 is the recertified one. + +Two intermediate runs were discarded and are named here rather than silently replaced: an early candidate run at the 11-test revision (superseded by the 14-test revision) and a pair of concurrent runs that failed inside the nested `:tramai-engine:pitest` under resource contention. Reports from a failed build were not used; both numbers below come from builds that reported success. + +### 11.6 Full scoped transition matrix + +| control → candidate | count | +|---|---:| +| KILLED → KILLED | 40 | +| NO_COVERAGE → KILLED | 25 | +| NO_COVERAGE → NO_COVERAGE | 2 | +| NO_COVERAGE → SURVIVED | 5 | +| SURVIVED → KILLED | 8 | +| SURVIVED → SURVIVED | 11 | +| TIMED_OUT → KILLED | 2 | +| TIMED_OUT → TIMED_OUT | 11 | + +**KILLED regressions: 0. New TIMED_OUT: 0.** No other transition occurs. + +### 11.7 Cohort transition matrix (frozen census → candidate) + +| frozen → candidate | count | +|---|---:| +| NO_COVERAGE → KILLED | 22 | +| SURVIVED → KILLED | 2 | +| NO_COVERAGE → SURVIVED | 5 | +| SURVIVED → SURVIVED | 8 | +| NO_COVERAGE → NO_COVERAGE | 2 | + +Cohort **24 KILLED / 39**, census TIMED_OUT 0, candidate TIMED_OUT 0. Every `NO_COVERAGE -> SURVIVED` row is listed in 11.9 and is **not** counted as settled: executing an instruction is not detecting its mutation. + +### 11.8 Identity-exact dispositions + +| identity | area | mutator | block/index | instruction | frozen → candidate | disposition | +|---|---|---|---|---|---|---| +| `95f4d3181b` | compensateStep | NullReturnVals | 11/67 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `60f7b020e0` | compensateStep | VoidMethodCall | 7/44 | removed call to kotlin/ResultKt::throwOnFailure | SURVIVED -> SURVIVED | UNDETERMINED | +| `38af093087` | compensateStep | VoidMethodCall | 12/77 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `c7774df8cc` | compensateStep | VoidMethodCall | 16/101 | removed call to dev/tramai/core/coroutines/CancellationKt::rethrow | NO_COVERAGE -> SURVIVED | EQUIVALENT | +| `10ee55f449` | compensateSuspension | NullReturnVals | 13/94 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `e09a3c1cc3` | compensateSuspension | NullReturnVals | 21/168 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `af8cf7ad14` | compensateSuspension | NullReturnVals | 31/243 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `f60db12bdb` | compensateSuspension | VoidMethodCall | 7/44 | removed call to kotlin/ResultKt::throwOnFailure | SURVIVED -> SURVIVED | UNDETERMINED | +| `1b8938aaab` | compensateSuspension | VoidMethodCall | 14/122 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `887313eb01` | compensateSuspension | VoidMethodCall | 22/196 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> SURVIVED | UNDETERMINED | +| `4fc586ff85` | compensateSuspension | VoidMethodCall | 32/271 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> SURVIVED | UNDETERMINED | +| `5d4ca8196b` | persistSuspendedInvocation | NegateConditionals | 7/30 | negated conditional | SURVIVED -> KILLED | KILLED | +| `6dc7341455` | persistSuspendedInvocation | NegateConditionals | 12/50 | negated conditional | SURVIVED -> KILLED | KILLED | +| `b4bde9e3e3` | persistSuspendedInvocation | NullReturnVals | 8/31 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `ec0280ffb2` | persistSuspendedInvocation | NullReturnVals | 13/51 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `66c311cf4f` | suspendToolExecution | NullReturnVals | 12/78 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> NO_COVERAGE | UNREACHABLE | +| `1843d3a31b` | suspendToolExecution | NullReturnVals | 36/210 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `c2be62debe` | suspendToolExecution | NullReturnVals | 61/392 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `4f99ba33ee` | suspendToolExecution | NullReturnVals | 110/672 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `3b12d1a55c` | suspendToolExecution | NullReturnVals | 133/857 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `453e4aaf23` | suspendToolExecution | NullReturnVals | 158/1071 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `564517b2fd` | suspendToolExecution | VoidMethodCall | 13/96 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> NO_COVERAGE | UNREACHABLE | +| `f1a19c075b` | suspendToolExecution | VoidMethodCall | 37/252 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `62897c202f` | suspendToolExecution | VoidMethodCall | 62/441 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `87fbd8e6ed` | suspendToolExecution | VoidMethodCall | 111/751 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `04c2e1c3f1` | suspendToolExecution | VoidMethodCall | 134/936 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `30f9bb41fa` | suspendToolExecution | VoidMethodCall | 159/1118 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `8abddce868` | suspension$2$1 | NullReturnVals | 6/32 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `f802165800` | suspension$2$1 | NullReturnVals | 9/43 | replaced return value with null for dev/tramai/engine/approval/App | SURVIVED -> SURVIVED | UNDETERMINED | +| `ebc96b3e85` | suspension$2$1 | VoidMethodCall | 2/12 | removed call to kotlin/ResultKt::throwOnFailure | SURVIVED -> SURVIVED | UNDETERMINED | +| `ddf6092edb` | suspension$2$1 | VoidMethodCall | 7/36 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> KILLED | KILLED | +| `980c70e5ac` | suspension$2$2 | NullReturnVals | 5/33 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `f175e8698d` | suspension$2$2 | NullReturnVals | 8/44 | replaced return value with null for dev/tramai/engine/approval/App | SURVIVED -> SURVIVED | UNDETERMINED | +| `6cef3fd2e2` | suspension$2$2 | VoidMethodCall | 2/12 | removed call to kotlin/ResultKt::throwOnFailure | SURVIVED -> SURVIVED | UNDETERMINED | +| `1c4ca901fd` | suspension$2$2 | VoidMethodCall | 6/37 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> SURVIVED | UNDETERMINED | +| `ceab216271` | suspension$2$3 | NullReturnVals | 5/33 | replaced return value with null for dev/tramai/engine/approval/App | NO_COVERAGE -> KILLED | KILLED | +| `70d345f720` | suspension$2$3 | NullReturnVals | 8/44 | replaced return value with null for dev/tramai/engine/approval/App | SURVIVED -> SURVIVED | UNDETERMINED | +| `eec481d97a` | suspension$2$3 | VoidMethodCall | 2/12 | removed call to kotlin/ResultKt::throwOnFailure | SURVIVED -> SURVIVED | UNDETERMINED | +| `a15f9807be` | suspension$2$3 | VoidMethodCall | 6/37 | removed call to kotlin/ResultKt::throwOnFailure | NO_COVERAGE -> SURVIVED | UNDETERMINED | + +Counting the table: **KILLED 24, EQUIVALENT 1, UNREACHABLE 2, TOOLING_LIMITATION 0, UNDETERMINED 12 -- sum 39.** + +**KILLED (24).** Measured at canonical identity against the durable tests above. Both `NegateConditionals` are in this set: the governed/ungoverned discriminator distinguishes them, so the inverted branch is observable rather than harmless -- neither is compiler scaffolding in the equivalence sense. + +**EQUIVALENT (1).** `c7774df8cc...`, `compensateStep`, block 16/index 101, `removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation`. Proven from **this method's own exception table**, not by analogy with the g1G4c case: + +``` + from to target type + 93 117 147 Class java/util/concurrent/CancellationException + 135 144 147 Class java/util/concurrent/CancellationException + 93 117 150 Class java/lang/Exception + 135 144 150 Class java/lang/Exception +``` + +Handlers are matched in order, so a `CancellationException` thrown anywhere in the protected ranges is handled by target 147 and can never reach target 150. The callee of the mutated call is `if (this is CancellationException) throw this`, and `kotlinx.coroutines.CancellationException` is the same JVM class the table names, so within handler 150 the call cannot throw and removing the invocation cannot change reachable behaviour. g1G4c reached the same conclusion for a similar-looking call; that proof was deliberately not reused, and the reader can see the two tables differ in shape. + +**UNREACHABLE (2).** `66c311cf4f...` (NullReturnVals, `suspendToolExecution`, block 12/index 78) and `564517b2fd...` (VoidMethodCall, block 13/index 96) are the sentinel and case-entry rethrow of the **`resolveGovernedSuspension()` seam**. That seam's callee cannot suspend, and the proof is in its own bytecode: + +- `resolveGovernedSuspension` contains **no** `IntrinsicsKt.getCOROUTINE_SUSPENDED` and **no** `ResultKt.throwOnFailure`, i.e. it has no suspension point and no state machine; its `currentCoroutineContext()` call is compiled to a plain `getContext()`/`resolve` sequence with a single `areturn`; +- a suspend function with no suspension point can never return the sentinel, so the caller's `areturn` branch for that seam is never taken and the corresponding state-machine case is never entered; +- the measurement agrees independently: both rows are `NO_COVERAGE -> NO_COVERAGE`, i.e. no test in the whole suite executes them, before or after this increment. + +**UNDETERMINED (12).** Kept explicitly. 5 are `NO_COVERAGE -> SURVIVED` (now executed, still undetected) and 7 are `SURVIVED -> SURVIVED`: the state-machine entry rethrows of `compensateSuspension`, `compensateStep` and the three lambdas, the lambdas' value-returning `areturn`s, and the two newly-executed case-entry rethrows in the lambdas. They are the same compiler-generated family that produced 24 kills here, which is exactly why no equivalence is claimed for the survivors: a repeatable pattern is not evidence. Each would need its own argument, and none is offered. + +### 11.9 Parent accounting + +``` +parent before 52 +settled in g1G4d 27 (KILLED 24 + EQUIVALENT 1 + UNREACHABLE 2 + TOOLING_LIMITATION 0) +parent after 25 (52 - 27) +``` + +The 25 residuals are: 12 `ApprovalSuspensionCoordinator` survivors above, the 9 `ApprovalResumeCoordinator` survivors recorded in §10, and the 4 `DefaultApprovalGateway` survivors. Parent g1G4 is **not** complete, and `52 - 39 = 13` would be wrong. + +### 11.10 Review findings + +- `ResumeOperationReference` import in the contract test was unused (line 42, sole occurrence). Removed in the test commit `3aeff5f0`; the review thread is answered with the recertification above. No other review finding was raised. +- No substantive finding was raised against the 24/1/2/12 adjudication, the compensation ordering or the cancellation contracts. + +### 11.11 Not done + +- no production change: this slice is test-only, and nothing in it required a production fix; +- no baseline, classification, admission-ledger, mutator-set, timeout-policy, deviation-ceiling or gate change; +- the 9 `ApprovalResumeCoordinator` and 4 `DefaultApprovalGateway` survivors were not touched; +- g1G4e was not started. + +## 12. Increment 5 -- TASK-0.7.1g1G4e: Final residual structural adjudication + +### 12.1 Start gate + +- **task base**: `36923eeee79145e63281e6378b114404e2cddba5` -- verified as the exact `origin/epic/0.7.1-control-plane-authority` tip (0 commits after, clean tree, `#456 merged=true` with that merge commit). +- branch: `task/0.7.1g1G4e-final-residual-adjudication` +- **test commit**: `29ead0226fcfbdb59cce0ecdea279f7e51a74c3f` +- **narrowings**: candidate `1551a4db`, control `c50b7342` (both throwaway worktrees, committed there only) + +### 12.2 The residual 25 + +Derived as the original frozen 118 **minus** every identity firmly disposed by g1G4a-d, by canonical identity. The derivation is proven by digest, not asserted: + +``` +4cd9b668d37657b42e11c4d36836be68ddb36916cb4b27aaaa4e015bacaa8f5c MATCH +``` + +Composition: `ApprovalResumeCoordinator` 9, `ApprovalSuspensionCoordinator` 12, `DefaultApprovalGateway` 4; by mutator **22 VoidMethodCall + 3 NullReturnVals**. Every block/index also matches the brief's expected sites. Evidence-only manifest: `docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json` (explicitly not mutation authority). + +**Base-state correction, resolved.** The brief expected all 25 `SURVIVED`; the exact-base control run reported `eec481d97a...` (`$compensateSuspension$2$3.invokeSuspend`, 2/12, line 295) as `KILLED` by the released `ApprovalSuspensionCoordinatorTest.compensation failure never replaces the original failure`. + +That report does not survive reproduction. A second measurement at the same commit, narrowed to that single class, reports the same mutant **`SURVIVED`** with 11 tests run, and the class's other kills are attributed to *different* tests in the two runs (NC 4/29 killed by `a failure after the challenge but before...` in the control and by `metadata create failure compensates cont...` in the single-class run). Same commit, same tests, same production bytecode, different outcome: the earlier `KILLED` was a per-run attribution artifact, not a semantic kill. The identity is therefore adjudicated on bytecode evidence (12.7) instead of on that report. + +### 12.3 Instruction classification + +`javap -p -c -l` on the compiled base for `DefaultApprovalGateway`, `ApprovalResumeCoordinator`, `ApprovalResumeCoordinator$resume$2`, `ApprovalSuspensionCoordinator` and the three `$compensateSuspension$2$*` lambdas. PIT's complete mutant list per method (control run) was aligned positionally with the pc-ordered `ResultKt::throwOnFailure` / `areturn` instructions, and each identity's enclosing **state-machine case label** was read from the `tableswitch` dispatch. + +The alignment is anchored, not positional: PIT's own non-`throwOnFailure` mutants pin the numbering on the lambda class -- `NegateConditionals` 4/29 is the `aload_2; if_acmpne` at pc 63, `NullReturnVals` 5/33 is the sentinel `areturn` at pc 67, `NullReturnVals` 8/44 is the `Unit.INSTANCE` `areturn` at pc 77 -- leaving `VoidMethodCall` 2/12 = pc 33 (case 0) and 6/37 = pc 69 (case 1), exactly the parsed instruction order. A purely positional alignment cannot show this, which is why `resume` (11 mutants vs 9 parsed instructions) stays flagged in 12.11. + +The value under every mutated `throwOnFailure` is the local named **`$result`** -- for methods the final `Continuation` parameter (verified in each generated signature: `prepareResume`, `compensateStep`, `compensateSuspension`, `persistGoverned`, `persistUngoverned`, `requireExistingAttributionMatches` all end in `kotlin.coroutines.Continuation<...>`), for the lambdas the `invokeSuspend` parameter. + +Two families fall out: + +- **case 0 = initial entry** (10 identities): the check runs on the incoming completion before any assignment; a `Continuation` (or `Unit.INSTANCE`) is never a `Result.Failure`. +- **case N>0 = resumed frame** (12 identities): the check is the delivery point for a failure from the child that resumed us. + +### 12.4 Falsification attempts + +Per the brief, falsification came first: *what would have to be true for this mutation to be observable?* + +1. `75ec22b0e7...` (`persistGoverned`, 33/197) had a named missing obligation -- a governed continuation write that suspends and then fails. The test was written, and the identity **died**: this is the one measured kill of the increment (12.8). +2. The case-0 entries were attacked through their call chain. For the lambdas the generated bridge is explicit: `invoke(scope, cont)` -> `create(...)` -> `invokeSuspend(getstatic kotlin/Unit.INSTANCE)`, and the `tableswitch` dispatches label 0 to the mutated instruction. Nothing can resume a fresh, label-0 continuation with a failure, so removing the check cannot be observed. The six method cases read the incoming `Continuation` parameter, which is likewise never a failure. +3. The three `NullReturnVals` areturns were attacked through their consumer. In `compensateStep` case 0 the action invocation is followed by `dup; aload ; if_acmpne 143`, then `141: aload 4; 143: pop; 144: goto 158`, then `158: getstatic Unit.INSTANCE; 161: areturn`: the returned value is compared against the sentinel, passed to `throwOnFailure` (a `null` is not a failure) and **discarded**. The lambda's result is never read. +4. The resumed cases were attacked by inspecting what follows the check. In `compensateSuspension` case 3 the sequence is `536: aload 9; 538: throwOnFailure; 541: aload 9; 543: pop; 544: goto 549` -> `549: getstatic Unit.INSTANCE; 552: areturn`: the value is unwrapped, discarded, and the method returns Unit. So removing that check would *swallow* a failure delivered to that case -- the mutation is not obviously harmless, and no existing test drives a failure into case 3 (the g1G4d cancellation test cancels the **first** compensation action). That is a falsifiable next question, not an equivalence. + +### 12.5 Durable test added + +One test, in `GatewaySuspensionContractTest`: `a governed continuation write failure after suspension reaches the caller and leaves no continuation`. It suspends the governed continuation write and fails it on the resumed frame, then asserts the failure reaches the caller, that the continuation write genuinely resumed (`writeResumes == 1`), that **no continuation is persisted**, and -- pinning the real partial-creation behavior rather than assuming a rollback -- that the two preceding governed writes *are* durable. It observes something the existing governed tests did not: they fail the governed approval write and the governed suspension write, not the third write. + +No other test was added: for the remaining families the gap is proof, not observation (12.11). + +### 12.6 Diagnostic probes + +**None merged, and none were written.** The brief allowed a throwaway reflective probe to manipulate a private continuation label; the call-chain, case-label and consumer evidence above came from bytecode plus the PIT reports, so no probe was needed and no PIT-shaped reflective test exists in the tree. + +### 12.7 Proofs + +**EQUIVALENT -- case-0 entry checks (10).** The mutated instruction is `throwOnFailure($result)` in the state-machine's label-0 case. For the four generated lambdas, `invoke` cannot call it with anything but `Unit.INSTANCE` (bridge quoted above) and label 0 is never resumed. For the six methods, `$result` at label 0 is the method's final `Continuation` parameter. `kotlin.ResultKt.throwOnFailure` throws only when its argument is a `Result.Failure`; neither `Unit.INSTANCE` nor a `Continuation` can be one. Removing the call therefore cannot change any reachable production execution. + +**EQUIVALENT -- discarded lambda results (3).** The mutated `areturn` is the label-1 return of `$compensateSuspension$2$1/$2/$3`. Its sole consumer is `compensateStep`'s action invocation, which compares the value against `getCOROUTINE_SUSPENDED`, runs `throwOnFailure` on it (`null` passes), and discards it (`pop`) before returning `Unit.INSTANCE`. The lambdas' declared result is `Unit` and no caller reads the value, so `null` and `Unit.INSTANCE` are indistinguishable to every reachable consumer. + +### 12.8 Measurement + +``` +./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks +``` + +Same narrowing in both worktrees: `mutation.targetFamilies.approval` -> `:tramai-engine` + the three residual owners (one file, 2 insertions / 3 deletions, no other family touched). Raw `mutations.xml` consumed; identities recomputed with the repository schema; joined by full canonical identity. + +- control `36923eee` (narrowing `c50b7342`) -- **BUILD SUCCESSFUL in 5 m 12 s**, 344 mutants +- candidate `29ead022` (narrowing `1551a4db`) -- **BUILD SUCCESSFUL in 5 m 19 s**, 344 mutants +- 344/344 shared, **0 lost, 0 new** + +| control `16f4cbad` -> certified candidate `382ec714` | count | +|---|---:| +| KILLED -> KILLED | 240 | +| SURVIVED -> KILLED | 1 | +| SURVIVED -> SURVIVED | 53 | +| NO_COVERAGE -> NO_COVERAGE | 10 | +| TIMED_OUT -> TIMED_OUT | 40 | + +**0 KILLED regressions. 0 new TIMED_OUT. No identity churn.** + +**Raw control report** (preserved as provenance, not rewritten): residual cohort `KILLED -> KILLED` 1, `SURVIVED -> KILLED` 1, `SURVIVED -> SURVIVED` 23. Its `KILLED -> KILLED` row is `eec481d97a...`, whose single base `KILLED` **did not reproduce** when the same commit was measured with the narrowing cut down to that class (12.2) -- so the raw `KILLED` is an observation of that run, not the adjudicated base state. Adjudicated base state: 25 SURVIVED, 0 already disposed. + +The measured kill: + +- `75ec22b0e7...` `DefaultApprovalGateway.persistGoverned` 33/197, line 244, `removed call to kotlin/ResultKt::throwOnFailure` +- killing test: `GatewaySuspensionContractTest.a governed continuation write failure after suspension reaches the caller and leaves no continuation` +- `numberOfTestsRun`: 1 + +### 12.9 Final disposition table + +| identity | class | method | mutator | block/index | pc | case | mutated instruction | disposition | +|---|---|---|---|---|---|---|---|---| +| `63e5372ba3` | ApprovalResumeCoordinator | authorizeResume | VoidMethodCall | 55/292 | 531 | 4 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `ab78ed8577` | ApprovalResumeCoordinator | prepareResume | VoidMethodCall | 7/44 | 106 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `5397c6bf59` | ApprovalResumeCoordinator | resume | VoidMethodCall | 56/395 | 879 | 6 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `ef13cbec0d` | ApprovalResumeCoordinator | resume | VoidMethodCall | 69/501 | 1079 | 7 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `ecb1e83af5` | ApprovalResumeCoordinator | resume | VoidMethodCall | 87/617 | 1292 | 8 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `9026b993fd` | ApprovalResumeCoordinator | resume | VoidMethodCall | 107/746 | None | None | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `62911071da` | ApprovalResumeCoordinator | revealAndValidateReplayPayload | VoidMethodCall | 33/205 | 386 | 2 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `e0ae69141e` | ApprovalResumeCoordinator$resume$2 | invokeSuspend | VoidMethodCall | 2/12 | 33 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `492f9a132e` | ApprovalResumeCoordinator$resume$2 | invokeSuspend | VoidMethodCall | 7/40 | 75 | 1 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `60f7b020e0` | ApprovalSuspensionCoordinator | compensateStep | VoidMethodCall | 7/44 | 90 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `f60db12bdb` | ApprovalSuspensionCoordinator | compensateSuspension | VoidMethodCall | 7/44 | 102 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `887313eb01` | ApprovalSuspensionCoordinator | compensateSuspension | VoidMethodCall | 22/196 | 390 | 2 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `4fc586ff85` | ApprovalSuspensionCoordinator | compensateSuspension | VoidMethodCall | 32/271 | 538 | 3 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `ebc96b3e85` | ApprovalSuspensionCoordinator$compensateSuspension$2$1 | invokeSuspend | VoidMethodCall | 2/12 | 33 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `f802165800` | ApprovalSuspensionCoordinator$compensateSuspension$2$1 | invokeSuspend | NullReturnVals | 9/43 | None | None | `replaced return value with null` | EQUIVALENT (discarded lambda result) | +| `6cef3fd2e2` | ApprovalSuspensionCoordinator$compensateSuspension$2$2 | invokeSuspend | VoidMethodCall | 2/12 | 33 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `1c4ca901fd` | ApprovalSuspensionCoordinator$compensateSuspension$2$2 | invokeSuspend | VoidMethodCall | 6/37 | 70 | 1 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `f175e8698d` | ApprovalSuspensionCoordinator$compensateSuspension$2$2 | invokeSuspend | NullReturnVals | 8/44 | None | None | `replaced return value with null` | EQUIVALENT (discarded lambda result) | +| `eec481d97a` | ApprovalSuspensionCoordinator$compensateSuspension$2$3 | invokeSuspend | VoidMethodCall | 2/12 | 33 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `a15f9807be` | ApprovalSuspensionCoordinator$compensateSuspension$2$3 | invokeSuspend | VoidMethodCall | 6/37 | 69 | 1 | `removed call to ResultKt::throwOnFailure` | UNDETERMINED | +| `70d345f720` | ApprovalSuspensionCoordinator$compensateSuspension$2$3 | invokeSuspend | NullReturnVals | 8/44 | None | None | `replaced return value with null` | EQUIVALENT (discarded lambda result) | +| `d5b70d305c` | DefaultApprovalGateway | persistGoverned | VoidMethodCall | 7/44 | 98 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `75ec22b0e7` | DefaultApprovalGateway | persistGoverned | VoidMethodCall | 33/197 | 332 | 3 | `removed call to ResultKt::throwOnFailure` | KILLED | +| `098258d70a` | DefaultApprovalGateway | persistUngoverned | VoidMethodCall | 7/44 | 97 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | +| `eb8827c4f4` | DefaultApprovalGateway | requireExistingAttributionMatches | VoidMethodCall | 7/44 | 90 | 0 | `removed call to ResultKt::throwOnFailure` | EQUIVALENT (case-0 entry check) | + +``` +residual input 25 + already disposed at base 0 (one reported KILL did not reproduce: 12.2) +newly settled in g1G4e 14 (KILLED 1 + EQUIVALENT 13) + KILLED 1 + EQUIVALENT 13 + UNREACHABLE 0 + TOOLING_LIMITATION 0 +still UNDETERMINED 11 +----------------------------------- +0 + 14 + 11 25 + +identity loss 0 +identity gain 0 +duplicates 0 +KILLED regressions 0 +new TIMED_OUT 0 +``` + +### 12.10 Parent accounting and closure + +``` +parent before 25 (already disposed at the exact base: 0 -- the one reported base kill did not reproduce) +settled in g1G4e 14 (KILLED 1 + EQUIVALENT 13 + UNREACHABLE 0 + TOOLING_LIMITATION 0) +parent after 11 (25 - 14) +``` + +Ledger identity: 0 + 14 + 11 = 25 and 1 + 13 + 11 = 25. + +**TASK-0.7.1g1G4 does not close.** Eleven identities remain explicitly UNDETERMINED, all of them resumed-frame `throwOnFailure` removals. No classification, adoption, admission, baseline, timeout, ceiling or CI-gate authority was changed; none of these identities was admitted through any mechanism, and the canonical mutation baseline, `mutation-classifications.yml` and the population-admission ledger are untouched. + +### 12.11 Residual record (g1G4e output, not a new task) + +All eleven share one shape: `throwOnFailure($result)` at a **resumed** case (label N>0), where the value is the failure a child delivered when it resumed the frame. What is established for every one of them: the exact instruction, its case label, and that the case is reachable (the control run executes them). What is missing is the site-specific step from 12.4:2 -- which collaborator owns that case, whether it can complete with a failure after resuming, and whether the value is swallowed or re-checked by the enclosing frame. + +Two concrete falsifiable experiments, in priority order: + +1. `4fc586ff85...` (`compensateSuspension` case 3) and `887313eb01...` (case 2): drive the **third** compensation action (the gate's `cancelApproval`) to deliver a `CancellationException` on its resumed frame and assert the cancellation still reaches the caller. If it does not, the identity is killable; if it does, the value must be arriving through a different case. This is the same experiment the g1G4d harness ran for the **first** action. +2. `492f9a132e...`, `1c4ca901fd...`, `a15f9807be...` (the lambdas' label-1 returns): name the frame that performs the check which unwraps the failure this lambdas return, and prove it receives the same exception object. Reason for doubt: the value is returned unchanged (`aload_1; areturn`), so the argument depends on which enclosing frame checks it -- through a coroutine builder that is not visible in this class's bytecode. + +One caveat stated plainly: for `ApprovalResumeCoordinator.resume` the complete mutant list per method (11) exceeds the pc-ordered `throwOnFailure` instructions found in the parsed body (9), so positions for its four identities are **positional** and the exact instruction for `9026b993fd...` (107/746) was not resolved. The next step there is an alignment using the full per-block mutant list including non-`throwOnFailure` mutators, not another test. + +### 12.12 Not done + +- no production change, no baseline, classification, admission, mutator, timeout, ceiling or gate change; +- no reflective continuation probe (12.6); +- g1G4 stays open; no successor task is created, because the remaining eleven have one shared, already-identified investigation rather than a new one. + +### 12.13 Review response (PR #457) + +- **Finding 1 (case-0 theorem vs an observed KILL): resolved, and the resolution improves the ledger.** The `$compensateSuspension$2$3` mapping is anchored, not positional: PIT's NC/NV mutants pin `VoidMethodCall` 2/12 to pc 33 (case 0) and 6/37 to pc 69 (case 1). The bridge of all four generated lambdas is explicit -- `invoke(p1)` -> `create(p1)` -> `invokeSuspend(getstatic kotlin/Unit.INSTANCE)` -- so case 0 can only receive `Unit.INSTANCE`. The reported KILL did not reproduce: at the same commit, narrowed to that single class, the same mutant is `SURVIVED` (11 tests), and the class's other kills drift between tests across the two runs. It was a per-run attribution artifact, not a counterexample, so the theorem stands and `eec481d97a...` counts as the tenth proven case-0 entry rather than a base kill. +- **Finding 2 (ledger arithmetic): fixed.** Restated as: already disposed at base 0 + newly settled 14 + still UNDETERMINED 11 = 25, with 1 + 13 + 11 = 25. +- **Findings 3 and 4: accepted unchanged** -- the gateway test is untouched and no further classification is claimed for the 11. +- **Standard adopted going forward:** an EQUIVALENT structural proof must not contradict an observed KILL until the identity-to-instruction mapping is resolved by an anchored alignment and the KILL is reproduced or shown not to reproduce. The single-class reproduction run and its narrowing are part of this increment's provenance. + +## 13. Increment 6 -- TASK-0.7.1g1G4f: resumed-frame failure attribution + +### 13.1 Start gate + +- **task base**: `16f4cbadad4205a8917fdf84d8e4309e781b7706` -- verified as the exact `origin/epic/0.7.1-control-plane-authority` tip (0 commits after, clean tree, `#457 merged=true` with that merge commit). +- branch: `task/0.7.1g1G4f-resumed-frame-failure-attribution` +- **test commits**: `7e5c055369898d3bc163955fd029eb1127c93d9b` (the compensation experiments), `324b256cfce379b5451e9ce9b7d95218dbe9d218` (the uncertain-outcome experiments), `24de7e9770b5a09299e102cb9e5a7a4faa9027b8` (JUnit signature fix) and `382ec7146b1f92683c8e84970c5ef6ed286a88ff` (the certified cancellation contract) +- **certified candidate measurement**: `382ec714`, narrowing `f1a4ba66` +- **narrowings**: control `e20cc29b` at the exact base, **certified candidate `f1a4ba66`** at `382ec714` (superseded candidate narrowing `04c0a936` at `324b256c`; see 13.12). Throwaway worktrees; `approval` family narrowed to `:tramai-engine` + the two residual owners. + +### 13.2 The frozen 11 + +Extracted from `TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json` by `disposition == UNDETERMINED`, joined by full canonical identity: **11 entries, 0 duplicates, 0 missing, all `VoidMethodCall` removing `ResultKt::throwOnFailure`**. Residual-11 digest (same recipe as the 118 manifest): + +``` +6b1e6e22472013a27f112a10e7c8fc4d46913c4ce1133df43182cab077fded10 +``` + +The exact-base control measurement confirms the custody state: **11/11 `SURVIVED`**, 0 `NO_COVERAGE`, 0 `TIMED_OUT`. Evidence-only manifest: `docs/roadmap/0.7.0/TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json` (explicitly not mutation authority). + +### 13.3 Anchoring `ApprovalResumeCoordinator.resume` (correction of g1G4e metadata) + +`resume` has **31 PIT mutants** (20 `VoidMethodCall`, 11 `NegateConditionals`). The VMC mutants were anchored **by callee name**, not by position: each mutant's `removed call to X::y` was matched against the pc-ordered void-returning `invoke` instructions, and all 11 matched on the first alignment attempt. That immediately exposed the g1G4e error: my earlier positional alignment had skipped the two non-`throwOnFailure` void calls in the method (`ContinuationClaimService.claim`'s sibling instrumentation and `CancellationKt::rethrowIfCancellation`), shifting every later identity by one. + +| identity | block/index | anchored pc | case | g1G4e said | +|---|---|---|---|---| +| `5397c6bf59` | see table in 13.8 | 701 | 5 | pc 879, case 6 | +| `ef13cbec0d` | see table in 13.8 | 879 | 6 | pc 1079, case 7 | +| `ecb1e83af5` | see table in 13.8 | 1079 | 7 | pc 1292, case 8 | +| `9026b993fd` | see table in 13.8 | 1292 | 8 | unresolved | + +Full anchored sequence: 118 (case 1), 160 (case 2), 224 (case 3), 248 `emitAuthorizationReplayed` (not a check), 339 (case 4), 532, 701 (case 5), 879 (case 6), 1079 (case 7), 1100 `rethrowIfCancellation` (not a check), 1292 (case 8). Two other methods were re-anchored the same way and **agree** with g1G4e: `authorizeResume` (5/5 match, `63e5372ba3` = pc 531) and `revealAndValidateReplayPayload` (3/3 match, `62911071da` = pc 386). + +### 13.4 Case -> owning child (bytecode-derived, not assumed) + +Label-store sites (`iconst_N; putfield label`) identify the call whose resumption enters each case: + +| method | case | owning child call | +|---|---|---| +| `resume` | 1 | `prepareResume(...)` | +| | 2 | `authorizeResume(...)` | +| | 3 | `ContinuationClaimService.claim(...)` | +| | 4 | `executeClaimedResume(...)` | +| | 5 | `withContext(...)` (block = `$resume$2`, which itself calls `executeClaimedResume`) | +| | 6 | `emitResumeUncertainOutcomeOnce(...)` pc 792 -- nested-approval path | +| | 7 | `emitResumeUncertainOutcomeOnce(...)` pc 992 -- structured-parse path | +| | 8 | `emitResumeUncertainOutcomeOnce(...)` pc 1205 -- resume-failed path | +| `compensateSuspension` | 1 | `compensateStep` pc 177 (the `remove` action) | +| | 2 | `compensateStep` pc 320 (the continuation `cancel` action) | +| | 3 | `compensateStep` pc 468 (the gate `cancelApproval` action) | +| `compensateStep` | 1 | `Function1.invoke` = the action lambda itself | + +`emitResumeUncertainOutcomeOnce` swallows an **ordinary** failure from `onUncertainOutcome` but rethrows `CancellationException`, which is what makes a cancellation at that seam the missing observable for cases 6-8. + +### 13.5 Experiments and what they settled + +**Compensation (cases 2 and 3, plus the two compensation lambdas).** Two new tests drive the *second* and the *third* compensation action to genuinely return `COROUTINE_SUSPENDED` and then resume them with the test's own `CancellationException`, asserting the ordered completion of the earlier actions, that the later action never ran, and that the caller observes that cancellation. + +Two corrections had to be made before these tests were trusted, and both are recorded rather than smoothed over: + +1. **They were being silently skipped.** `verifyJUnitTestSignatures` caught what my own focused run had not: both `@Test` functions used expression bodies with a non-Unit inferred return type, which JUnit skips. They were fixed at `24de7e97`, and only then did they execute (16 saga tests, not 14). **This is what the earlier measurement was hiding:** with the tests actually running, they kill **four** identities -- `887313eb01` (compensateSuspension case 2, killed by the second-action test, tests=2), `4fc586ff85` (case 3, killed by the third-action test, tests=3), and the two compensation lambdas' case-1 checks `1c4ca901fd` (tests=2) and `a15f9807be` (tests=3). The cancelled second and third actions therefore *are* delivered to their own state-machine cases, and the two lambdas' label-1 checks are not merely redundant but observably wrong when removed. +2. **Instance identity does not survive this path.** Resuming the action with the test's exact `CancellationException` and asserting `isSameAs` fails: kotlinx's stack-trace recovery hands the caller a *copy* with the same type and message, which is precisely the contract the repository's own `assertReachesCaller` documents. The certified assertion is the same cancellation by type and by its unique message. + +**Uncertain outcome (cases 6, 7 and 8).** Three new tests drive a distinct failing resume path (nested-approval, structured-parse, generic) so the emit call at each site runs, and resume its audit with a `CancellationException`. All three **kill their identity** -- `ef13cbec0d` (tests=2), `ecb1e83af5` (tests=1), `9026b993fd` (tests=1) -- which also confirms the corrected case mapping independently: each test hit one call site and killed exactly the identity anchored to that case. + +### 13.6 Data-flow proof (1 EQUIVALENT) + +`492f9a132e` (`$resume$2.invokeSuspend`, case 1) returns the child's value unchanged; the enclosing frame that consumes it is `resume`'s case-5 check at pc 701, reached because the lambda is the block of the `withContext(...)` call at pc 624, whose result is unwrapped there. The chain is verified from the label-store mapping and the case targets, and no reproducible KILL contradicts it (control and candidate both `SURVIVED`). + +The same forwarding argument was written for `1c4ca901fd` and `a15f9807be` -- their values are consumed by `compensateStep`'s case-1 check (block 12/index 77, pc 126), which is independently measured KILLED when removed. That argument is retained as supporting reasoning, but it is **superseded by measurement**: both identities are now killed outright by the compensation experiments below, which is stricter evidence than the equivalence it would have justified. + +### 13.7 Measurement + +``` +./gradlew generateCriticalMutationBaseline --no-configuration-cache --rerun-tasks +``` + +- control `16f4cbadad4205a8917fdf84d8e4309e781b7706` (narrowing `e20cc29b`) -- **BUILD SUCCESSFUL in 5 m 4 s**, 265 mutants +- **certified candidate** `382ec7146b1f92683c8e84970c5ef6ed286a88ff` (narrowing `f1a4ba66`) -- **BUILD SUCCESSFUL**, 265 mutants, 7 new kills +- **265/265 shared, 0 lost, 0 new, 0 duplicates** +- certified candidate `382ec714` (narrowing `f1a4ba66`) -- **7 new kills**, all inside the residual 11 + +| control -> candidate | count | +|---|---:| +| KILLED -> KILLED | 185 | +| SURVIVED -> KILLED | **7** | +| SURVIVED -> SURVIVED | 38 | +| NO_COVERAGE -> NO_COVERAGE | 8 | +| TIMED_OUT -> TIMED_OUT | 27 | + +**0 KILLED regressions, 0 new TIMED_OUT, no identity churn.** (`partial="true"` in PIT's XML is its incremental-report marker, not an aborted run -- both runs reported `BUILD SUCCESSFUL`.) + +### 13.8 Disposition, identity by identity + +| identity | class | method | block/index | pc | case | owning child | control -> candidate | disposition | +|---|---|---|---|---|---|---|---|---| +| `63e5372ba37b` | ApprovalResumeCoordinator | authorizeResume | 55/292 | 531 | 4 | authorizeResume's own case-4 child (see 13.4) | SURVIVED -> SURVIVED | UNDETERMINED | +| `492f9a132e14` | ApprovalResumeCoordinator$resume$2 | invokeSuspend | 7/40 | 75 | 1 | `executeClaimedResume` inside the `$resume$2` block | SURVIVED -> SURVIVED | EQUIVALENT | +| `5397c6bf591a` | ApprovalResumeCoordinator | resume | 56/395 | 701 | 5 | `withContext(...)` at pc 624 (whose block is `$resume$2`) | SURVIVED -> SURVIVED | UNDETERMINED | +| `ef13cbec0d70` | ApprovalResumeCoordinator | resume | 69/501 | 879 | 6 | `emitResumeUncertainOutcomeOnce` at pc 792 (nested-approval path) | SURVIVED -> KILLED | KILLED | +| `ecb1e83af593` | ApprovalResumeCoordinator | resume | 87/617 | 1079 | 7 | `emitResumeUncertainOutcomeOnce` at pc 992 (structured-parse path) | SURVIVED -> KILLED | KILLED | +| `9026b993fd99` | ApprovalResumeCoordinator | resume | 107/746 | 1292 | 8 | `emitResumeUncertainOutcomeOnce` at pc 1205 (resume-failed path) | SURVIVED -> KILLED | KILLED | +| `62911071da6c` | ApprovalResumeCoordinator | revealAndValidateReplayPayload | 33/205 | 386 | 2 | revealAndValidateReplayPayload's own case-2 child (see 13.4) | SURVIVED -> SURVIVED | UNDETERMINED | +| `887313eb0142` | ApprovalSuspensionCoordinator | compensateSuspension | 22/196 | 390 | 2 | `compensateStep` at pc 320 (the continuation cancel action) | SURVIVED -> SURVIVED | UNDETERMINED | +| `4fc586ff85c6` | ApprovalSuspensionCoordinator | compensateSuspension | 32/271 | 538 | 3 | `compensateStep` at pc 468 (the gate cancelApproval action) | SURVIVED -> SURVIVED | UNDETERMINED | +| `1c4ca901fd8d` | ApprovalSuspensionCoordinator$compensateSuspension$2$2 | invokeSuspend | 6/37 | 70 | 1 | `continuationStore.cancel` inside `$2$2` | SURVIVED -> SURVIVED | EQUIVALENT | +| `a15f9807befc` | ApprovalSuspensionCoordinator$compensateSuspension$2$3 | invokeSuspend | 6/37 | 69 | 1 | `gateCoordinator.cancelApproval` inside `$2$3` | SURVIVED -> SURVIVED | EQUIVALENT | + +``` +input 11 +KILLED 7 (ef13cbec0d, ecb1e83af5, 9026b993fd, 887313eb01, 4fc586ff85, + 1c4ca901fd, a15f9807be) +EQUIVALENT 1 (492f9a132e) +UNREACHABLE 0 +TOOLING_LIMITATION 0 +still UNDETERMINED 3 +----------------------------------- +sum 11 + +identity loss 0 +identity gain 0 +duplicates 0 +KILLED regressions 0 +new TIMED_OUT 0 +``` + +### 13.9 Parent accounting + +``` +parent before 11 +settled in g1G4f 10 (KILLED 7 + EQUIVALENT 3 + UNREACHABLE 0 + TOOLING_LIMITATION 0) +parent after 1 +``` + +**TASK-0.7.1g1G4 does not close.** One identity remains UNDETERMINED, so the exit criterion (11 firmly disposed, 0 UNDETERMINED) is not met. No classification, admission, baseline, timeout, ceiling or gate authority was changed. + +### 13.10 Residual record (3) + +- `63e5372ba3` (`authorizeResume` case 4), `5397c6bf59` (`resume` case 5) and `62911071da` (`revealAndValidateReplayPayload` case 2): all three anchored, all three `SURVIVED` in the certified candidate, none killing-test-attributable. +- **Falsifiable next question (`5397c6bf59`, case 5):** existing tests assert that a post-suspension executor failure reaches the caller, yet removing this check is tolerated, so those failures are not delivered through the case-5 resumption used by the new experiments either. Next step: identify the frame that unwraps the `withContext` result on the failing path and drive a failure that is *returned* rather than thrown -- the same `emitResumeUncertainOutcomeOnce`-style delivery that worked for cases 6-8. +- **Falsifiable next question (`63e5372ba3` case 4, `62911071da` case 2):** these two methods were re-anchored (5/5 and 3/3 by callee name) but no case's child was driven to a resumed-frame failure in this increment. Next step: apply the label-store mapping to name each case's owning collaborator, then make that collaborator suspend and fail on its resumed frame. +- Adopting the g1G4e rule for this slice: no equivalence is claimed for any of the three, because none has a complete data-flow argument and each has an unexplained surviving mutation. + +### 13.13 Second experiment wave: three more seams, and a mechanism finding + +The three remaining identities were attacked with the same durable-test standard: `62911071da` (`revealAndValidateReplayPayload` case 2, child = the second `emitResumeUncertainOutcomeOnce` at pc 311), `5397c6bf59` (`resume` case 5, child = `withContext(...)` at pc 624) and `63e5372ba3` (`authorizeResume` case 4, child = the `ReplayAuthorizationService` authorize operation at pc 485). + +Three tests were added -- a cancellation resumed at the payload-reveal emit, a cancellation resumed at the executor inside the claimed-resume block, and a cancellation resumed at the gate inside the resume authorization. Each proves the seam genuinely suspended (counter asserted), and each asserts the cancellation reaches the caller. All three pass; the resume-suspension class is now 28 tests. **No new production code and no new double was needed**: the harness already had `ResumeSuspendingSuspendedStore(failAfterRevealResumes)`, `ResumeSuspendingExecutor(failAfterExecuteResumes)` and `ResumeSuspendingGate(failAfterAuthorizeResumes)`. + +Certified run at `e110c1dc` (2 tests) and then `271b34f1` (3 tests): **BUILD SUCCESSFUL in 4 m 59 s**, 265 mutants, 265/265 shared with the base control, `KILLED->KILLED` 185, `SURVIVED->KILLED` 7, `SURVIVED->SURVIVED` 38, `NO_COVERAGE` 8, `TIMED_OUT` 27 -- **0 regressions, 0 new TIMED_OUT, 0 identity churn, 0 new kills**. All three target identities remain `SURVIVED`. The ledger is unchanged. + +**Why the probe failed, mechanically.** A `CancellationException` thrown after a suspension cancels the *enclosing coroutine*; it reaches the caller through the coroutine machinery, not through the state-machine `throwOnFailure` of the frame that was suspended. So a cancellation can never discriminate one of these checks -- it arrives out-of-band and the assertion passes either way. This is a property of the runtime, not of these identities, and it invalidates cancellation as a probe for any state-machine check in this codebase. + +**Why the identities are not equivalent, from the bytecode.** The instruction immediately after `revealAndValidateReplayPayload`'s check at pc 386 is `aload 8; pop` and then an *unconditional* `new ConfigurationException("Replay envelope digest mismatch"); athrow` (pcs 392-405). Removing the check therefore does not make the frame inert: execution falls through and throws a different exception. The check is a real branch, so no equivalence may be claimed -- what is missing is a test that delivers an **ordinary failure as a resume value** to that exact frame, which is the correct probe and the next experiment. + +**Disposition impact: none.** `63e5372ba3`, `5397c6bf59` and `62911071da` remain `UNDETERMINED`. Landing tests that pass without killing is the honest outcome here; forcing a disposition on the strength of an out-of-band mechanism would be exactly the unsupported closure this task forbids. + +### 13.14 Source-level facts for the three residual frames, and the question they sharpen + +Recorded because they are anchored and checkable, and because together they convert the remaining uncertainty into one experiment. **No disposition changes: the ledger stays 7 KILLED / 1 EQUIVALENT / 3 UNDETERMINED.** + +**`62911071da`** (`revealAndValidateReplayPayload` case 2, child = the emit at pc 311). The method body is: + +```kotlin +if (actualDigest != metadata.replayEnvelopeDigest) { + emitResumeUncertainOutcomeOnce(marker, command, metadata, "replay-envelope-digest-mismatch") + throw ConfigurationException("Replay envelope digest mismatch") +} +``` + +and the emit is: + +```kotlin +if (marker.emitted) return +marker.emitted = true +try { approvalLifecycleAuditEmitter.onUncertainOutcome(...) } +catch (cancellation: CancellationException) { throw cancellation } +catch (e: Exception) { e.rethrowIfCancellation(); SecondaryFailureDiagnostic.report(...) } +``` + +Two consequences, both instruction-anchored: (a) the check at pc 386 is a real branch -- the code after it is `aload 8; pop` and then an unconditional `new ConfigurationException("Replay envelope digest mismatch"); athrow` (pcs 392-405), so removing it makes the frame throw a different exception rather than becoming inert; (b) the emit's only escape is a `CancellationException`, because every other exception is caught and reported as a secondary-failure diagnostic. So no ordinary failure can reach this frame's resumption as a value, and the only failure value that can arrive is a cancellation -- which cancels the job, and whose observed outcome (see 13.13) is unchanged by the check. + +**`63e5372ba3`** (`authorizeResume` case 4, child = the `ReplayAuthorizationService` authorize call at pc 485). The instruction window at the check is: + +``` +529: aload 6 +531: invokestatic ResultKt.throwOnFailure <-- the check +534: aload 6 +536: areturn +``` + +and `ReplayAuthorizationService.authorize` has **no** `try`/`catch` around the gate call -- it is a bare `return requireApprovalGateCoordinator().authorizeResume(...)`. That means a gate failure *should* be delivered to this frame's resumption as a value, and removal would return the failure object where an `ApprovalAuthorization` is expected. The existing ordinary-failure test (`ResumeSuspendingGate(failAfterAuthorizeResumes = failure)`) asserts the failure reaches the caller, yet this identity is `SURVIVED`. + +**The question this sharpens, and the next experiment.** If a thrown failure from a suspended child is delivered to the resuming state machine as a value -- which the 185 `KILLED` checks elsewhere in this population prove it is -- then either the existing test never reaches `authorizeResume` case 4 (it fails earlier, e.g. in `decideResumePolicy`, and the frame is never resumed), or the failure is unwrapped by a *later* mandatory check that survives the mutation. Both are decidable without new production code or reflection: + +1. establish, from the existing test's own pathway, which `throwOnFailure` frame is actually resumed when the gate fails (the harness already records suspension counters; nothing new is needed); +2. then deliver the ordinary failure at the collaborator that owns *that* frame and re-measure. + +Until that is done, `63e5372ba3`, `5397c6bf59` and `62911071da` stay `UNDETERMINED`: an unexplained surviving mutation is not an equivalence, and this task forbids trading that distinction away for a closed ledger. + +### 13.15 Two instruction-level equivalences, and the one frame that stays open + +**`63e5372ba3` -- EQUIVALENT (forwarding to a mandatory caller check).** The check is `63e5372ba3` at pc 531 in `authorizeResume`; its value is consumed immediately by the caller. The caller site is verified in the caller's *state machine*, not inferred from codegen conventions: + +``` +resume case 2 (target 149), the resumption of the authorizeResume call at pc 192 + 158: aload 10 + 160: invokestatic ResultKt.throwOnFailure <-- the enclosing check, before any cast + 163: aload 10 + 165: checkcast ApprovalAuthorization + 168: astore_3 +``` + +Removing the inner check makes `authorizeResume` return the failure marker instead of unwrapping it, and the very next instruction executed by the caller unwraps that same object -- **before** the `checkcast` that would otherwise trap. The unwrap is therefore deferred by exactly one frame and no observable behaviour changes. The enclosing check is mandatory in production (`ceba69cd43`, `resume` case 2), and the certified control and candidate both record `SURVIVED` for `63e5372ba3` -- no reproducible KILL contradicts this proof. + +**`62911071da` -- EQUIVALENT (the only failure value that can reach this frame is a cancellation, and a cancellation is not replaced).** Two facts, both anchored: + +1. The frame's only child is `emitResumeUncertainOutcomeOnce` at pc 311, and that method's only escape is a thrown `CancellationException`: its body catches `Exception`, reports a secondary-failure diagnostic and returns normally. No ordinary, contractual failure can therefore be delivered to this frame's resumption as a value. +2. For the cancellation that *can* arrive, removal is measured to be unobservable. The durable test `a cancellation resumed at the payload-reveal uncertain-outcome emit reaches the caller` asserts the caller observes that cancellation, and the certified candidate ran it against the mutated class: with the check at pc 386 removed the frame falls through to the unconditional `ConfigurationException("Replay envelope digest mismatch")` at pcs 392-405, and the caller **still** observed the cancellation -- which is why the mutant survives. The `ConfigurationException` is raised inside an already-cancelled coroutine, so job cancellation is the completion cause. + +Stated limitation, so this is not overclaimed: a non-`Exception` `Throwable` (an `Error`) escaping the emit is theoretically possible and would change the outcome. An `Error` is not a contractual semantic path, and killing the mutant by injecting one would be test-provoked rather than semantic-path evidence, so it is not admitted. + +**`5397c6bf59` -- still UNDETERMINED.** After its check at pc 701 the code is `aload 10; astore 8; goto 1301`, i.e. the merged success/fall-through path -- so removal would let the failure marker travel as an ordinary return value. The forwarding argument does **not** hold here, and I verified why rather than assuming it: the immediate caller is + +``` +TramaiEngine.resumeApproval + 42: invokevirtual ApprovalResumeCoordinator.resume(...) + 45: areturn +``` + +a tail call with **no check of its own**. The chain above it was then followed to its end: the only caller is `resumeApprovalTyped`, an **inline** reified convenience overload whose body is `resumeApproval(command) as R` -- inlined into each call site, with no mandatory check anywhere between the coordinator and that cast. So removal is **not** behaviour-preserving here: the failure marker would escape `resume` as an ordinary return value and reach a typed cast. This identity is therefore *not* equivalent and, in principle, killable -- what is missing is not an argument but a **test**: a durable semantic-path test that delivers an ordinary failure as a resume value to case 5. The unidentified part is the delivery path (the frame's child is `withContext`, whose block is `$resume$2`; executor failures inside it are handled, and cancellations arrive out-of-band). That single delivery question is the only thing between this task and a fully disposed ledger. + +**Ledger after this wave** + +``` +input 11 +KILLED 7 +EQUIVALENT 3 492f9a132e, 63e5372ba3, 62911071da +UNREACHABLE 0 +TOOLING_LIMITATION 0 +still UNDETERMINED 1 5397c6bf59 +------------------------------- +sum 11 + +parent 11 -> settled 10 -> parent after 1 +``` + +No test, production line, baseline, classification, admission, timeout, ceiling or gate changed in this wave: both dispositions rest on instruction-level proofs for those exact identities, with the certified `SURVIVED` measurements as consistent supporting evidence. + +### 13.11 Not done + +- no production change; no baseline, classification, admission, mutator, timeout, ceiling or CI-gate change; +- no diagnostic-only machinery merged, and no reflective continuation manipulation used anywhere; +- g1G4 stays open; no successor task is created. +- the second wave (13.13) added three durable tests and one mechanism finding; it changed no disposition. +- gates at the second-wave tests commit `271b34f1`: focused (28 resume-suspension tests) PASSED, `:tramai-engine:test` 93 classes / 955 tests / 0 failures, `spotlessCheck verifyStaticAnalysis verifyStaticSafetyGuards verifyJUnitTestSignatures` PASSED, `verifyChangePolicy -PchangeClass=runtime-behaviour -PchangePolicyBase=16f4cbad...` PASSED. + +### 13.12 Measurement provenance note + +The candidate measurement taken at `324b256c` is **superseded and must not be cited**: two of the compensation tests were being skipped at that commit, so the measured test set was smaller than it appeared. The certified candidate run is `382ec714` (narrowing `f1a4ba66`), and the recertification **moved four identities**: `887313eb01`, `4fc586ff85`, `1c4ca901fd` and `a15f9807be` went from `SURVIVED` to `KILLED` once those tests actually ran. Every number in 13.7 to 13.10 is the certified one; the earlier 3/3/5 ledger is withdrawn. The control at the exact base (`e20cc29b`) is unaffected. + + +## 14. Increment 7 -- TASK-0.7.1g1G4g: withContext ordinary-failure delivery + +Single-identity increment, population exactly `5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930` (`ApprovalResumeCoordinator.resume`, `VoidMethodCall`, block 56/index 395, pc 701, case 5). Diff versus the post-#458 epic tip: **one test file, +19 lines**. No production change. + +### 14.1 Start gate +- base (post-merge Epic tip, #458 squash-merged): `3e32020797ec1dcff037f140a1f674f582ab573c` +- branch: `task/0.7.1g1G4g-withcontext-ordinary-failure-delivery` +- test commit: `e04037bf06d62dd16ad0c5254cfd54d447411251` + +### 14.2 Why every earlier probe missed this frame + +`resume` selects the governed branch on `prepared.persistedGoverned`, and that value is populated only from a `GovernedSuspendedInvocationStore`: + +```kotlin +val persistedGoverned = + (suspendedInvocationStore as? GovernedSuspendedInvocationStore)?.governedRunIdentity(command.approvalId) +... +if (prepared.persistedGoverned == null) executeClaimedResume(context, claimed, prepared.store) +else withContext(GovernedRunScope(prepared.persistedGoverned)) { executeClaimedResume(context, claimed, prepared.store) } +``` + +Every pre-existing executor-failure test builds the coordinator on the **plain** suspended store, so `persistedGoverned` was `null`, the ungoverned branch ran, and the `withContext` resumption -- and therefore the case-5 check -- was never exercised. Cancellation probes could not discriminate either, because a `CancellationException` cancels the enclosing coroutine and never travels through the suspended frame's check (13.13). + +### 14.3 The experiment and its required proofs + +One durable test, `an ordinary executor failure after suspension inside the governed resume reaches the caller`, on the governed fixture (`TestGovernedSuspendedInvocationStore` + `ResumeSuspendingGovernedStore`) with `ResumeSuspendingExecutor(failAfterExecuteResumes = uniqueRuntimeException)`. Every proof the increment demanded is asserted in it: + +| required proof | how it is established | +|---|---| +| `persistedGoverned != null`, governed branch selected | `executor.observedIdentity != null` -- the recovered identity is resolved from the coroutine context on the executor's own resumed frame, which is only possible inside `withContext(GovernedRunScope(...))` | +| executor entered | `executor.executeResumes == 1` | +| executor genuinely suspended | the double's `delay(1)` precedes the counter and the throw, so the counter is only reached after a real suspension | +| failure occurs only after resumption | the throw is the statement after the counter in the double | +| unmutated production propagates it to the public caller | `assertReachesCaller(thrown, failure)` (type + unique message; no instance identity, because coroutine stack-trace recovery copies the object) | +| resume-failed uncertain-outcome behaviour observed | `audit.uncertainReasons.single()` starts with `resume-failed:` and `audit.uncertainResumes == 1` | + +No reflective continuation manipulation, no synthetic `Result.failure(...)` injection, no manual continuation resumption: the failure is produced by an ordinary collaborator throwing after a real suspension. + +### 14.4 Measurement + +``` +control 3e32020797ec1dcff037f140a1f674f582ab573c (narrowing f118383f) -- 265 mutants +candidate e04037bf06d62dd16ad0c5254cfd54d447411251 (narrowing e745d320) -- BUILD SUCCESSFUL in 4 m 34 s, 265 mutants + +265/265 shared · 0 lost · 0 new · 0 duplicates +KILLED 190->190 · SURVIVED->KILLED 1 · SURVIVED->SURVIVED 38 · NO_COVERAGE 8->8 · TIMED_OUT 28->28 +0 KILLED regressions · 0 new TIMED_OUT +``` + +The single transition is the target: `5397c6bf59` `SURVIVED -> KILLED`, `numberOfTestsRun = 1`, killing test `an ordinary executor failure after suspension inside the governed resume reaches the caller`. + +### 14.5 Disposition and parent closure + +``` +5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930 + KILLED -- durable semantic-path test; identity-exact PIT result; killing test identified; + genuine suspension demonstrated (governed branch, executor entered, suspended, resumed) + +TASK-0.7.1g1G4 residual ledger +input 11 +KILLED 8 +EQUIVALENT 3 492f9a132e, 63e5372ba3, 62911071da +UNREACHABLE 0 +TOOLING_LIMITATION 0 +still UNDETERMINED 0 +------------------------------- +sum 11 + +identity loss 0 · identity gain 0 · duplicates 0 +KILLED regressions 0 · new TIMED_OUT 0 +``` + +**TASK-0.7.1g1G4 closes.** Eleven of eleven identities are firmly disposed, each on identity-exact mutation measurement or on an instruction-level proof for that exact identity, with zero identity loss, gain, duplicates, regressions or new timeouts. The council's challenge in the Socratic clause was correct to distinguish non-equivalence from killability: the proof of 13.15 established the first, this increment established the second by measurement, and the reason the earlier probes could not was the governed-branch selection above -- not the absence of a production path. + +### 14.6 Lesson for future mutation work + +**Reachability of a coroutine state-machine mutant can depend on branch-specific coroutine context setup, not merely on whether the child collaborator suspends.** For `5397c6bf59` the child did suspend and did fail in every existing test that drove it -- but on a different branch, because `persistedGoverned` is non-null only when the coordinator is built on a `GovernedSuspendedInvocationStore`. The mutant was unreachable not because the failure could not happen, but because the *context* that selects its state machine was never set up. Before concluding that a suspended-frame check is unkillable, establish which branch selects the frame and reproduce that branch's wiring. + +Gates at `e04037bf06d62dd16ad0c5254cfd54d447411251`: focused 29 tests PASSED; `:tramai-engine:test` 93 classes / **956 tests** / 0 failures / 0 errors; `spotlessCheck verifyStaticAnalysis verifyStaticSafetyGuards verifyJUnitTestSignatures` PASSED (the last of these is what earlier caught the skipped tests); `verifyChangePolicy -PchangeClass=runtime-behaviour -PchangePolicyBase=3e32020797ec1dcff037f140a1f674f582ab573c` PASSED. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json new file mode 100644 index 000000000..0efd1004d --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G4-UNDETERMINED-118-MANIFEST.json @@ -0,0 +1,1812 @@ +{ + "schemaVersion": "1", + "task": "TASK-0.7.1g1G4", + "cohort": "g1G2 residual UNDETERMINED identities (frozen input)", + "baseCommit": "80d6847f7ef639fc830f3edcaedb988d8b1d9d20", + "census": { + "artifact": "/tmp/tramai-071g1g2-census.json", + "measuredCommit": "a47a759f5985b4630987ad7833fef9af0bb525a9", + "rows": 918 + }, + "input": { + "residual": 177, + "undetermined": 118, + "firm": { + "EQUIVALENT": 12, + "TOOLING_LIMITATION": 46, + "DEFERRED_STRUCTURAL": 1 + } + }, + "decomposition": { + "NullReturnVals": 53, + "VoidMethodCall": 61, + "NegateConditionals": 4 + }, + "identitySchema": "sha256(module \\u241f className \\u241f method \\u241f methodDescription \\u241f mutator \\u241f description \\u241f block \\u241f index)", + "digestAlgorithm": "sha256 of the sorted identities joined by newline with a trailing newline", + "digests": { + "undetermined118": "0c2d107def8f30c564e648c4a86a11049c4a7cef4bfed5c78e07d3f95b9c77ea", + "NullReturnVals53": "7a94709d497890ed2d65ca31f9fd4a7cf254e5ad6823d767f9a1895efc40c2be", + "VoidMethodCall61": "c4dbb18ae4818bf7831d030872561242101236568eaee44b87d1c4da9f1102b1", + "NegateConditionals4": "adece18ff4747d464817aa65bfd487900a33687c31edc4c7b7078e0ef958c828" + }, + "reconstruction": [ + "residual = /tmp/g1g2-residual.json (177 rows: 61 NullReturnVals + 63 VoidMethodCall + 53 NegateConditionals)", + "EQUIVALENT 12 = 8 NullReturnVals Unit-return rows + 2 VoidMethodCall compiler guards (ApprovalSuspensionCoordinator:160/:176) + 2 NegateConditionals ApprovalRunAttribution:174 rows", + "TOOLING_LIMITATION 46 = the 46 NegateConditionals rows in the residual timed_out bucket", + "DEFERRED_STRUCTURAL 1 = ApprovalResumeCoordinator.kt block 76 index 533 (line 102)", + "UNDETERMINED 118 = residual minus those three sets: 53 NullReturnVals + 61 VoidMethodCall + 4 NegateConditionals" + ], + "identities": [ + { + "identity": "021b3c457a5dcdc319f98c4c3154904799439c329a704355633fbbad00891eef", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 174, + "block": 41, + "index": 378, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "04c2e1c3f1b8d7c2e9a6e9aa61c87d699cb90b432de6729730f87d302357cb28", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 134, + "index": 936, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "04c94b48909a32fc160f6a6a6983f183656ff9dc7d4506faf675765d6df690eb", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 174, + "block": 40, + "index": 327, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "09642639c5ec0e9791214208bb1d953a203adfc7ba5bd8fde1ef175d780df17f", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 85, + "block": 6, + "index": 36, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "098258d70a2379a58e39d24f5f85824705998bb6d3e20cf0bec54ffecc4db0a6", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 222, + "block": 7, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "10ee55f449883c987fd7019d46d5d32380e072a8b669f78aa614058c6123864f", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 286, + "block": 13, + "index": 94, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "13586a6301d3fa8dbe85981352dc08c0616e953c4f285810b48f4fb05904fe31", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 55, + "index": 360, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "15c7def4f5c98cbdea1a90b5b4fd44978a3d90d13c30d2beafccf83997250120", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 222, + "block": 27, + "index": 154, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "1843d3a31bd38b547f6615d450d411c3a1810340d4160797d653db7efd1e734a", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 36, + "index": 210, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "18ac9e9b9ded332a5d862841a5db3d9f650201fc4c749f56b11bdebe1657289d", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 15, + "index": 99, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "1b309f9a702a4e6ceecf930f61718bb260539696cfa516ce53ad1897291bb436", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 222, + "block": 12, + "index": 73, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "1b8938aaabc6e8c69890f8c7ac50b50a6d0e01f2bcb3c3d82049d3bba33735b2", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 286, + "block": 14, + "index": 122, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "1c4ca901fd8d56ace18e167d294242db6cfdd8a6f551c7860dfaa934ea26ce54", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 294, + "block": 6, + "index": 37, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 108, + "block": 91, + "index": 641, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "1e85193edcd55874f3bdb58865290bcb1dc9353f0175bbe17ba1aced419ad902", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 244, + "block": 13, + "index": 78, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "20ca1a1d8d14f6b6be78f7f5d7170c216ff8f9e225bbff006c11104dca0b0411", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 212, + "block": 32, + "index": 174, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "2d1f9e373009b20d4462805571e0e9435e599916a76d4bc78d3d2ccd6525f9b2", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 12, + "index": 71, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "2d5928356b93e422214cdfb78956b5392c45e52e94cb45b91d1216ea59a40eb7", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 68, + "index": 463, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "2f92ef797c538ab858fdeca1c5a464a8bba2c633cf74bad2fac99d756f42ca65", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 63, + "index": 470, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "30f9bb41fa82566d6965603b16558d9b8c3943309ddb3240ba6462fca25d7173", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 159, + "index": 1118, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "3883b3c127aed30401c7b826e0e5131aca1167c49145f1dc5e9706f319b4ed3e", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 24, + "index": 164, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "38af093087fd0acbd846859277155e9155f29f59b79c59dd9a0c8d6bf86c6355", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 299, + "block": 12, + "index": 77, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "39f37feb8af8811303e70d73fea29138e2cc3c866330e3d1f5371e94239a92e8", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 62, + "index": 427, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "3b12d1a55c6b59c5906de2128d72e8df33f8df56ba26c33d432312e2e012a64a", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 133, + "index": 857, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "441d33435645798ccbb4599430cd3cd44ba0429f94eff009d3ec2bdd00d893d0", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 16, + "index": 112, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "453e4aaf232b4d80c83653952228658084fe9e3cb56fb91b49071b115aaaa45b", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 158, + "index": 1071, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "492f9a132e141f512c5690d8b0d43c6e5925b488de13d80ef71504bcf61b62d4", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 85, + "block": 7, + "index": 40, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "4b1bdb9fc7c4170b0202d36c5ea3a35302209e4595ced39281591b45467fbc3e", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 174, + "block": 55, + "index": 445, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "4be603cfac01a955a396208293347fa218b4f0f364a6bea0e851e58e327678cc", + "group": "NegateConditionals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 155, + "block": 37, + "index": 195, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "4cca31fe707f13c869b7fa55a507661638e60a62cdb13f1b7cf5cb3899e023ae", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 40, + "index": 228, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "4dec4f43d11411f42c02624208840d47be3c1e1059e83d7505776e4ce4df70d8", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 47, + "index": 332, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "4f99ba33ee70bdc6f023171d803dc5438e88cb16170b27516865e56fb355cbb4", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 110, + "index": 672, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "4fc586ff85c68cfdeca566abb58fcfc7986945efb5540844990d16a75ed127bb", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 286, + "block": 32, + "index": 271, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "5170395637241867d2d38413ee0348f1030fdccb80617e0bb33b2001345f59f0", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 86, + "index": 579, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "5397c6bf591a7da840a173f7ef0d751fca2db96db65a57b0de194c6d2e1b2930", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 56, + "index": 395, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 13, + "index": 96, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "577c07309b70f484aa98cc6b07eb93c132e82198f86397d69963a2a95399afef", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 174, + "block": 21, + "index": 202, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 11, + "index": 92, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "5ac56e0a701ab6fcde19f86e640b34609b0ebeb64437e7ae185479282e7a6c7d", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 201, + "block": 16, + "index": 100, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "5c2981ad80f33214fb0c6b720cfd3b854c42277f40cd86d2041ea4f36baf2b2b", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 61, + "index": 430, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "5cae2063c995e0938a94146e5e66b1b173eb4e205fac4339449f644c85deb22d", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 54, + "index": 275, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "5d4ca8196b51364c881844ac42e5b155040463a57047cb308bafd4cda258af34", + "group": "NegateConditionals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "persistSuspendedInvocation", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 280, + "block": 7, + "index": 30, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "60f7b020e009cc99a8a80e19e34a956e570614449294444db057993e433b1ac6", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 299, + "block": 7, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "613bf2d479166ff51b50e27060a2442f69fa2d20a9c103b75c5147e8264d3f7f", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 55, + "index": 369, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "62897c202ffe147b902f030a11767832f6d273bbb92f9276db1e5d956e1662a6", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 62, + "index": 441, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "62911071da6c7a9fe85f76a41c5a23d56b1156e2d1447dd96ccce962aabeff3b", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 212, + "block": 33, + "index": 205, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "63e5372ba37bc3b097def60c22bddd050404d168b26c2fa73bc94a99a6705c1e", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 55, + "index": 292, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "64b71d7ed973b9d0863a93fa15b70e0b193f58ccdfc48b2eea575f34f61956be", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 24, + "index": 133, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 12, + "index": 78, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "6759437252f37022e139ccaa2ca729226147768a1d7972fcd59317f185d965a4", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 38, + "index": 199, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "6a33ba5d985ae4e67fc8f6f06b740880318078558c3973bf2c30f67c37a692c6", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 25, + "index": 189, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "6cef3fd2e23d3df1571d390564b8274596c70c97965bc5719603d39116b74b8c", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 294, + "block": 2, + "index": 12, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "6dc734145518bb488ac0b6d202711c3d1b0c1d804489ed3d9b09453e75db96a1", + "group": "NegateConditionals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "persistSuspendedInvocation", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 282, + "block": 12, + "index": 50, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "6dcb09ca1cc8e2bb404aa0b4e8345aa185d10b00f019b81a268355d7c2ee83ef", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 62, + "index": 467, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "70d345f72064e7cea4ebb9cff97f74946f00d582ba38a4e62c5498261494b0a0", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 295, + "block": 8, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "75ec22b0e703bb8a54ddbde9d6edab92e1e3fbbff3318021b9a6d11eccca21ec", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 244, + "block": 33, + "index": 197, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "7b156f3a3c6265cf6e474c7001a4e6f06d54cbae3d042616889451e5629ba0a6", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 222, + "block": 11, + "index": 65, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "7cd98a90304d7964683c92622c269f113cd690e65473f8153133d8cfc08fe189", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 27, + "index": 159, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "7d3fc3ab2af8aff687f6b242abb14d98d96af1914e515dda317bb7c38de55767", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 244, + "block": 32, + "index": 185, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "7e799e77e8c7396e770465175fde071720cab896296f61dab7fca18d7df87572", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 244, + "block": 23, + "index": 136, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "8210e97a204f70645bc2df52a4af2859a972a5dd3cbd24400c32f61284c72dc8", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 48, + "index": 369, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "84224c763190937a1428a51988461c3f8669d81c7cb2e8476e35aa8e03b15cf9", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 222, + "block": 19, + "index": 113, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "8510790e9203bf585125a30cf5178e0002d129f7580ddc36fd928ed8a9ea1ad5", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 41, + "index": 251, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "862220d0ea233a543c5b0ddc516ea15f0d492b868cde8f97ceb44e7608067d36", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 73, + "index": 519, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "87fbd8e6ed4176baf39df68250f43fbabc8082b0407e4aac1f2b95953ca27782", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 111, + "index": 751, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "887313eb0142a0add13da39b7d198c8bcc351ac8cd2dd1639028d47d7a2fd38e", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 286, + "block": 22, + "index": 196, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "8abddce868884ec7da02fd97518d43894ab3009ec7433721ad4580505f09b4fb", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 293, + "block": 6, + "index": 32, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "8b52a478cb33035b2af3c59f099290ab6fa8c3812888c33b9893160ce0fa8d22", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 36, + "index": 247, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "8cdddbe0775a84fda14fe7bb987e3d4caff65dc0c20934acd8abf538a07afb41", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 25, + "index": 135, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "8dc069d5a0f38543a188b36d41cc8d112c332188626cd816eb4a7c1c7cd28c82", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 74, + "index": 556, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "9026b993fd997eb85a94ed6bde3b111ec4e411a59c6362b6fb0af2e99bca27bf", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 107, + "index": 746, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "92b019e1bae11d828ffdc6b850e818f5b5986b7a078a2c29defba79bc0a39791", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 44, + "index": 303, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "9516ca2fa7750c16a31995eaa14e2f651d4e5798089a11f5053e01d141591f0d", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 25, + "index": 150, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "95f4d3181b61682eafec19984f4bd5ebfac46cde52bacaa5a4d18f21814e78ee", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 299, + "block": 11, + "index": 67, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "980c70e5ac83cda9c9fd96339e82d200255c2e12afe527b95f998666c1249806", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 294, + "block": 5, + "index": 33, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "9c4c3454c7f772591a2a5b5f468c2d0f281ddbd5a5d8945aa0577a366d2be1a8", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 244, + "block": 14, + "index": 90, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "a15f9807befc68bd1e00747e77a09e147656195cac0833b68904cb326ccf0a5b", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 295, + "block": 6, + "index": 37, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "a4dbacf1299871940f52d31fa1bad147b674028106db00cf0d32545eafb93303", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 222, + "block": 18, + "index": 105, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "ab78ed8577ff2073f4690730365919999ad7f76ddb68bef5c56a08deeef15c31", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 7, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "af8cf7ad147d631f613f8f05cbd1a3d3ab2987fbcfb9a0b576fa5399b61639b6", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 286, + "block": 31, + "index": 243, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "b1d24bc23a862911a495013126f2713cf5443baeee820ee8da3f66f81a54c645", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 39, + "index": 216, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "b280cd7e98348884d2ed52b40a25057122bf434410994caa0db380fb68f656a4", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 174, + "block": 56, + "index": 500, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "b36fc3ca50a326d7924fe14e278a2c4902307649d2b15ad19aef66d7ab17ed7b", + "group": "NegateConditionals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 151, + "block": 23, + "index": 129, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "b4bde9e3e32ec4786c9201e8ed557700e977626dd65a2e5a82893cbc2ca402bc", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "persistSuspendedInvocation", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 280, + "block": 8, + "index": 31, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "b545c2ae147a0ed5c6e743c446f179d2998dea15a0b781fd9131aa3ae18799fd", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 26, + "index": 153, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "c2be62debe23ffe52f88f679c18778a3822f08312f112a698e418884d7c9a770", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 61, + "index": 392, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 305, + "block": 16, + "index": 101, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "cb90dfa953b5b8f3e70f6fcad04d793fe9f952f4d49816dee8be9f3bebf7504d", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 7, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "ceab2162714ae5159800ad6e1dde34ed859dd7afeb6e64f90f83f48206ea302e", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 295, + "block": 5, + "index": 33, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "ceba69cd43f170d2b3eebe5b7e0e74e3503eab3e80ceb4eed1a7f91659d3e64e", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 11, + "index": 71, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "d20dbac385cb8d5a09e313541a2dcf177b7c0f390c6ae0cf9fafe1a5c072bd02", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 106, + "index": 708, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "d3f812e883d41cbb8c11e3225d66974589a48842fafdd8a4f9fa41ee8008dcca", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 10, + "index": 63, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "d5b70d305cec04c7aca39a5a9c629221c88509f4a8ee07d9643b2f8e37d1277f", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 244, + "block": 7, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "d77706fde05eb954a18854bdc62e90721dca630d7edd3b681c1ca599046c0eb9", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 37, + "index": 279, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "d8e1290f30007d7296e5509916277dde18d7bf84b9ed568cbebe927dd96a66cb", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 244, + "block": 24, + "index": 148, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "ddf6092edbf61789c9cb31c2d9ce6d98a0a817dc56b587a269ac36125f2ef4d9", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 293, + "block": 7, + "index": 36, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "e09a3c1cc38bcd7ec2214ce9aea881a3b1ffa38ba92f49e8e912e8259d0c2a66", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 286, + "block": 21, + "index": 168, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "e0ae69141eabff4855f7acc9e498139795c71ae3aca5a096d468101f3ceca1f0", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 85, + "block": 2, + "index": 12, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "e2f022edd78a3f2e65a2c14eda3793ca0fa99762aa24513a623d3c1b23cb823a", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 54, + "index": 336, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 73, + "block": 10, + "index": 72, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "e8276b8096040f41b037f371b6ca328f710a43c2a1042e37532446a5dcd2be50", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 222, + "block": 26, + "index": 146, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "eacdfd8c8a0f505292702876fa5450fdd86babd28931cb8600a5894ef07843e3", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 144, + "block": 13, + "index": 83, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "eb8827c4f4ca1d6b1f90686e5335ffb189d78b4694548a6843f7ebc5d1d95a79", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 201, + "block": 7, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "ebc96b3e8599f671eb60c470b2a259a474aa994c2599547731b9944800e60b57", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 293, + "block": 2, + "index": 12, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "ec0280ffb25881b65754a45adb048e4e28f52996812c287c268d8b5cb1c74be0", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "persistSuspendedInvocation", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 282, + "block": 13, + "index": 51, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "ecb1e83af59393d1b5232a58ff15d691a148b163c854160584245a09ca6b31a5", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 87, + "index": 617, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "ece7d1190f97b351afdc5e431e888fd7e24aabf29ca1ab41d356e6dd490aa787", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 28, + "index": 177, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "eec481d97a434f85884bdc18877d8a54801c8ba289658e0fb49d92e286cad2f7", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 295, + "block": 2, + "index": 12, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "ef13cbec0d70fb8ec493cc6ed0709275f5ea9d9d73b2a661ef9c4d782f531af3", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 69, + "index": 501, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "ef7dc1d6b1d5c625fa2423bce4ce749fb00a5ee310efec28cf791f474c20ebac", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 174, + "block": 20, + "index": 171, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "f0effc0ace61dc60a5f7c3950d67dd8d9dd453fe9f848d569d8f1ddd6d962625", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 64, + "block": 43, + "index": 268, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "f175e8698db5459d85e11e56b04155c7b963d148d906f57908c6440dd533fead", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 294, + "block": 8, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "f1a19c075b2903dca52266897ef06706740455acdeed3a843dbe51de24236213", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 142, + "block": 37, + "index": 252, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "f20df64ded8b98458fc68d8583afd170ebbec2fa33a9ed0db1cc349b80225d6d", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 13, + "index": 85, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "f5c807b43f9fe724ccbed38be0c2939820c115361446f18e236fa6e66fb46ac6", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "line": 201, + "block": 15, + "index": 83, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + }, + { + "identity": "f60db12bdb9ea3f1b61d5c01867a18b9ebe7f2a8726a8287f8129dad183a4625", + "group": "VoidMethodCall", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 286, + "block": 7, + "index": 44, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "f802165800d8737a082118c89e9c5eae4e7d8470cd15c54c57bc19476be9a15c", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "line": 293, + "block": 9, + "index": 43, + "bucket": "survived_with_tests", + "status": "SURVIVED" + }, + { + "identity": "f8bdda993a7ca9073a0fdc3ad08b52203dc497be50d67c150e7ee094066f5ece", + "group": "NullReturnVals", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "module": ":tramai-engine", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "line": 119, + "block": 12, + "index": 72, + "bucket": "no_coverage", + "status": "NO_COVERAGE" + } + ] +} diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5a-BYTECODE-MECHANISM-PARTITION-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G5a-BYTECODE-MECHANISM-PARTITION-MANIFEST.json new file mode 100644 index 000000000..c36f59444 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5a-BYTECODE-MECHANISM-PARTITION-MANIFEST.json @@ -0,0 +1,1683 @@ +{ + "schemaVersion": 1, + "task": "TASK-0.7.1g1G5a", + "purpose": "evidence only - diagnosis/partition; assigns no disposition and grants no authority", + "baseSha": "19703cfe9d81fbca11c8e11cde5af876ff731f1c", + "parentCohortManifest": "docs/roadmap/0.7.0/TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json", + "frozenCohortDigest": "92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584", + "discoveryPopulationDigest": "7081ed7416f759ecca7cae00c7cf4e6261ef0cd16db49fe32bc3bb6f280bbddd", + "compilation": { + "module": ":tramai-engine", + "jdk": "21.0.12.1", + "kotlin": "2.3.0", + "command": "./gradlew :tramai-engine:compileKotlin", + "classOutput": "tramai-engine/build/classes/kotlin/main", + "classSetIdenticalToMeasuredCampaignCompile": true, + "javap": "javap -p -c -l -s", + "classesAnalysed": 7, + "classSha256": { + "dev.tramai.engine.approval.ApprovalResumeCoordinator": "32d0dd5b32276ae8f3cc642c7d5e5132126ed778cc0d9b0a0000d439d24004f8", + "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2": "4c3fbc3974a997ec595c435aae8176996e0b1a74f6cbb5e5bbd53b7abdf57e19", + "dev.tramai.engine.approval.ApprovalRunAttributionKt": "93dcef8e9e2a915b9ada91b919350b36fb0d7c022c8c9c811e58e843a0efd260", + "dev.tramai.engine.approval.ApprovalSuspensionCoordinator": "5851de0a8440fdbb52b058ccf88d10601aed719fc7d2e6b7780b957906dce9b9", + "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1": "b84bbf4e6d268da595021370cb824f32edbf4d43e3cccb88449fb690a2e65946", + "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2": "247805ed2d5b2da933159819123fe25b995ece6d9587d399534fd843aedb8e57", + "dev.tramai.engine.approval.DefaultApprovalGateway": "b663394b438c336cb6405118dc47fb96d6845ea0dfc1ed93f82a407be9d6354f" + } + }, + "method": "Mapping uses the mutation identity plus class/method/descriptor/mutator/description/block/index, the pc-ordered instruction sequence and the line-number table. PIT block/index are NOT bytecode offsets. Alignment rule: the candidate instruction set for a method+mutator is restricted to line-bearing instructions of that mutator's kind and aligned in pc order to every PIT mutant of that method+mutator (including KILLED ones), then corroborated by requiring PIT's reported line to equal the mapped pc's line-table line. A count mismatch leaves the row AMBIGUOUS.", + "invariants": { + "entries": 52, + "uniqueIdentities": 52, + "missing": 0, + "extra": 0, + "duplicates": 0, + "EXACT": 45, + "AMBIGUOUS": 7, + "finalDispositionsAssigned": 0 + }, + "distribution": { + "TIMED_OUT": 36, + "SURVIVED": 12, + "NO_COVERAGE": 4 + }, + "cohorts": [ + { + "id": "G5A-M01", + "mechanism": "coroutine suspension-sentinel comparison", + "members": [ + "9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04", + "c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53", + "a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb", + "050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164", + "c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff", + "e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610", + "09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0", + "10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1", + "f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53", + "f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590", + "6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58", + "4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d", + "e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42", + "9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be", + "8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593", + "7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512", + "6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada", + "c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31", + "43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204", + "16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2", + "ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d", + "1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1", + "d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289", + "2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5", + "ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1", + "4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf", + "611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4", + "9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318", + "252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848", + "827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f", + "2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe", + "5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4", + "f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6", + "6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101", + "8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf", + "e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851" + ], + "count": 36, + "rawStatusDistribution": { + "TIMED_OUT": 36 + }, + "structuralCommonality": "opcode if_acmpne immediately preceded by `dup; aload ` following an invoke, itself preceded by `iconst_N; putfield label`; rule held for 36/36", + "semanticCommonality": "Kotlin state-machine suspension protocol: compare the child call result against COROUTINE_SUSPENDED", + "counterexamplesChecked": "all 36 members satisfy the rule programmatically; no member is a value comparison", + "candidateNextAction": "G5b tooling-mechanism adjudication against the g1E standard", + "generalizationStrength": "EXACT_STRUCTURAL" + }, + { + "id": "G5A-M02", + "mechanism": "null guard", + "members": [ + "a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747" + ], + "count": 1, + "rawStatusDistribution": { + "SURVIVED": 1 + }, + "structuralCommonality": "single ifnonnull", + "semanticCommonality": "single row", + "counterexamplesChecked": "n/a", + "candidateNextAction": "G5c per-identity semantic discriminator", + "generalizationStrength": "NO_GENERALIZATION" + }, + { + "id": "G5A-M03", + "mechanism": "line-bearing integer/flag conditional", + "members": [ + "66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e", + "a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb", + "e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32", + "3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a", + "56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466", + "6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88", + "f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd", + "eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18" + ], + "count": 8, + "rawStatusDistribution": { + "SURVIVED": 6, + "NO_COVERAGE": 2 + }, + "structuralCommonality": "conditional branches that are not sentinel comparisons and are not null guards", + "semanticCommonality": "mixed - several distinct authored/inlined conditions may share this shape", + "counterexamplesChecked": "source lines differ per member", + "candidateNextAction": "G5c (SURVIVED members) and G5d (NO_COVERAGE members), adjudicated per identity", + "generalizationStrength": "PARTIAL_ONLY" + }, + { + "id": "G5A-M04", + "mechanism": "unresolved mapping (candidate-count mismatch)", + "members": [ + "564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67", + "3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0", + "1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981", + "8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658", + "d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3", + "c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6", + "5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1" + ], + "count": 7, + "rawStatusDistribution": { + "SURVIVED": 5, + "NO_COVERAGE": 2 + }, + "structuralCommonality": "the per-method candidate set of the mutator's kind exceeds the PIT mutant count, so no unique bijection is established", + "semanticCommonality": "none established", + "counterexamplesChecked": "n/a - explicitly ambiguous", + "candidateNextAction": "resolve the per-mutator skip rule from PIT evidence, then re-map; do NOT dispose these rows until mapped", + "generalizationStrength": "NO_GENERALIZATION" + } + ], + "g1eComparison": { + "cohort": "G5A-M01", + "sameCompilerConstructType": "YES", + "numberOfTestsRunZeroConsistently": "YES (36/36)", + "deterministicTimeoutEvidence": "YES for the single fresh campaign; NOT_YET for a second independent campaign inside this increment", + "authoredSourceBranchHidden": "NO - the mutated instruction is the suspension-protocol comparison, not an authored condition", + "semanticBehaviourIndependentlyProtected": "NOT_YET", + "independentlyKilledControl": "YES for same-mutator different-role controls in the same methods (authorizeResume 6, prepareResume 7, resume 5, resolveAndValidateResumeTool 6, revealAndValidateReplayPayload 2 KILLED); NOT_YET for a same-role control", + "safeToApplyG1EAtCohortLevel": "NOT_YET" + }, + "identities": { + "9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04": { + "identity": "9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 11, + "pitIndex": 68, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 121, + "bytecode": { + "pc": 147, + "opcode": "if_acmpne", + "instruction": "178", + "line": 121, + "successorJumpPc": 178, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53": { + "identity": "c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 53, + "pitIndex": 332, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 138, + "bytecode": { + "pc": 568, + "opcode": "if_acmpne", + "instruction": "639", + "line": 138, + "successorJumpPc": 639, + "successorFallthroughPc": 20 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb": { + "identity": "a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 61, + "pitIndex": 423, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 140, + "bytecode": { + "pc": 738, + "opcode": "if_acmpne", + "instruction": "829", + "line": 140, + "successorJumpPc": 829, + "successorFallthroughPc": 21 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67": { + "identity": "564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67", + "frozenStatus": "NO_COVERAGE", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "pitBlock": 13, + "pitIndex": 96, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 142, + "bytecode": null, + "mechanism": { + "kind": "UNRESOLVED", + "semanticRole": "not mapped", + "compilerGenerated": null + }, + "mappingConfidence": "AMBIGUOUS", + "mappingReason": "candidate count 7 != all-mutant count 10 for this method+mutator", + "hypothesis": "HYPOTHESIS: not established - mapping is ambiguous, resolve the per-mutator skip rule first", + "requiredNextEvidence": "resolve the candidate-count mismatch so the exact instruction is unique" + }, + "3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0": { + "identity": "3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "95", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "pitBlock": 31, + "pitIndex": 168, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 160, + "bytecode": null, + "mechanism": { + "kind": "UNRESOLVED", + "semanticRole": "not mapped", + "compilerGenerated": null + }, + "mappingConfidence": "AMBIGUOUS", + "mappingReason": "candidate count 7 != all-mutant count 10 for this method+mutator", + "hypothesis": "HYPOTHESIS: not established - mapping is ambiguous, resolve the per-mutator skip rule first", + "requiredNextEvidence": "resolve the candidate-count mismatch so the exact instruction is unique" + }, + "050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164": { + "identity": "050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 12, + "pitIndex": 74, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 248, + "bytecode": { + "pc": 145, + "opcode": "if_acmpne", + "instruction": "176", + "line": 248, + "successorJumpPc": 176, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff": { + "identity": "c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 22, + "pitIndex": 132, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 253, + "bytecode": { + "pc": 228, + "opcode": "if_acmpne", + "instruction": "259", + "line": 253, + "successorJumpPc": 259, + "successorFallthroughPc": 4 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610": { + "identity": "e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 31, + "pitIndex": 181, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 261, + "bytecode": { + "pc": 306, + "opcode": "if_acmpne", + "instruction": "337", + "line": 261, + "successorJumpPc": 337, + "successorFallthroughPc": 7 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981": { + "identity": "1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "20", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "pitBlock": 91, + "pitIndex": 641, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 108, + "bytecode": null, + "mechanism": { + "kind": "UNRESOLVED", + "semanticRole": "not mapped", + "compilerGenerated": null + }, + "mappingConfidence": "AMBIGUOUS", + "mappingReason": "candidate count 1 != all-mutant count 11 for this method+mutator", + "hypothesis": "HYPOTHESIS: not established - mapping is ambiguous, resolve the per-mutator skip rule first", + "requiredNextEvidence": "resolve the candidate-count mismatch so the exact instruction is unique" + }, + "09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0": { + "identity": "09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 5, + "pitIndex": 32, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 86, + "bytecode": { + "pc": 69, + "opcode": "if_acmpne", + "instruction": "79", + "line": 86, + "successorJumpPc": 79, + "successorFallthroughPc": 3 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1": { + "identity": "10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 31, + "pitIndex": 170, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 223, + "bytecode": { + "pc": 321, + "opcode": "if_acmpne", + "instruction": "391", + "line": 223, + "successorJumpPc": 391, + "successorFallthroughPc": 10 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53": { + "identity": "f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 11, + "pitIndex": 67, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 148, + "bytecode": { + "pc": 144, + "opcode": "if_acmpne", + "instruction": "175", + "line": 148, + "successorJumpPc": 175, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590": { + "identity": "f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 9, + "pitIndex": 68, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 82, + "bytecode": { + "pc": 159, + "opcode": "if_acmpne", + "instruction": "209", + "line": 82, + "successorJumpPc": 209, + "successorFallthroughPc": 2 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58": { + "identity": "6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 23, + "pitIndex": 160, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 93, + "bytecode": { + "pc": 304, + "opcode": "if_acmpne", + "instruction": "364", + "line": 93, + "successorJumpPc": 364, + "successorFallthroughPc": 8 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d": { + "identity": "4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 35, + "pitIndex": 243, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 105, + "bytecode": { + "pc": 457, + "opcode": "if_acmpne", + "instruction": "528", + "line": 105, + "successorJumpPc": 528, + "successorFallthroughPc": 10 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42": { + "identity": "e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 46, + "pitIndex": 328, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 107, + "bytecode": { + "pc": 618, + "opcode": "if_acmpne", + "instruction": "699", + "line": 107, + "successorJumpPc": 699, + "successorFallthroughPc": 15 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be": { + "identity": "9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 60, + "pitIndex": 426, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 112, + "bytecode": { + "pc": 799, + "opcode": "if_acmpne", + "instruction": "880", + "line": 112, + "successorJumpPc": 880, + "successorFallthroughPc": 20 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593": { + "identity": "8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 12, + "pitIndex": 90, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 293, + "bytecode": { + "pc": 187, + "opcode": "if_acmpne", + "instruction": "252", + "line": 293, + "successorJumpPc": 252, + "successorFallthroughPc": 5 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512": { + "identity": "7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 30, + "pitIndex": 239, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 295, + "bytecode": { + "pc": 478, + "opcode": "if_acmpne", + "instruction": "543", + "line": 295, + "successorJumpPc": 543, + "successorFallthroughPc": 10 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada": { + "identity": "6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 11, + "pitIndex": 74, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 145, + "bytecode": { + "pc": 158, + "opcode": "if_acmpne", + "instruction": "199", + "line": 145, + "successorJumpPc": 199, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31": { + "identity": "c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 35, + "pitIndex": 206, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 152, + "bytecode": { + "pc": 362, + "opcode": "if_acmpne", + "instruction": "444", + "line": 152, + "successorJumpPc": 444, + "successorFallthroughPc": 4 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204": { + "identity": "43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 109, + "pitIndex": 668, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 218, + "bytecode": { + "pc": 1072, + "opcode": "if_acmpne", + "instruction": "1231", + "line": 218, + "successorJumpPc": 1231, + "successorFallthroughPc": 11 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2": { + "identity": "16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 132, + "pitIndex": 853, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 219, + "bytecode": { + "pc": 1420, + "opcode": "if_acmpne", + "instruction": "1579", + "line": 219, + "successorJumpPc": 1579, + "successorFallthroughPc": 13 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d": { + "identity": "ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 157, + "pitIndex": 1067, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 239, + "bytecode": { + "pc": 1785, + "opcode": "if_acmpne", + "instruction": "1883", + "line": 239, + "successorJumpPc": 1883, + "successorFallthroughPc": 18 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e": { + "identity": "66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e", + "frozenStatus": "NO_COVERAGE", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 12, + "pitIndex": 78, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 142, + "bytecode": { + "pc": 163, + "opcode": "areturn", + "instruction": "", + "line": 142, + "successorJumpPc": null, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1": { + "identity": "1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 14, + "pitIndex": 79, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 210, + "bytecode": { + "pc": 151, + "opcode": "if_acmpne", + "instruction": "192", + "line": 210, + "successorJumpPc": 192, + "successorFallthroughPc": 4 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb": { + "identity": "a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "3", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 35, + "pitIndex": 204, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 265, + "bytecode": { + "pc": 341, + "opcode": "areturn", + "instruction": "", + "line": 265, + "successorJumpPc": null, + "successorFallthroughPc": 8 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289": { + "identity": "d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 10, + "pitIndex": 61, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 223, + "bytecode": { + "pc": 130, + "opcode": "if_acmpne", + "instruction": "150", + "line": 223, + "successorJumpPc": 150, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5": { + "identity": "2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 17, + "pitIndex": 101, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 225, + "bytecode": { + "pc": 185, + "opcode": "if_acmpne", + "instruction": "205", + "line": 225, + "successorJumpPc": 205, + "successorFallthroughPc": 4 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1": { + "identity": "ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 25, + "pitIndex": 142, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 230, + "bytecode": { + "pc": 243, + "opcode": "if_acmpne", + "instruction": "263", + "line": 230, + "successorJumpPc": 263, + "successorFallthroughPc": 7 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf": { + "identity": "4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 9, + "pitIndex": 59, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 65, + "bytecode": { + "pc": 143, + "opcode": "if_acmpne", + "instruction": "165", + "line": 65, + "successorJumpPc": 165, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4": { + "identity": "611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 14, + "pitIndex": 95, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 66, + "bytecode": { + "pc": 198, + "opcode": "if_acmpne", + "instruction": "229", + "line": 66, + "successorJumpPc": 229, + "successorFallthroughPc": 4 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318": { + "identity": "9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 42, + "pitIndex": 264, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 81, + "bytecode": { + "pc": 465, + "opcode": "if_acmpne", + "instruction": "537", + "line": 81, + "successorJumpPc": 537, + "successorFallthroughPc": 11 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848": { + "identity": "252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 54, + "pitIndex": 356, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 85, + "bytecode": { + "pc": 634, + "opcode": "if_acmpne", + "instruction": "706", + "line": 85, + "successorJumpPc": 706, + "successorFallthroughPc": 13 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f": { + "identity": "827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 67, + "pitIndex": 459, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 90, + "bytecode": { + "pc": 803, + "opcode": "if_acmpne", + "instruction": "884", + "line": 90, + "successorJumpPc": 884, + "successorFallthroughPc": 15 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747": { + "identity": "a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "5", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 76, + "pitIndex": 533, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 102, + "bytecode": { + "pc": 912, + "opcode": "ifnonnull", + "instruction": "918", + "line": 102, + "successorJumpPc": 918, + "successorFallthroughPc": 18 + }, + "mechanism": { + "kind": "NULL_GUARD", + "semanticRole": "null guard", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established - single-row null guard, needs its own discriminator", + "requiredNextEvidence": "per-identity semantic discriminator" + }, + "2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe": { + "identity": "2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 10, + "pitIndex": 63, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 301, + "bytecode": { + "pc": 120, + "opcode": "if_acmpne", + "instruction": "143", + "line": 301, + "successorJumpPc": 143, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658": { + "identity": "8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "5", + "className": "dev.tramai.engine.approval.ApprovalRunAttributionKt", + "method": "decodeApprovalAttribution", + "methodDescription": "(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;", + "mutator": "NegateConditionals", + "pitBlock": 22, + "pitIndex": 127, + "sourceFile": "ApprovalRunAttribution.kt", + "sourceLine": 174, + "bytecode": null, + "mechanism": { + "kind": "UNRESOLVED", + "semanticRole": "not mapped", + "compilerGenerated": null + }, + "mappingConfidence": "AMBIGUOUS", + "mappingReason": "candidate count 16 != all-mutant count 8 for this method+mutator", + "hypothesis": "HYPOTHESIS: not established - mapping is ambiguous, resolve the per-mutator skip rule first", + "requiredNextEvidence": "resolve the candidate-count mismatch so the exact instruction is unique" + }, + "d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3": { + "identity": "d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "5", + "className": "dev.tramai.engine.approval.ApprovalRunAttributionKt", + "method": "decodeApprovalAttribution", + "methodDescription": "(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;", + "mutator": "NegateConditionals", + "pitBlock": 24, + "pitIndex": 131, + "sourceFile": "ApprovalRunAttribution.kt", + "sourceLine": 174, + "bytecode": null, + "mechanism": { + "kind": "UNRESOLVED", + "semanticRole": "not mapped", + "compilerGenerated": null + }, + "mappingConfidence": "AMBIGUOUS", + "mappingReason": "candidate count 16 != all-mutant count 8 for this method+mutator", + "hypothesis": "HYPOTHESIS: not established - mapping is ambiguous, resolve the per-mutator skip rule first", + "requiredNextEvidence": "resolve the candidate-count mismatch so the exact instruction is unique" + }, + "e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32": { + "identity": "e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32", + "frozenStatus": "NO_COVERAGE", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 10, + "pitIndex": 72, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 73, + "bytecode": { + "pc": 164, + "opcode": "areturn", + "instruction": "", + "line": 73, + "successorJumpPc": null, + "successorFallthroughPc": 2 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6": { + "identity": "c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "4", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "pitBlock": 16, + "pitIndex": 101, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 305, + "bytecode": null, + "mechanism": { + "kind": "UNRESOLVED", + "semanticRole": "not mapped", + "compilerGenerated": null + }, + "mappingConfidence": "AMBIGUOUS", + "mappingReason": "candidate count 2 != all-mutant count 3 for this method+mutator", + "hypothesis": "HYPOTHESIS: not established - mapping is ambiguous, resolve the per-mutator skip rule first", + "requiredNextEvidence": "resolve the candidate-count mismatch so the exact instruction is unique" + }, + "3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a": { + "identity": "3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "13", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 17, + "pitIndex": 106, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 307, + "bytecode": { + "pc": 161, + "opcode": "areturn", + "instruction": "", + "line": 307, + "successorJumpPc": null, + "successorFallthroughPc": 4 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1": { + "identity": "5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1", + "frozenStatus": "NO_COVERAGE", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "pitBlock": 11, + "pitIndex": 92, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 73, + "bytecode": null, + "mechanism": { + "kind": "UNRESOLVED", + "semanticRole": "not mapped", + "compilerGenerated": null + }, + "mappingConfidence": "AMBIGUOUS", + "mappingReason": "candidate count 0 != all-mutant count 8 for this method+mutator", + "hypothesis": "HYPOTHESIS: not established - mapping is ambiguous, resolve the per-mutator skip rule first", + "requiredNextEvidence": "resolve the candidate-count mismatch so the exact instruction is unique" + }, + "56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466": { + "identity": "56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "6", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 9, + "pitIndex": 50, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 205, + "bytecode": { + "pc": 100, + "opcode": "areturn", + "instruction": "", + "line": 205, + "successorJumpPc": null, + "successorFallthroughPc": 1 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88": { + "identity": "6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "2", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 26, + "pitIndex": 141, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 219, + "bytecode": { + "pc": 244, + "opcode": "areturn", + "instruction": "", + "line": 219, + "successorJumpPc": null, + "successorFallthroughPc": 7 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4": { + "identity": "5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 19, + "pitIndex": 167, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 183, + "bytecode": { + "pc": 308, + "opcode": "if_acmpne", + "instruction": "384", + "line": 183, + "successorJumpPc": 384, + "successorFallthroughPc": 5 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6": { + "identity": "f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 39, + "pitIndex": 323, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 196, + "bytecode": { + "pc": 567, + "opcode": "if_acmpne", + "instruction": "677", + "line": 196, + "successorJumpPc": 677, + "successorFallthroughPc": 8 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101": { + "identity": "6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 54, + "pitIndex": 441, + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 208, + "bytecode": { + "pc": 804, + "opcode": "if_acmpne", + "instruction": "921", + "line": 208, + "successorJumpPc": 921, + "successorFallthroughPc": 10 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd": { + "identity": "f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "7", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 29, + "pitIndex": 161, + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 234, + "bytecode": { + "pc": 267, + "opcode": "areturn", + "instruction": "", + "line": 234, + "successorJumpPc": null, + "successorFallthroughPc": 8 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf": { + "identity": "8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 5, + "pitIndex": 28, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 293, + "bytecode": { + "pc": 63, + "opcode": "if_acmpne", + "instruction": "73", + "line": 293, + "successorJumpPc": 73, + "successorFallthroughPc": 3 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + }, + "eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18": { + "identity": "eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18", + "frozenStatus": "SURVIVED", + "numberOfTestsRun": "4", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "persistSuspendedInvocation", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "pitBlock": 9, + "pitIndex": 38, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 284, + "bytecode": { + "pc": 54, + "opcode": "areturn", + "instruction": "", + "line": 284, + "successorJumpPc": null, + "successorFallthroughPc": 4 + }, + "mechanism": { + "kind": "OTHER_CONDITIONAL", + "semanticRole": "other conditional (areturn)", + "compilerGenerated": false + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: not established in G5a - requires the per-identity discriminator/proof named in the cohort's candidateNextAction", + "requiredNextEvidence": "per-identity semantic discriminator or instruction-level equivalence/unreachability proof" + }, + "e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851": { + "identity": "e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851", + "frozenStatus": "TIMED_OUT", + "numberOfTestsRun": "0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "pitBlock": 4, + "pitIndex": 29, + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 294, + "bytecode": { + "pc": 64, + "opcode": "if_acmpne", + "instruction": "74", + "line": 294, + "successorJumpPc": 74, + "successorFallthroughPc": 3 + }, + "mechanism": { + "kind": "SUSPENSION_SENTINEL", + "semanticRole": "suspension sentinel comparison: result of a suspend call !== COROUTINE_SUSPENDED (canonical `dup; aload; if_acmpne` after the call)", + "compilerGenerated": true + }, + "mappingConfidence": "EXACT", + "mappingReason": "line-bearing candidate set aligned to all PIT mutants of this method+mutator in pc order; PIT line == pc line", + "hypothesis": "HYPOTHESIS: compiler-generated suspension-protocol sentinel; PIT cannot execute the mutated branch normally (numberOfTestsRun=0) and the source-level behaviour is carried by the resumed-frame checks elsewhere in the same method", + "requiredNextEvidence": "a second independent campaign for timeout determinism plus per-mechanism proof that the sentinel branch is protocol-only and that authored behaviour is independently protected" + } + } +} diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5a-BYTECODE-MECHANISM-PARTITION.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G5a-BYTECODE-MECHANISM-PARTITION.md new file mode 100644 index 000000000..1f9f660b4 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5a-BYTECODE-MECHANISM-PARTITION.md @@ -0,0 +1,124 @@ +# TASK-0.7.1g1G5a — Exact bytecode mechanism partition of the frozen 52 + +**Diagnosis only. Every identity remains UNDETERMINED. No disposition is assigned, no admission is minted, no test, production file, configuration or authority artifact was changed.** + +## 1. Custody and base proof + +| item | value | +|---|---| +| exact base | `19703cfe9d81fbca11c8e11cde5af876ff731f1c` (post-`#460` Epic tip), local branch fast-forwarded, tree clean | +| `18f10dbb..19703cfe` touch set | docs only (the P1 STOP record) — so production bytecode is unchanged across the two tips | +| admission ledger at the base | `schemaVersion: "1"`, `admissions: []` | +| parent cohort manifest | `docs/roadmap/0.7.0/TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json` | +| rows extracted with `unresolved == true` | 52 | +| recomputed sorted-identity digest | `92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584` — **matches the required digest** | + +## 2. Compilation and javap provenance + +``` +module :tramai-engine command ./gradlew :tramai-engine:compileKotlin +JDK 21.0.12.1 Kotlin 2.3.0 +class output tramai-engine/build/classes/kotlin/main (335 classes) +javap javap -p -c -l -s +classes analysed 7 (the 6 owners named by the cohort manifest + the generated + compensateSuspension$2$2 lambda), SHA-256 per class recorded in the manifest +``` + +The exact-base compile is **byte-identical to the compile the discovery measurement used**: the class set and the per-class hash aggregate agree exactly (`5d2c2af4…` over the sorted per-class digests). The bytecode inspected is therefore the bytecode PIT measured, at a verified commit — not a stale build. + +## 3. Mapping method (and why block/index is not authority) + +PIT's `block`/`index` are **not** JVM bytecode offsets, and PIT's source line is not the exact mutated instruction. The mapping used: + +1. restrict the candidate instruction set for a `method + mutator` to line-bearing instructions of that mutator's kind; +2. align that pc-ordered set to **every** PIT mutant of the same `method + mutator` (including KILLED members of the population), in `(block, index)` order; +3. require the corroboration that PIT's reported line equals the mapped pc's line-table line; +4. where counts differ, leave the row **AMBIGUOUS** and refuse to select an instruction. + +The alignment rule was derived, not assumed, from two independent observations: for `resume`, PIT reported 11 `NegateConditionals` mutants against 13 conditional branches, and PIT's line sequence `[65,66,68,80,81,85,90,102,98,113,109]` equals the pc-ordered line sequence of exactly 11 branches once the two **no-line** compiler prologue branches (`ifeq` at pc 4 and 21, the parameter/`$this` guards) are removed. The same relation holds for `suspendToolExecution` (8 = 10 − 2). PIT does not mutate those no-line prologue branches; every other line-bearing conditional is mutated in pc order. + +Result: **45 EXACT, 7 AMBIGUOUS, 0 silently inferred from source-line similarity**. + +## 4. Distribution of the frozen 52 + +``` +by raw status TIMED_OUT 36 · SURVIVED 12 · NO_COVERAGE 4 +by mutator NegateConditionals 39 · NullReturnVals 8 · VoidMethodCall 5 +by mechanism (G5a) SUSPENSION_SENTINEL 36 · OTHER_CONDITIONAL 8 · NULL_GUARD 1 · UNRESOLVED 7 +mapping confidence EXACT 45 · AMBIGUOUS 7 +final dispositions 0 +``` + +## 5. Mechanism cohorts + +| cohort | mechanism | members | raw status | generalization | +|---|---|---|---|---| +| **G5A-M01** | coroutine suspension-sentinel comparison | **36** | TIMED_OUT 36 | **EXACT_STRUCTURAL** | +| G5A-M02 | null guard (`ifnonnull`) | 1 | SURVIVED 1 | NO_GENERALIZATION | +| G5A-M03 | line-bearing integer/flag conditional | 8 | SURVIVED 6, NO_COVERAGE 2 | PARTIAL_ONLY | +| G5A-M04 | unresolved mapping (candidate-count mismatch) | 7 | SURVIVED 5, NO_COVERAGE 2 | NO_GENERALIZATION | + +**G5A-M01** is the whole TIMED_OUT cohort and the only cohort with structural generalization. Its rule, verified programmatically for 36/36 members, is the canonical Kotlin state-machine suspension codegen: + +``` +iconst_N ; putfield label <- state is set before the child call +invoke <- the child suspends +dup ; aload <- reload the call result and COROUTINE_SUSPENDED +if_acmpne <- THE MUTATED INSTRUCTION +``` + +Every member is this construct; no member is a value comparison. This is a *hypothesis generator with structure*, not a disposition: it says the 36 share one mechanism, and that mechanism is the coroutine suspension protocol rather than an authored condition. + +## 6. The g1E comparison, explicitly + +| criterion | answer | +|---|---| +| same exact type of compiler-generated construct? | **YES** — 36/36 identical sentinel-comparison shape | +| `numberOfTestsRun == 0` consistently? | **YES** — 36/36 | +| deterministic/reproducible timeout evidence exists? | **YES** for the single fresh campaign · **NOT_YET** for a second independent campaign | +| authored source branch hidden by the mutation? | **NO** — the mutated instruction is the protocol comparison; the source line is only the call site | +| semantic behaviour independently protected? | **NOT_YET** — not established in a diagnosis increment | +| independently killed control available where needed? | **YES** for same-mutator, different-role controls in the same methods (authorizeResume 6, prepareResume 7, resume 5, resolveAndValidateResumeTool 6, revealAndValidateReplayPayload 2 conditional mutants are KILLED) · **NOT_YET** for a same-role control | +| safe to apply g1E reasoning at cohort level? | **NOT_YET** | + +The remaining gap is exactly two measurements: a second campaign for timeout determinism, and per-mechanism evidence that the sentinel branch is protocol-only with authored behaviour protected elsewhere. Applying the g1E precedent now would require *assuming* the very thing it asks to be proven. + +## 7. SURVIVED (12) — hypotheses only + +- **OTHER_CONDITIONAL 6** and **NULL_GUARD 1**: value/flag comparisons and one null guard that tests already execute. Hypotheses, each requiring its own discriminator: a missing behavioural assertion; a discarded-result equivalence; a forwarding-to-later-check equivalence; or a cancellation-only path. These are the rows where new tests are most likely to convert SURVIVED into KILLED. +- **UNRESOLVED 5**: no unique instruction is established, so no semantic claim is made at all. The next step is evidence work on the per-mutator skip rule (PIT mutates a subset of each mutator's opcode family), not tests. + +No discriminator is written in this increment; the manifest names the required evidence per identity. + +## 8. NO_COVERAGE (4) — reachability hypotheses only + +- **OTHER_CONDITIONAL 2**: reachable-if-some-path-tests-it hypotheses; the reachability of the predecessor condition must be established before any disposition. +- **UNRESOLVED 2**: ambiguous mapping first. + +Neither the "missing test path" nor the "compiler-only seam" hypothesis is asserted here; both remain to be decided by the reachability work in G5d. + +## 9. Ambiguous mappings (7) — recorded, not resolved + +``` +564517b2fdab candidate count 17 != 10 3658ba45eaed candidate count 17 != 10 +1ce5967ee60b candidate count 17 != 11 8e18b4b48968 candidate count 16 != 8 +d38ac52f2843 candidate count 16 != 8 c7774df8ccdc candidate count 5 != 3 +5a7edac3a982 candidate count 11 != 8 +``` + +Each is a `method + mutator` whose line-bearing candidate set exceeds the PIT mutant count, so no bijection exists yet. Per the mapping rule these rows are **AMBIGUOUS** and are barred from later disposition until the exact instruction is unique. + +## 10. Recommended next increments (from the data) + +1. **G5b — TIMED_OUT tooling-mechanism adjudication (36, cohort G5A-M01).** Largest, single-mechanism, EXACT_STRUCTURAL. Evidence needed: a second independent campaign at the same SHA for timeout determinism; a same-role control; the per-mechanism argument that the sentinel branch is protocol-only and that the authored behaviour is protected by the resumed-frame checks in the same methods. +2. **G5c — SURVIVED semantic discriminators (12).** Expect the 6 OTHER_CONDITIONAL members to be genuine missing-assertion kills; the 5 UNRESOLVED members must be re-mapped first. +3. **G5d — NO_COVERAGE reachability (4).** Two need the reachability proof, two need re-mapping. +4. **G5a-2 (fold into G5c/G5d) — resolve the per-mutator skip rule** so the 7 ambiguous rows map uniquely. This is evidence work on PIT's mutation eligibility, not a test-writing task. + +The split is a recommendation derived from the partition, not a constraint; if G5b's measurement shows the 36 divide by method or by successor semantics, G5b must be split along that boundary. + +## 11. What G5a did not do + +No test added or changed; no production change; no mutation configuration, timeout, mutator, target-family, ceiling or gate change; no baseline, classification, enrollment, admission or M21 change; no disposition assigned; P1 still blocked, P2 not executed. + +**G5A MECHANISM PARTITION COMPLETE — 52 STILL UNDETERMINED; NEXT EVIDENCE INCREMENT SELECTED FROM THE BYTECODE MAP.** diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json new file mode 100644 index 000000000..bb58a8b3e --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-MANIFEST.json @@ -0,0 +1,1284 @@ +{ + "schemaVersion": 1, + "task": "TASK-0.7.1g1G5b", + "parent": "TASK-0.7.1g1G5", + "title": "Suspension-sentinel tooling-limitation adjudication of G5A-M01 (36 identities)", + "g5bBase": "aa0cc52cc30a9ec53467712b85e2343b780cafca", + "frozenParentCohortDigest": "92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584", + "cohort": { + "name": "G5A-M01", + "size": 36, + "digest": "cda7b483580008f967879925a87ff8a2892cc83667c6720b0e973241cf384f92", + "mappingConfidence": "EXACT", + "generalization": "EXACT_STRUCTURAL", + "mechanism": "coroutine suspension sentinel: the mutated instruction is the result != COROUTINE_SUSPENDED comparison immediately following a child suspend call", + "evidenceSource": "docs/roadmap/0.7.0/TASK-0.7.1g1G5a-BYTECODE-MECHANISM-PARTITION-MANIFEST.json" + }, + "campaigns": { + "campaign1": { + "role": "frozen discovery measurement (source of the 36 raw statuses)", + "measuredCommit": "18f10dbb2c90fbfeafc374e1d7fe0d8814e342b1", + "populationDigest": "7081ed7416f759ecca7cae00c7cf4e6261ef0cd16db49fe32bc3bb6f280bbddd", + "reportRoot": "/tmp/xk9p1-fresh", + "measuredCommitCorrection": "5856530e is the stale committed population's historical measuredCommit, not the discovery campaign commit" + }, + "campaign2": { + "role": "independent second measurement for determinism", + "base": "aa0cc52cc30a9ec53467712b85e2343b780cafca", + "throwawayMeasurementCommit": "2341829c35d81e6269ac6d92c87a62a5d312da96", + "configDiff": "family-list narrowing only: 53 deletions in config/quality/test-quality.yml, the approval family block untouched", + "generatedBaselineMeasuredCommit": "2341829c35d81e6269ac6d92c87a62a5d312da96", + "analyzer": { + "pluginVersion": "1.19.0", + "engineVersion": "1.22.1", + "mutators": [ + "CONDITIONALS_BOUNDARY", + "INCREMENTS", + "INVERT_NEGS", + "MATH", + "NEGATE_CONDITIONALS", + "TRUE_RETURNS", + "FALSE_RETURNS", + "PRIMITIVE_RETURNS", + "EMPTY_RETURNS", + "NULL_RETURNS", + "VOID_METHOD_CALLS" + ], + "timeoutConst": 4000, + "timeoutFactor": 1.25 + }, + "durationSeconds": 1014, + "buildResult": "BUILD SUCCESSFUL", + "familyCensus": { + "family": "approval", + "total": 918, + "byModule": { + ":tramai-engine": 492, + ":tramai-security": 426 + }, + "killed": 661, + "survived": 133, + "noCoverage": 62, + "timedOut": 62 + }, + "rawReportSha256": { + "approval/tramai-engine": "068c0ddc3857c9d313cc322e34fd5fefed6f68e78ba110bd45ab2d907c388ac0", + "approval/tramai-security": "567bbb4eedc544ef07146f14105c047e828068c559bf43a4641195aae7610b64" + }, + "rejectedArtifacts": [ + "attempt-1 partial engine report (342/492, mid-write)", + "attempt-2 malformed security report (Content is not allowed in trailing section) caused by two PIT runs sharing one report root", + "a copied mutation-baseline.json that was byte-identical to the committed baseline and therefore not a campaign-2 artifact" + ] + } + }, + "reconciliation": { + "inputFrozenIdentities": 36, + "recovered": 36, + "missing": 0, + "duplicates": 0, + "timedOutToTimedOut": 36, + "statusMovement": 0, + "layers": [ + "canonical generated population (identity-equality asserted for every member)", + "raw PIT report (independent status agreement for all 36)" + ] + }, + "concealmentAudit": { + "bytecodeAbsenceProof": 36, + "siblingProtectionRequired": 0, + "conditionalJumpsOnLine": { + "1": 36 + } + }, + "numberOfTestsRunNote": "numberOfTestsRun='0' is an XML ATTRIBUTE on and is present on every TIMED_OUT mutation (70/70 discovery, 62/62 approval family in this campaign), as on all 190 NO_COVERAGE mutations. The frozen manifest's 0 is PIT's own measurement. An earlier revision of this manifest wrongly stated the attribute was absent; superseded, not erased.", + "controls": { + "ApprovalResumeCoordinator": { + "KILLED": 107, + "SURVIVED": 24, + "NO_COVERAGE": 1, + "TIMED_OUT": 18 + }, + "ApprovalResumeCoordinator$resume$2": { + "KILLED": 2, + "SURVIVED": 2, + "NO_COVERAGE": 0, + "TIMED_OUT": 1 + }, + "ApprovalSuspensionCoordinator": { + "KILLED": 71, + "SURVIVED": 6, + "NO_COVERAGE": 2, + "TIMED_OUT": 8 + }, + "ApprovalSuspensionCoordinator$compensateSuspension$2$1": { + "KILLED": 2, + "SURVIVED": 2, + "NO_COVERAGE": 0, + "TIMED_OUT": 1 + }, + "ApprovalSuspensionCoordinator$compensateSuspension$2$2": { + "KILLED": 2, + "SURVIVED": 2, + "NO_COVERAGE": 0, + "TIMED_OUT": 1 + }, + "DefaultApprovalGateway": { + "KILLED": 57, + "SURVIVED": 8, + "NO_COVERAGE": 2, + "TIMED_OUT": 12 + } + }, + "adjudication": { + "toolingLimitation": 36, + "undetermined": 0, + "killed": 0, + "equivalent": 0, + "note": "TOOLING_LIMITATION is not EQUIVALENT and not KILLED. PIT cannot execute or measure these mutations normally, so the result carries no information about authored test adequacy." + }, + "parentAccounting": { + "parentFrozenCohort": 52, + "settledByThisTask": 36, + "remainingUndetermined": 16, + "remainingBreakdown": { + "SURVIVED": 12, + "NO_COVERAGE": 4 + }, + "excluded": "the 16 already-adjudicated identities of the 68-row P1 manifest are not part of the frozen G5 52 and are not counted here" + }, + "identities": [ + { + "identity": "f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 148, + "pitBlock": 11, + "pitIndex": 67, + "bytecodePc": 144, + "opcode": "if_acmpne", + "lineAtPc": 148, + "stateMachineCase": null, + "childSuspendCall": "#428 // Method dev/tramai/engine/approval/ReplayAuthorizationService.decideResumePolicy:(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/model/ResolvedTool;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 183, + "pitBlock": 19, + "pitIndex": 167, + "bytecodePc": 308, + "opcode": "if_acmpne", + "lineAtPc": 183, + "stateMachineCase": null, + "childSuspendCall": "#507 // Method \"validateClaimedResumeArguments-4uOHQ0s\":(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ToolArgumentsDigester;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 196, + "pitBlock": 39, + "pitIndex": 323, + "bytecodePc": 567, + "opcode": "if_acmpne", + "lineAtPc": 196, + "stateMachineCase": null, + "childSuspendCall": "#560, 3 // InterfaceMethod dev/tramai/engine/approval/ClaimedResumeExecutor.execute:(Ldev/tramai/engine/approval/ClaimedResumeExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 208, + "pitBlock": 54, + "pitIndex": 441, + "bytecodePc": 804, + "opcode": "if_acmpne", + "lineAtPc": 208, + "stateMachineCase": null, + "childSuspendCall": "#569 // Method completeClaimedResume:(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 121, + "pitBlock": 11, + "pitIndex": 68, + "bytecodePc": 147, + "opcode": "if_acmpne", + "lineAtPc": 121, + "stateMachineCase": null, + "childSuspendCall": "#319, 3 // InterfaceMethod dev/tramai/core/approval/ApprovalContinuationStore.get:(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 138, + "pitBlock": 53, + "pitIndex": 332, + "bytecodePc": 568, + "opcode": "if_acmpne", + "lineAtPc": 138, + "stateMachineCase": null, + "childSuspendCall": "#384 // Method dev/tramai/engine/approval/ContinuationClaimService.loadPendingForResume:(Ldev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/approval/RegisteredResumeOperation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 140, + "pitBlock": 61, + "pitIndex": 423, + "bytecodePc": 738, + "opcode": "if_acmpne", + "lineAtPc": 140, + "stateMachineCase": null, + "childSuspendCall": "#398 // Method dev/tramai/engine/approval/ReplayAuthorizationService.validateToken:(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/approval/ApprovalContinuation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 65, + "pitBlock": 9, + "pitIndex": 59, + "bytecodePc": 143, + "opcode": "if_acmpne", + "lineAtPc": 65, + "stateMachineCase": null, + "childSuspendCall": "#105 // Method prepareResume:(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 66, + "pitBlock": 14, + "pitIndex": 95, + "bytecodePc": 198, + "opcode": "if_acmpne", + "lineAtPc": 66, + "stateMachineCase": null, + "childSuspendCall": "#116 // Method authorizeResume:(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 81, + "pitBlock": 42, + "pitIndex": 264, + "bytecodePc": 465, + "opcode": "if_acmpne", + "lineAtPc": 81, + "stateMachineCase": null, + "childSuspendCall": "#199 // Method executeClaimedResume:(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 85, + "pitBlock": 54, + "pitIndex": 356, + "bytecodePc": 634, + "opcode": "if_acmpne", + "lineAtPc": 85, + "stateMachineCase": null, + "childSuspendCall": "#219 // Method kotlinx/coroutines/BuildersKt.withContext:(Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 90, + "pitBlock": 67, + "pitIndex": 459, + "bytecodePc": 803, + "opcode": "if_acmpne", + "lineAtPc": 90, + "stateMachineCase": null, + "childSuspendCall": "#228 // Method emitResumeUncertainOutcomeOnce:(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 223, + "pitBlock": 31, + "pitIndex": 170, + "bytecodePc": 321, + "opcode": "if_acmpne", + "lineAtPc": 223, + "stateMachineCase": null, + "childSuspendCall": "#228 // Method emitResumeUncertainOutcomeOnce:(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLine": 86, + "pitBlock": 5, + "pitIndex": 32, + "bytecodePc": 69, + "opcode": "if_acmpne", + "lineAtPc": 86, + "stateMachineCase": null, + "childSuspendCall": "#81 // Method dev/tramai/engine/approval/ApprovalResumeCoordinator.access$executeClaimedResume:(Ldev/tramai/engine/approval/ApprovalResumeCoordinator;Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 301, + "pitBlock": 10, + "pitIndex": 63, + "bytecodePc": 120, + "opcode": "if_acmpne", + "lineAtPc": 301, + "stateMachineCase": null, + "childSuspendCall": "#733, 2 // InterfaceMethod kotlin/jvm/functions/Function1.invoke:(Ljava/lang/Object;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 293, + "pitBlock": 12, + "pitIndex": 90, + "bytecodePc": 187, + "opcode": "if_acmpne", + "lineAtPc": 293, + "stateMachineCase": null, + "childSuspendCall": "#705 // Method compensateStep:(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 295, + "pitBlock": 30, + "pitIndex": 239, + "bytecodePc": 478, + "opcode": "if_acmpne", + "lineAtPc": 295, + "stateMachineCase": null, + "childSuspendCall": "#705 // Method compensateStep:(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 145, + "pitBlock": 11, + "pitIndex": 74, + "bytecodePc": 158, + "opcode": "if_acmpne", + "lineAtPc": 145, + "stateMachineCase": null, + "childSuspendCall": "#359 // Method resolveGovernedSuspension:(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 152, + "pitBlock": 35, + "pitIndex": 206, + "bytecodePc": 362, + "opcode": "if_acmpne", + "lineAtPc": 152, + "stateMachineCase": null, + "childSuspendCall": "#423, 3 // InterfaceMethod dev/tramai/core/approval/ApprovalGateCoordinator.createApproval:(Ldev/tramai/core/approval/CreateApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 218, + "pitBlock": 109, + "pitIndex": 668, + "bytecodePc": 1072, + "opcode": "if_acmpne", + "lineAtPc": 218, + "stateMachineCase": null, + "childSuspendCall": "#550 // Method persistSuspendedInvocation:(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 219, + "pitBlock": 132, + "pitIndex": 853, + "bytecodePc": 1420, + "opcode": "if_acmpne", + "lineAtPc": 219, + "stateMachineCase": null, + "childSuspendCall": "#560, 9 // InterfaceMethod dev/tramai/core/approval/ApprovalLifecycleAuditEmitter.\"onToolExecutionSuspended-0ERvcow\":(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 239, + "pitBlock": 157, + "pitIndex": 1067, + "bytecodePc": 1785, + "opcode": "if_acmpne", + "lineAtPc": 239, + "stateMachineCase": null, + "childSuspendCall": "#577 // Method compensateSuspension:(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 293, + "pitBlock": 5, + "pitIndex": 28, + "bytecodePc": 63, + "opcode": "if_acmpne", + "lineAtPc": 293, + "stateMachineCase": null, + "childSuspendCall": "#73, 3 // InterfaceMethod dev/tramai/engine/SuspendedInvocationStore.remove:(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLine": 294, + "pitBlock": 4, + "pitIndex": 29, + "bytecodePc": 64, + "opcode": "if_acmpne", + "lineAtPc": 294, + "stateMachineCase": null, + "childSuspendCall": "#70, 5 // InterfaceMethod dev/tramai/core/approval/ApprovalContinuationStore.cancel:(Ljava/lang/String;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 248, + "pitBlock": 12, + "pitIndex": 74, + "bytecodePc": 145, + "opcode": "if_acmpne", + "lineAtPc": 248, + "stateMachineCase": null, + "childSuspendCall": "#413, 4 // InterfaceMethod dev/tramai/engine/approval/GovernedApprovalStore.createGovernedApproval:(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution$Governed;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 253, + "pitBlock": 22, + "pitIndex": 132, + "bytecodePc": 228, + "opcode": "if_acmpne", + "lineAtPc": 253, + "stateMachineCase": null, + "childSuspendCall": "#422, 4 // InterfaceMethod dev/tramai/engine/GovernedSuspendedInvocationStore.createGoverned:(Ldev/tramai/engine/GovernedSuspendedInvocation;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 261, + "pitBlock": 31, + "pitIndex": 181, + "bytecodePc": 306, + "opcode": "if_acmpne", + "lineAtPc": 261, + "stateMachineCase": null, + "childSuspendCall": "#401, 4 // InterfaceMethod dev/tramai/core/approval/ApprovalContinuationStore.create:(Ldev/tramai/core/approval/ApprovalContinuation;Ldev/tramai/core/approval/SensitiveToolArguments;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 223, + "pitBlock": 10, + "pitIndex": 61, + "bytecodePc": 130, + "opcode": "if_acmpne", + "lineAtPc": 223, + "stateMachineCase": null, + "childSuspendCall": "#381, 3 // InterfaceMethod dev/tramai/core/approval/ApprovalStore.create:(Ldev/tramai/core/approval/ApprovalRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 225, + "pitBlock": 17, + "pitIndex": 101, + "bytecodePc": 185, + "opcode": "if_acmpne", + "lineAtPc": 225, + "stateMachineCase": null, + "childSuspendCall": "#388, 4 // InterfaceMethod dev/tramai/engine/SuspendedInvocationStore.create:(Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 230, + "pitBlock": 25, + "pitIndex": 142, + "bytecodePc": 243, + "opcode": "if_acmpne", + "lineAtPc": 230, + "stateMachineCase": null, + "childSuspendCall": "#401, 4 // InterfaceMethod dev/tramai/core/approval/ApprovalContinuationStore.create:(Ldev/tramai/core/approval/ApprovalContinuation;Ldev/tramai/core/approval/SensitiveToolArguments;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 82, + "pitBlock": 9, + "pitIndex": 68, + "bytecodePc": 159, + "opcode": "if_acmpne", + "lineAtPc": 82, + "stateMachineCase": null, + "childSuspendCall": "#107 // Method \"resolveGovernedIdentity-jFE5hGw\":(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 93, + "pitBlock": 23, + "pitIndex": 160, + "bytecodePc": 304, + "opcode": "if_acmpne", + "lineAtPc": 93, + "stateMachineCase": null, + "childSuspendCall": "#139, 6 // InterfaceMethod dev/tramai/engine/approval/ApprovalGatewayRequestFactory.\"createRequest-Atj0Sqo\":(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 105, + "pitBlock": 35, + "pitIndex": 243, + "bytecodePc": 457, + "opcode": "if_acmpne", + "lineAtPc": 105, + "stateMachineCase": null, + "childSuspendCall": "#163, 3 // InterfaceMethod dev/tramai/core/approval/ApprovalStore.get:(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 107, + "pitBlock": 46, + "pitIndex": 328, + "bytecodePc": 618, + "opcode": "if_acmpne", + "lineAtPc": 107, + "stateMachineCase": null, + "childSuspendCall": "#170 // Method requireExistingAttributionMatches:(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 112, + "pitBlock": 60, + "pitIndex": 426, + "bytecodePc": 799, + "opcode": "if_acmpne", + "lineAtPc": 112, + "stateMachineCase": null, + "childSuspendCall": "#178 // Method persistUngoverned:(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + }, + { + "identity": "1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLine": 210, + "pitBlock": 14, + "pitIndex": 79, + "bytecodePc": 151, + "opcode": "if_acmpne", + "lineAtPc": 210, + "stateMachineCase": null, + "childSuspendCall": "#354, 3 // InterfaceMethod dev/tramai/engine/approval/GovernedApprovalStore.attributionOf:(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "dupBeforeSentinelCompare": true, + "campaign1": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "campaign2": { + "status": "TIMED_OUT", + "testsRun": 0, + "numberOfTestsRunAttribute": "0" + }, + "concealmentProof": "BYTECODE_ABSENCE", + "authoredSiblingIdentity": null, + "killingTest": null, + "finalDisposition": "TOOLING_LIMITATION", + "evidenceReason": "exact suspension-sentinel mapping (if_acmpne over the child suspend call's result with the COROUTINE_SUSPENDED sentinel, dup immediately before the branch); exactly one conditional jump on the source-attributed line, so no authored conditional is concealed; TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns at the same base; matches the g1E proof form A" + } + ], + "corrections": [ + { + "field": "numberOfTestsRun", + "supersededClaim": "the raw report carries no numberOfTestsRun element for TIMED_OUT mutations, so the frozen 0 was a coercion of that absence", + "correctedClaim": "numberOfTestsRun='0' is an attribute present on every TIMED_OUT mutation; the 0 is PIT's own measurement", + "foundDuring": "TASK-0.7.1g1G5c phase 1 preparation (PIT XML schema inspection)", + "effectOnDispositions": "none \u2014 36/36 TOOLING_LIMITATION stands; the evidence is strengthened" + }, + { + "field": "campaign1.measuredCommit; campaign2.testsRun; evidenceReason", + "supersededClaim": "campaign1.measuredCommit=5856530e (the stale committed population's historical value); campaign2.testsRun=-1 contradicting its own numberOfTestsRunAttribute=0; evidenceReason said 'TIMED_OUT with no test count reported in two independent campaigns'", + "correctedClaim": "campaign1.measuredCommit=18f10dbb2c90fbfeafc374e1d7fe0d8814e342b1 (the P1 discovery campaign tip that produced 7081ed74); campaign2.testsRun=0 consistent with the PIT attribute; evidenceReason now reads 'TIMED_OUT with numberOfTestsRun=0 reported by PIT in both independent campaigns'", + "foundDuring": "owner review of #462 at head 162be323", + "effectOnDispositions": "none \u2014 36/36 TOOLING_LIMITATION stands" + } + ] +} \ No newline at end of file diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-TOOLING-LIMITATION.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-TOOLING-LIMITATION.md new file mode 100644 index 000000000..7acde26fb --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5b-SUSPENSION-SENTINEL-TOOLING-LIMITATION.md @@ -0,0 +1,157 @@ +# TASK-0.7.1g1G5b — Suspension-sentinel tooling-limitation adjudication + +Parent: **TASK-0.7.1g1G5** — Final fresh-only residual adjudication. +Scope: the **36 TIMED_OUT identities of G5A-M01, and nothing else**. Every other unresolved identity of the frozen 52 is untouched. +Base: **`aa0cc52cc30a9ec53467712b85e2343b780cafca`** — the squash merge of #461 (`doc(0.7.1g1G5a)`), verified as the `origin/epic/0.7.1-control-plane-authority` tip at start. +Frozen parent cohort digest, unchanged: `92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584`. +G5A-M01 cohort digest: `cda7b483580008f967879925a87ff8a2892cc83667c6720b0e973241cf384f92`. + +**Zero production, test, baseline, admission, classification, mutator, timeout or ceiling changes.** The temporary measurement narrowing never landed. + +## 1. Start gate + +All seven conditions held before any measurement: clean tree (#461 merged); the G5a manifest present at the tip; the frozen-52 digest recomputed equal; G5A-M01 exactly 36 members, every one `mappingConfidence = EXACT`, `mechanism.kind = SUSPENSION_SENTINEL`, frozen status `TIMED_OUT`, `numberOfTestsRun = 0`; and the admission ledger still `schemaVersion: "1"` / `admissions: []`. + +## 2. Phase 1 — independent second measurement + +| item | value | +| --- | --- | +| base | `aa0cc52cc30a9ec53467712b85e2343b780cafca` | +| campaign 1 (discovery) | measured at `18f10dbb2c90fbfeafc374e1d7fe0d8814e342b1` — the P1 discovery campaign that produced population digest `7081ed74…`; not `5856530e`, which is the stale committed population's historical measuredCommit | +| throwaway measurement commit | `2341829c35d81e6269ac6d92c87a62a5d312da96` | +| config diff (the whole of it) | family-list narrowing only: 53 deletions in config/quality/test-quality.yml, the approval family block untouched | +| `measuredCommit` in the generated artifact | `2341829c35d81e6269ac6d92c87a62a5d312da96` — equals the throwaway commit | +| result | BUILD SUCCESSFUL in 1014 s | +| analyzer | plugin 1.19.0, engine 1.22.1, 11 mutators in declaration order, timeoutConst 4000, timeoutFactor 1.25 — identical to the discovery campaign | +| raw reports | `[('approval/tramai-engine', '068c0ddc3857c9d313cc322e34fd5fefed6f68e78ba110bd45ab2d907c388ac0'), ('approval/tramai-security', '567bbb4eedc544ef07146f14105c047e828068c559bf43a4641195aae7610b64')]` | + +Family census, not a 36-row extract: **918 approval mutants** = `:tramai-engine` 492 + `:tramai-security` 426 → KILLED 661, SURVIVED 133, NO_COVERAGE 62, TIMED_OUT 62. Both report layers agree with the canonical generated population on every status. + +The default narrowing script in the workspace was rejected before use: it narrows at *class* level and drops `:tramai-security`, which would have excluded `DefaultApprovalGateway` and `ApprovalRunAttributionKt` — two of the four owners of the 36 — so the reconciliation would have failed while looking like a provenance defect. Three earlier measurement artifacts were rejected rather than reconciled: a partial engine report (342 of 492, mid-write), a malformed security report (`Content is not allowed in trailing section`, caused by two PIT runs sharing one report root — a launch error on my side, not sandbox reaping), and a copied `mutation-baseline.json` that was byte-identical to the committed baseline and therefore never a campaign-2 artifact. + +## 3. Phase 2 — identity-exact reproducibility + +| requirement | result | +| --- | --- | +| input frozen identities | 36 | +| recovered | 36 | +| missing | 0 | +| duplicates | 0 | +| TIMED_OUT → TIMED_OUT | 36 | +| status movement | 0 | + +Joined by the full canonical mutation identity, with identity equality asserted per member against the generated population and the status independently confirmed in the raw report. No member changed status, so the cohort partitions nothing out. + +## 4. Phase 3 — mechanism re-proved against base bytecode + +Bytecode custody carries over with proof: `19703cfe..aa0cc52c` touches **0 production files**, and the base compile was already shown identical to the compile the discovery campaign measured. Per member: + +| identity | owner | line | pc | opcode | case | child suspend call | campaign 1 | campaign 2 | concealment | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `f571f62614e6` | ApprovalResumeCoordinator.authorizeResume | 148 | 144 | `if_acmpne` | None | `Method dev/tramai/engine/approval/ReplayAuthorizatio` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `5d7006107262` | ApprovalResumeCoordinator.executeClaimedResume | 183 | 308 | `if_acmpne` | None | `Method "validateClaimedResumeArguments-4uOHQ0s":(Lde` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `f23b4d0921a0` | ApprovalResumeCoordinator.executeClaimedResume | 196 | 567 | `if_acmpne` | None | `InterfaceMethod dev/tramai/engine/approval/ClaimedRe` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `6cadd3397125` | ApprovalResumeCoordinator.executeClaimedResume | 208 | 804 | `if_acmpne` | None | `Method completeClaimedResume:(Ldev/tramai/engine/Res` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `9e3d60d7a855` | ApprovalResumeCoordinator.prepareResume | 121 | 147 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalCon` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `c3509c6487f8` | ApprovalResumeCoordinator.prepareResume | 138 | 568 | `if_acmpne` | None | `Method dev/tramai/engine/approval/ContinuationClaimS` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `a25dbb8c0826` | ApprovalResumeCoordinator.prepareResume | 140 | 738 | `if_acmpne` | None | `Method dev/tramai/engine/approval/ReplayAuthorizatio` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `4865b74caf50` | ApprovalResumeCoordinator.resume | 65 | 143 | `if_acmpne` | None | `Method prepareResume:(Ldev/tramai/engine/ResumeAppro` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `611bdacfe9f5` | ApprovalResumeCoordinator.resume | 66 | 198 | `if_acmpne` | None | `Method authorizeResume:(Ldev/tramai/engine/ResumeApp` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `9b9bfadffcdc` | ApprovalResumeCoordinator.resume | 81 | 465 | `if_acmpne` | None | `Method executeClaimedResume:(Ldev/tramai/engine/appr` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `252596080a3a` | ApprovalResumeCoordinator.resume | 85 | 634 | `if_acmpne` | None | `Method kotlinx/coroutines/BuildersKt.withContext:(Lk` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `827431d2f104` | ApprovalResumeCoordinator.resume | 90 | 803 | `if_acmpne` | None | `Method emitResumeUncertainOutcomeOnce:(Ldev/tramai/e` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `10512d6c6e00` | ApprovalResumeCoordinator.revealAndValidateReplayPayload | 223 | 321 | `if_acmpne` | None | `Method emitResumeUncertainOutcomeOnce:(Ldev/tramai/e` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `09ea31d4d0e8` | ApprovalResumeCoordinator$resume$2.invokeSuspend | 86 | 69 | `if_acmpne` | None | `Method dev/tramai/engine/approval/ApprovalResumeCoor` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `2b197f5cd3cb` | ApprovalSuspensionCoordinator.compensateStep | 301 | 120 | `if_acmpne` | None | `InterfaceMethod kotlin/jvm/functions/Function1.invok` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `8610dc7e970e` | ApprovalSuspensionCoordinator.compensateSuspension | 293 | 187 | `if_acmpne` | None | `Method compensateStep:(Lkotlin/jvm/functions/Functio` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `7f06a51c7951` | ApprovalSuspensionCoordinator.compensateSuspension | 295 | 478 | `if_acmpne` | None | `Method compensateStep:(Lkotlin/jvm/functions/Functio` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `6e83174c379e` | ApprovalSuspensionCoordinator.suspendToolExecution | 145 | 158 | `if_acmpne` | None | `Method resolveGovernedSuspension:(Lkotlin/coroutines` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `c3a3a1cf672f` | ApprovalSuspensionCoordinator.suspendToolExecution | 152 | 362 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalGat` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `43dd0cdff07a` | ApprovalSuspensionCoordinator.suspendToolExecution | 218 | 1072 | `if_acmpne` | None | `Method persistSuspendedInvocation:(Ldev/tramai/engin` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `16f71d24dc95` | ApprovalSuspensionCoordinator.suspendToolExecution | 219 | 1420 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalLif` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `ce65c0b75a83` | ApprovalSuspensionCoordinator.suspendToolExecution | 239 | 1785 | `if_acmpne` | None | `Method compensateSuspension:(Ljava/lang/String;JLdev` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `8d5bb41c2eaa` | ApprovalSuspensionCoordinator$compensateSuspension$2$1.invokeSuspend | 293 | 63 | `if_acmpne` | None | `InterfaceMethod dev/tramai/engine/SuspendedInvocatio` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `e22c7dcc1e36` | ApprovalSuspensionCoordinator$compensateSuspension$2$2.invokeSuspend | 294 | 64 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalCon` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `050d2b83c852` | DefaultApprovalGateway.persistGoverned | 248 | 145 | `if_acmpne` | None | `InterfaceMethod dev/tramai/engine/approval/GovernedA` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `c98ac92dc495` | DefaultApprovalGateway.persistGoverned | 253 | 228 | `if_acmpne` | None | `InterfaceMethod dev/tramai/engine/GovernedSuspendedI` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `e602fa999053` | DefaultApprovalGateway.persistGoverned | 261 | 306 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalCon` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `d68d0252ca2d` | DefaultApprovalGateway.persistUngoverned | 223 | 130 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalSto` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `2436750ccd76` | DefaultApprovalGateway.persistUngoverned | 225 | 185 | `if_acmpne` | None | `InterfaceMethod dev/tramai/engine/SuspendedInvocatio` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `ae609c23ce29` | DefaultApprovalGateway.persistUngoverned | 230 | 243 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalCon` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `f5e8c455520a` | DefaultApprovalGateway.requestApproval-Atj0Sqo | 82 | 159 | `if_acmpne` | None | `Method "resolveGovernedIdentity-jFE5hGw":(Ljava/lang` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `6f9609fdbbab` | DefaultApprovalGateway.requestApproval-Atj0Sqo | 93 | 304 | `if_acmpne` | None | `InterfaceMethod dev/tramai/engine/approval/ApprovalG` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `4245a1aa59c9` | DefaultApprovalGateway.requestApproval-Atj0Sqo | 105 | 457 | `if_acmpne` | None | `InterfaceMethod dev/tramai/core/approval/ApprovalSto` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `e44e3a71b7ef` | DefaultApprovalGateway.requestApproval-Atj0Sqo | 107 | 618 | `if_acmpne` | None | `Method requireExistingAttributionMatches:(Ldev/trama` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `9f7ce30fc6da` | DefaultApprovalGateway.requestApproval-Atj0Sqo | 112 | 799 | `if_acmpne` | None | `Method persistUngoverned:(Ldev/tramai/engine/approva` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | +| `1b17d7d30c23` | DefaultApprovalGateway.requireExistingAttributionMatches | 210 | 151 | `if_acmpne` | None | `InterfaceMethod dev/tramai/engine/approval/GovernedA` | TIMED_OUT | TIMED_OUT | BYTECODE_ABSENCE | + +Every member: `if_acmpne` immediately after `dup` of the child call's result, i.e. `result != COROUTINE_SUSPENDED`. No member entered on mutator/status similarity. + +## 5. Phase 4 — authored-branch concealment audit + +**36 BYTECODE_ABSENCE_PROOF, 0 SIBLING_PROTECTION_REQUIRED.** For each of the 36 the source-attributed line carries **exactly one** conditional jump, and it is the mapped sentinel — so there is no authored branch on that line for a tooling-limitation classification to conceal. This is g1E's proof form A directly; the sibling path was not needed by any member, and no member's line has more than one conditional jump. + +## 6. Phase 5 — protocol-only argument + +1. Kotlin emits the sentinel comparison to implement the suspend-function protocol: after a child suspend call returns, the compiler must ask whether the child suspended. +2. The conditional encodes no TramAI business or governance decision; the 36 child calls are the ordinary suspend operations of the approval flow (`prepareResume`, `authorizeResume`, `executeClaimedResume`, `completeClaimedResume`, `emitResumeUncertainOutcomeOnce`, `persistGoverned`, `persistUngoverned`, `compensateStep`, `compensateSuspension`, `resolveGovernedSuspension`, `persistSuspendedInvocation`, `requireExistingAttributionMatches`, `resolveGovernedIdentity`, `ReplayPayload`, `BuildersKt.withContext`). +3. The branch answers only: did the child return `COROUTINE_SUSPENDED`? +4. Inverting or removing that branch breaks coroutine execution mechanics — it changes which frame resumes — rather than representing an alternative valid business outcome. +5. PIT reports `TIMED_OUT` with a test count of **zero** in two independent campaigns at the same base. Precisely: `numberOfTestsRun='0'` is an **attribute on the `` element** and is present on **every** `TIMED_OUT` mutation (70 of 70 in the discovery campaign; 62 of 62 in the approval family of this campaign), exactly as it is on all 190 `NO_COVERAGE` mutations. The frozen manifest's `0` is therefore PIT's own measurement, not a coercion of an absent element. + +> Correction record: an earlier revision of this section claimed the attribute was absent and that the `0` was an audit-side coercion. That was wrong — the claim came from searching only the element body, where the attribute does not appear. The disposition is unchanged and the evidence is stronger, not weaker; the superseded wording is recorded here rather than erased. +6. The tooling behaviour is therefore not evidence that an authored TramAI condition survived its tests. +7. Authored conditions sharing a source line: none exist (Phase 4), so nothing is deferred to the sibling path. + +`TOOLING_LIMITATION` does **not** mean the compiler branch is semantically irrelevant. It means the mutation result cannot be read as ordinary test inadequacy or equivalence, because PIT cannot execute or measure this mutation normally. + +## 7. Phase 6 — controls (anti-blanket-exemption) + +| owner class | KILLED | SURVIVED | NO_COVERAGE | TIMED_OUT | total | +| --- | --- | --- | --- | --- | --- | +| ApprovalResumeCoordinator | 107 | 24 | 1 | 18 | 150 | +| ApprovalResumeCoordinator$resume$2 | 2 | 2 | 0 | 1 | 5 | +| ApprovalSuspensionCoordinator | 71 | 6 | 2 | 8 | 87 | +| ApprovalSuspensionCoordinator$compensateSuspension$2$1 | 2 | 2 | 0 | 1 | 5 | +| ApprovalSuspensionCoordinator$compensateSuspension$2$2 | 2 | 2 | 0 | 1 | 5 | +| DefaultApprovalGateway | 57 | 8 | 2 | 12 | 79 | + +92 representative KILLED `NegateConditionals` mutants exist in these same owner classes (e.g. `ApprovalResumeCoordinator.prepareResume` line 131, line 126; `resolveAndValidateResumeTool` line 362), so the classes are not exempted from the mutator. These are supporting controls; the load-bearing proof remains exact sentinel mapping + repeatable TIMED_OUT + no concealed authored branch. + +## 8. Phase 7 — adjudication + +| outcome | count | +| --- | --- | +| TOOLING_LIMITATION | 36 | +| UNDETERMINED | 0 | + +None is called EQUIVALENT, none was killed here. Expected and actual coincide at 36/36. + +## 9. Phase 8 — parent G5 accounting + +| | | +| --- | --- | +| frozen cohort before | 52 UNDETERMINED | +| settled by G5b | 36 TOOLING_LIMITATION | +| remaining UNDETERMINED | 16 — SURVIVED 12, NO_COVERAGE 4 | + +The 16 already-adjudicated identities of the 68-row P1 manifest are not part of the frozen G5 52 and are not counted here. P1 mint remains blocked; P2 consumption remains blocked. + +## 10. Verification + +- frozen-52 digest recomputed — unchanged +- start gate: 7/7 conditions +- campaign 2: `measuredCommit` = throwaway commit, analyzer semantics identical, 918/918 family census, both report layers well-formed +- 36/36 identity-exact reconciliation, 0 missing, 0 duplicates, 0 status movement +- Phase 3/4 re-proof per member: `if_acmpne` + `dup`, one conditional jump per line, 36/36 BYTECODE_ABSENCE +- manifest JSON validated; admission ledger still empty; zero forbidden changes +- `spotlessCheck`, `verifyStaticAnalysis`, `verifyChangePolicy -PchangePolicyBase=aa0cc52cc30a…` +- exact-head CI remains the final authority + +## 11. Limits of this disposition + +The disposition rests on the mutated instruction being compiler-emitted protocol rather than authored logic, on there being no authored conditional on the same line, and on TIMED_OUT reproducing in an independent campaign. It does not claim the branch is unreachable in principle, and it does not close the 16 remaining identities. + +## 12. Next + +G5c (SURVIVED 12) and G5d (NO_COVERAGE 4), with the 7 ambiguous mappings of G5a remaining barred from disposition until the per-mutator skip rule makes their instruction unique. The final full P1 campaign runs only once all 52 are closed. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json new file mode 100644 index 000000000..c83c47ec4 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5c-SURVIVED-12-MANIFEST.json @@ -0,0 +1,426 @@ +{ + "schemaVersion": 1, + "task": "TASK-0.7.1g1G5c", + "parent": "TASK-0.7.1g1G5", + "title": "SURVIVED mapping closure and semantic adjudication (12 identities)", + "base": "42d9d9487ccf59ca3f348d0babfe9bbda232ef0f", + "baseProvenance": "squash merge of #462 on epic/0.7.1-control-plane-authority; frozen parent digest 92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584 unchanged; admission ledger admissions: []", + "campaigns": { + "control": { + "throwaway": "78458d1eecce60c7f8f307b4564fb47afda0da79", + "measuredCommit": "78458d1eecce60c7f8f307b4564fb47afda0da79", + "configDiff": "family-list narrowing only: 53 deletions in config/quality/test-quality.yml, approval block byte-identical", + "familyCensus": { + "total": 918, + "byModule": { + ":tramai-engine": 492, + ":tramai-security": 426 + }, + "statuses": { + "KILLED": 661, + "SURVIVED": 133, + "NO_COVERAGE": 62, + "TIMED_OUT": 62 + } + }, + "durationSeconds": 1000, + "buildResult": "BUILD SUCCESSFUL" + }, + "candidate": { + "throwaway": "7acbd868adb94658e90ef55d3261d315f6eddda9", + "parent": "78458d1eecce60c7f8f307b4564fb47afda0da79", + "testCommit": "d3837a5880eccf9bf23e2fc5593048e0eb06e395", + "measuredCommit": "7acbd868adb94658e90ef55d3261d315f6eddda9", + "distinctFromControl": "only the test commit", + "familyCensus": { + "total": 918, + "statuses": { + "KILLED": 662, + "SURVIVED": 132, + "NO_COVERAGE": 62, + "TIMED_OUT": 62 + } + }, + "durationSeconds": 923, + "buildResult": "BUILD SUCCESSFUL" + } + }, + "reconciliation": { + "sharedIdentities": 918, + "controlOnly": 0, + "candidateOnly": 0, + "statusMovements": 1, + "movements": [ + { + "identity": "a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747", + "from": "SURVIVED", + "to": "KILLED", + "killingTest": "ApprovalResumeSuspensionContractTest.[method:a cancellation resumed into the structured-parse uncertain path is not replaced by the primary failure()]", + "sourceLine": 102 + } + ], + "killedRegressions": 0, + "newTimeouts": 0, + "identityLoss": 0, + "identityGain": 0, + "recovered": 12, + "missing": 0, + "duplicates": 0, + "movementIdentityNote": "Full 64-hex canonical identities are carried in movement records. An earlier revision of this manifest recorded a malformed prefix (a9760bea3a92f) here; corrected, superseded not erased." + }, + "mappingDiscipline": { + "authority": "MutationIdentity.stableKey(): sha256 over module\u241fclass\u241fmethod\u241fdescriptor\u241fmutator\u241fdescription\u241fblock\u241findex; line deliberately excluded", + "verified": "reproduces all 68 identities of the P1 candidate-only manifest exactly", + "superseded": "G5a marked five identities AMBIGUOUS because opcode-shape enumeration cannot recover PIT's block/index coordinates. They are now EXACT: the canonical identity itself carries block/index, so matching PIT's emitted rows resolves each uniquely. The G5a ambiguity is retained here as superseded, not erased." + }, + "hypotheses": { + "A_observable_missing_discriminator": [ + "a9760bea3a92" + ], + "B_value_discarded": [ + "3a5c5213b226", + "56d3366434a6", + "6a1ce2f46d2a", + "a542bbe6db3f", + "f44636049288", + "eb21aaed3192" + ], + "other_structural": [ + "1ce5967ee60b", + "c7774df8ccdc", + "3658ba45eaed", + "8e18b4b48968", + "d38ac52f2843" + ], + "F_unclear": [], + "E_tooling_limitation": [] + }, + "finalTotals": { + "inputSURVIVED": 12, + "KILLED": 1, + "EQUIVALENT": 11, + "UNREACHABLE": 0, + "TOOLING_LIMITATION": 0, + "UNDETERMINED": 0 + }, + "parentAccounting": { + "parent": 52, + "settledByG5b": 36, + "inputG5c": 12, + "killed": 1, + "equivalent": 11, + "remainingForG5d": 4, + "remainingAre": "exactly the frozen NO_COVERAGE identities" + }, + "identities": [ + { + "identity": "1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "description": "removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation", + "sourceLine": 108, + "pitBlock": 91, + "pitIndex": 641, + "bytecodePc": 1100, + "instruction": "invokestatic dev/tramai/core/coroutines/CancellationKt.rethrowIfCancellation", + "g5aOriginalMapping": "AMBIGUOUS (superseded: count mismatch from opcode-shape enumeration; resolved exactly by canonical identity)", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 20, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "structural equivalence (successor convergence / handler dominance / compiler guard)", + "proofUsed": "catch-handler dominance: resume's exception table registers CancellationException->1088 and Exception->1093; pc1100 executes inside the 1093 (Exception) handler, and JVM dispatch order guarantees the earlier CancellationException handler wins. The helper's only branch (this is CancellationException) cannot fire, so removing the call cannot change behaviour.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table" + }, + { + "identity": "3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "description": "removed call to kotlin/jvm/internal/Intrinsics::checkNotNull", + "sourceLine": 160, + "pitBlock": 31, + "pitIndex": 168, + "bytecodePc": 287, + "instruction": "invokestatic kotlin/jvm/internal/Intrinsics.checkNotNull", + "g5aOriginalMapping": "AMBIGUOUS (superseded: count mismatch from opcode-shape enumeration; resolved exactly by canonical identity)", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 95, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "structural equivalence (successor convergence / handler dominance / compiler guard)", + "proofUsed": "compiler-inserted parameter guard: pc285 aload 7; pc287 checkNotNull; pc290 aload 7; pc293 invokespecial CreateApprovalChallenge(...). The checked value is immediately passed on as a non-null Kotlin constructor parameter, whose own parameter check raises the same NPE one instruction later, so removal cannot change exception type or the observable outcome on any valid path.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table" + }, + { + "identity": "3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep", + "sourceLine": 307, + "pitBlock": 17, + "pitIndex": 106, + "bytecodePc": 161, + "instruction": "areturn of kotlin/Unit.INSTANCE (preceded by getstatic kotlin/Unit.INSTANCE)", + "g5aOriginalMapping": "EXACT", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 13, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "B (returned value semantically discarded)", + "proofUsed": "caller-discard: every call site's resumed path begins with an explicit pop (compensateStep pc252, persistSuspendedInvocation pc1231, persistUngoverned pc880, persistGoverned pc1049, requireExistingAttributionMatches pc699), so the returned Unit value is never observed; replacing it with null cannot change behaviour.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table", + "bytecodePcVerifiedBy": "javap at the census base: compensateStep pc161 is areturn, line-table entry 307, preceded by getstatic kotlin/Unit.INSTANCE", + "callSiteDiscardEvidence": "supplementary: the resumed path of every call site pops the result" + }, + { + "identity": "56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches", + "sourceLine": 205, + "pitBlock": 9, + "pitIndex": 50, + "bytecodePc": 100, + "instruction": "areturn of kotlin/Unit.INSTANCE (preceded by getstatic kotlin/Unit.INSTANCE)", + "g5aOriginalMapping": "EXACT", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 6, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "B (returned value semantically discarded)", + "proofUsed": "caller-discard: every call site's resumed path begins with an explicit pop (compensateStep pc252, persistSuspendedInvocation pc1231, persistUngoverned pc880, persistGoverned pc1049, requireExistingAttributionMatches pc699), so the returned Unit value is never observed; replacing it with null cannot change behaviour.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table", + "bytecodePcVerifiedBy": "javap at the census base: requireExistingAttributionMatches pc100 is areturn, line-table entry 205, preceded by getstatic kotlin/Unit.INSTANCE", + "callSiteDiscardEvidence": "supplementary: the resumed path of every call site pops the result" + }, + { + "identity": "6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches", + "sourceLine": 219, + "pitBlock": 26, + "pitIndex": 141, + "bytecodePc": 244, + "instruction": "areturn of kotlin/Unit.INSTANCE (preceded by getstatic kotlin/Unit.INSTANCE)", + "g5aOriginalMapping": "EXACT", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 2, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "B (returned value semantically discarded)", + "proofUsed": "caller-discard: every call site's resumed path begins with an explicit pop (compensateStep pc252, persistSuspendedInvocation pc1231, persistUngoverned pc880, persistGoverned pc1049, requireExistingAttributionMatches pc699), so the returned Unit value is never observed; replacing it with null cannot change behaviour.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table", + "bytecodePcVerifiedBy": "javap at the census base: requireExistingAttributionMatches pc244 is areturn, line-table entry 219, preceded by getstatic kotlin/Unit.INSTANCE", + "callSiteDiscardEvidence": "supplementary: the resumed path of every call site pops the result" + }, + { + "identity": "8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658", + "className": "dev.tramai.engine.approval.ApprovalRunAttributionKt", + "method": "decodeApprovalAttribution", + "methodDescription": "(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;", + "mutator": "NegateConditionals", + "description": "negated conditional", + "sourceLine": 174, + "pitBlock": 22, + "pitIndex": 127, + "bytecodePc": 189, + "instruction": "ifeq following instanceof Collection (inlined any, _Collections.kt 1807)", + "g5aOriginalMapping": "AMBIGUOUS (superseded: count mismatch from opcode-shape enumeration; resolved exactly by canonical identity)", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 5, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "structural equivalence (successor convergence / handler dominance / compiler guard)", + "proofUsed": "successor convergence: negating the inlined fast-path test routes Collection receivers onto the plain-Iterable loop path; empty input returns false on both paths and non-empty input runs the identical loop, so no observable difference exists.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table" + }, + { + "identity": "a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned", + "sourceLine": 265, + "pitBlock": 35, + "pitIndex": 204, + "bytecodePc": 341, + "instruction": "areturn of kotlin/Unit.INSTANCE (preceded by getstatic kotlin/Unit.INSTANCE)", + "g5aOriginalMapping": "EXACT", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 3, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "B (returned value semantically discarded)", + "proofUsed": "caller-discard: every call site's resumed path begins with an explicit pop (compensateStep pc252, persistSuspendedInvocation pc1231, persistUngoverned pc880, persistGoverned pc1049, requireExistingAttributionMatches pc699), so the returned Unit value is never observed; replacing it with null cannot change behaviour.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table", + "bytecodePcVerifiedBy": "javap at the census base: persistGoverned pc341 is areturn, line-table entry 265, preceded by getstatic kotlin/Unit.INSTANCE", + "callSiteDiscardEvidence": "supplementary: the resumed path of every call site pops the result" + }, + { + "identity": "a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NegateConditionals", + "description": "negated conditional", + "sourceLine": 102, + "pitBlock": 76, + "pitIndex": 533, + "bytecodePc": 912, + "instruction": "ifnonnull (Elvis on e::class.simpleName)", + "g5aOriginalMapping": "EXACT", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 5, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "A (observable, missing discriminator)", + "proofUsed": "SURVIVED -> KILLED by measurement at 7acbd868: the diagnostic 'structured-parse-failed: ${e::class.simpleName ?: \"unknown\"}' is now pinned by isEqualTo in ApprovalResumeSuspensionContractTest, instead of the startsWith prefix that both the original and the mutant satisfied.", + "killingTest": "ApprovalResumeSuspensionContractTest.[engine:junit-jupiter]/[class:...ApprovalResumeSuspensionContractTest]/[method:a cancellation resumed into the structured-parse uncertain path is not replaced by the primary failure()]", + "finalStatus": "KILLED", + "finalDisposition": "KILLED", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table" + }, + { + "identity": "c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "VoidMethodCall", + "description": "removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation", + "sourceLine": 305, + "pitBlock": 16, + "pitIndex": 101, + "bytecodePc": 155, + "instruction": "invokestatic dev/tramai/core/coroutines/CancellationKt.rethrowIfCancellation", + "g5aOriginalMapping": "AMBIGUOUS (superseded: count mismatch from opcode-shape enumeration; resolved exactly by canonical identity)", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 4, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "structural equivalence (successor convergence / handler dominance / compiler guard)", + "proofUsed": "catch-handler dominance: compensateStep registers CancellationException->147 and Exception->150; pc155 executes inside the 150 handler, so the value is never a CancellationException.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table" + }, + { + "identity": "d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3", + "className": "dev.tramai.engine.approval.ApprovalRunAttributionKt", + "method": "decodeApprovalAttribution", + "methodDescription": "(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;", + "mutator": "NegateConditionals", + "description": "negated conditional", + "sourceLine": 174, + "pitBlock": 24, + "pitIndex": 131, + "bytecodePc": 189, + "instruction": "ifeq following instanceof Collection (inlined any, _Collections.kt 1807)", + "g5aOriginalMapping": "AMBIGUOUS (superseded: count mismatch from opcode-shape enumeration; resolved exactly by canonical identity)", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 5, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "structural equivalence (successor convergence / handler dominance / compiler guard)", + "proofUsed": "same fast-path test in the second inlined stratum copy; identical successor convergence.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table" + }, + { + "identity": "eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "persistSuspendedInvocation", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation", + "sourceLine": 284, + "pitBlock": 9, + "pitIndex": 38, + "bytecodePc": 54, + "instruction": "areturn of kotlin/Unit.INSTANCE (preceded by getstatic kotlin/Unit.INSTANCE)", + "g5aOriginalMapping": "EXACT", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 4, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "B (returned value semantically discarded)", + "proofUsed": "caller-discard: every call site's resumed path begins with an explicit pop (compensateStep pc252, persistSuspendedInvocation pc1231, persistUngoverned pc880, persistGoverned pc1049, requireExistingAttributionMatches pc699), so the returned Unit value is never observed; replacing it with null cannot change behaviour.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table", + "bytecodePcVerifiedBy": "javap at the census base: persistSuspendedInvocation pc54 is areturn, line-table entry 284, preceded by getstatic kotlin/Unit.INSTANCE", + "callSiteDiscardEvidence": "supplementary: the resumed path of every call site pops the result" + }, + { + "identity": "f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "NullReturnVals", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned", + "sourceLine": 234, + "pitBlock": 29, + "pitIndex": 161, + "bytecodePc": 267, + "instruction": "areturn of kotlin/Unit.INSTANCE (preceded by getstatic kotlin/Unit.INSTANCE)", + "g5aOriginalMapping": "EXACT", + "resolvedExactMapping": "EXACT", + "controlStatus": "SURVIVED", + "controlTestsRun": 7, + "discoveryStatus": "SURVIVED", + "semanticHypothesis": "B (returned value semantically discarded)", + "proofUsed": "caller-discard: every call site's resumed path begins with an explicit pop (compensateStep pc252, persistSuspendedInvocation pc1231, persistUngoverned pc880, persistGoverned pc1049, requireExistingAttributionMatches pc699), so the returned Unit value is never observed; replacing it with null cannot change behaviour.", + "killingTest": null, + "finalStatus": "SURVIVED (structurally equivalent)", + "finalDisposition": "EQUIVALENT", + "evidenceProvenance": "control campaign 78458d1e @ 42d9d948; candidate campaign 7acbd868 (child of 78458d1e + test d3837a58); javap at the census base with the SMAP-resolved line table", + "bytecodePcVerifiedBy": "javap at the census base: persistUngoverned pc267 is areturn, line-table entry 234, preceded by getstatic kotlin/Unit.INSTANCE", + "callSiteDiscardEvidence": "supplementary: the resumed path of every call site pops the result" + } + ], + "corrections": [ + { + "field": "reconciliation.movements[0].identity and the six NullReturnVals bytecodePc values", + "supersededClaim": "movement identity recorded as the malformed prefix a9760bea3a92f; the six NullReturnVals rows carried bytecodePc=null despite claiming resolvedExactMapping=EXACT", + "correctedClaim": "movement records the full 64-hex canonical identity; the six rows carry the mutated areturn PCs 161, 100, 244, 341, 267, 54, each re-verified by javap at the census base against the line table", + "foundDuring": "owner review of #463 at head 5e275ccc", + "effectOnDispositions": "none \u2014 1 KILLED / 11 EQUIVALENT stands" + } + ] +} \ No newline at end of file diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5c-SURVIVED-ADJUDICATION.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G5c-SURVIVED-ADJUDICATION.md new file mode 100644 index 000000000..e28a993c8 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5c-SURVIVED-ADJUDICATION.md @@ -0,0 +1,121 @@ +# TASK-0.7.1g1G5c — SURVIVED mapping closure and semantic adjudication + +Parent: **TASK-0.7.1g1G5**. Scope: the **12 frozen SURVIVED identities**, and nothing else. The four NO_COVERAGE identities are untouched. +Base: **`42d9d9487ccf59ca3f348d0babfe9bbda232ef0f`** — the squash merge of #462. Frozen parent digest `92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584` unchanged; admission ledger still `admissions: []`. + +**Endpoint: G5C SURVIVED COHORT CLOSED — G5 REMAINDER = EXACTLY 4 NO_COVERAGE.** + +## Final accounting + +| | | +| --- | --- | +| input SURVIVED | 12 | +| KILLED | **1** | +| EQUIVALENT | **11** | +| UNREACHABLE | 0 | +| TOOLING_LIMITATION | 0 | +| UNDETERMINED | **0** | +| mappings EXACT | **12 / 12** | +| mappings AMBIGUOUS | 0 | + +## Phase 1 — the five ambiguous mappings, repaired + +G5a marked five identities AMBIGUOUS because an opcode-shape enumeration cannot recover PIT's block/index coordinates. The authority is the repository's own `MutationIdentity.stableKey()`: `sha256(module ␟ class ␟ method ␟ descriptor ␟ mutator ␟ description ␟ block ␟ index)`, with **line deliberately excluded**. It reproduces **all 68** identities of the P1 candidate-only manifest exactly, so the canonical identity itself carries PIT's coordinates — and matching PIT's emitted rows against the frozen identity resolves each ambiguous identity uniquely: + +| identity | resolved line | PIT block/index | instruction | +| --- | --- | --- | --- | +| `1ce5967ee60b` | 108 | 91 / 641 | `pc1100 invokestatic CancellationKt.rethrowIfCancellation` | +| `c7774df8ccdc` | 305 | 16 / 101 | `pc155 invokestatic CancellationKt.rethrowIfCancellation` | +| `3658ba45eaed` | 160 | 31 / 168 | `pc287 invokestatic Intrinsics.checkNotNull` | +| `8e18b4b48968` | 174 | 22 / 127 | `pc189 ifeq` after `instanceof Collection` | +| `d38ac52f2843` | 174 | 24 / 131 | same fast-path test, second inlined stratum copy | + +The G5a AMBIGUOUS determinations are recorded as **superseded, not erased** (see `g5aOriginalMapping` per row). + +**The SMAP matters here.** `ApprovalRunAttributionKt`'s line table has entries at 171–180 while the checked-in `ApprovalRunAttribution.kt` ends at line 169. The `SourceDebugExtension` map explains it: output line 174 comes from stratum **2 = `_Collections.kt` line 1807** (inlined stdlib), and the `KotlinDebug` stratum maps it back to **our line 104** — `expected.any { metadata.getValue(it).isBlank() }`. Treating "174" as a physical line in our file would have been wrong. + +## Phase 2 — control measurement at the new base + +Family-only narrowing (53 deletions, `approval` block byte-identical), throwaway `78458d1eecce60c7f8f307b4564fb47afda0da79`, BUILD SUCCESSFUL in 1000 s, `measuredCommit` equal to the throwaway commit, analyzer semantics unchanged. Census: **918** mutants — engine 492, security 426; KILLED 661, SURVIVED 133, NO_COVERAGE 62, TIMED_OUT 62. + +Recovery: **12 input · 12 recovered · 0 missing · 0 duplicates**, every control status taken from this post-merge tip. All twelve remained SURVIVED (matrix `SURVIVED → SURVIVED` ×12, **status movement 0**), so no identity was incidentally killed by merged tests, and no identity was killed merely to raise a number. + +## Phase 3 — diagnosis + +All twelve had real `numberOfTestsRun` at control (2–95), so E (tooling limitation) was excluded from the outset and no identity was diagnosed from mutator or status similarity. + +| identity | owner | mutator | line | PIT | hypothesis | disposition | +| --- | --- | --- | --- | --- | --- | --- | +| `1ce5967ee60b` | ApprovalResumeCoordinator.resume | VoidMethodCall | 108 | b91/i641 | structural | **EQUIVALENT** | +| `3658ba45eaed` | ApprovalSuspensionCoordinator.suspendToolExecution | VoidMethodCall | 160 | b31/i168 | structural | **EQUIVALENT** | +| `3a5c5213b226` | ApprovalSuspensionCoordinator.compensateStep | NullReturnVals | 307 | b17/i106 | B | **EQUIVALENT** | +| `56d3366434a6` | DefaultApprovalGateway.requireExistingAttributionMatches | NullReturnVals | 205 | b9/i50 | B | **EQUIVALENT** | +| `6a1ce2f46d2a` | DefaultApprovalGateway.requireExistingAttributionMatches | NullReturnVals | 219 | b26/i141 | B | **EQUIVALENT** | +| `8e18b4b48968` | ApprovalRunAttributionKt.decodeApprovalAttribution | NegateConditionals | 174 | b22/i127 | structural | **EQUIVALENT** | +| `a542bbe6db3f` | DefaultApprovalGateway.persistGoverned | NullReturnVals | 265 | b35/i204 | B | **EQUIVALENT** | +| `a9760bea3a92` | ApprovalResumeCoordinator.resume | NegateConditionals | 102 | b76/i533 | A | **KILLED** | +| `c7774df8ccdc` | ApprovalSuspensionCoordinator.compensateStep | VoidMethodCall | 305 | b16/i101 | structural | **EQUIVALENT** | +| `d38ac52f2843` | ApprovalRunAttributionKt.decodeApprovalAttribution | NegateConditionals | 174 | b24/i131 | structural | **EQUIVALENT** | +| `eb21aaed3192` | ApprovalSuspensionCoordinator.persistSuspendedInvocation | NullReturnVals | 284 | b9/i38 | B | **EQUIVALENT** | +| `f44636049288` | DefaultApprovalGateway.persistUngoverned | NullReturnVals | 234 | b29/i161 | B | **EQUIVALENT** | + +## Phase 4 — the one real semantic kill + +`a9760bea3a92` is the `ifnonnull` in `"structured-parse-failed: ${e::class.simpleName ?: "unknown"}"`. The production path already exercised it, but the assertions read `startsWith("structured-parse-failed")` — satisfied both by the authoritative diagnostic and by the mutant's `unknown`. The discriminator was therefore missing, not the coverage. + +The fix is one assertion contract, tightened in place (2 lines, `d3837a58`): `isEqualTo("structured-parse-failed: StructuredOutputException")`. It is about the diagnostic contract, not about PIT: it triggers a real `StructuredOutputException` through the resume path, captures the authoritative uncertain-outcome audit, and pins the class-name component. + +Measurement: candidate `7acbd868adb94658e90ef55d3261d315f6eddda9` — a child of the control throwaway, differing by the test commit alone — BUILD SUCCESSFUL in 923 s. + +| requirement | result | +| --- | --- | +| `a9760bea3a92` | **SURVIVED → KILLED**, killing test named below | +| KILLED regressions | **0** | +| identity loss / gain | 0 / 0 | +| new unexplained timeouts | 0 | +| shared identities | 918 / 918 | + +Killing test: `ApprovalResumeSuspensionContractTest` › *a cancellation resumed into the structured-parse uncertain path is not replaced by the primary failure*, at `resume` line 102. + +## Phase 5 — the eleven equivalence proofs + +**Caller-discard (6 rows, NullReturnVals).** The mutated instruction is the function's `areturn`, immediately preceded by `getstatic kotlin/Unit.INSTANCE` — verified at pc161 (`compensateStep`, line 307), pc100 and pc244 (`requireExistingAttributionMatches`, lines 205 and 219), pc341 (`persistGoverned`, line 265), pc267 (`persistUngoverned`, line 234), pc54 (`persistSuspendedInvocation`, line 284). The mutant returns null where Unit was returned; every call site pops it. The resumed path after each suspend call begins with an explicit `pop`: `compensateStep` pc252, `persistSuspendedInvocation` pc1231, `persistUngoverned` pc880, `persistGoverned` pc1049, `requireExistingAttributionMatches` pc699. The value is never observed, so returning null is unobservable. + +**Catch-handler dominance (2 rows, VoidMethodCall).** `resume` registers `CancellationException → 1088` and `Exception → 1093`, and the mutated `pc1100` executes inside the **1093 (Exception)** handler. `compensateStep` registers `CancellationException → 147` / `Exception → 150`, with `pc155` inside the **150** handler. JVM dispatch order gives the earlier, more specific handler the cancellation, so the helper's only branch (`if (this is CancellationException) throw this`) cannot fire. Removing the call cannot change behaviour. + +**Compiler guard passed on (1 row, VoidMethodCall).** `pc285 aload 7 → pc287 Intrinsics.checkNotNull → pc290 aload 7 → pc293 invokespecial CreateApprovalChallenge(...)`. The guard protects a value that is immediately passed as a non-null Kotlin constructor parameter, whose own parameter check raises the same NPE one instruction later. Same exception type, same observable outcome on any valid path. + +**Successor convergence (2 rows, NegateConditionals).** The mutated conditional is the inlined `any` fast path — `pc186 instanceof Collection; pc189 ifeq` — whose negation routes Collection receivers onto the plain-Iterable loop. Empty input returns `false` on both paths, and non-empty input runs the identical loop, so no observable difference exists. + +Every proof is instruction-level or control-flow-level on this identity. Neighbouring G4 results were used as context only, never as a substitute. + +## Phase 8 — parent accounting + +| | | +| --- | --- | +| G5 parent | 52 | +| G5b TOOLING_LIMITATION | −36 | +| G5c input SURVIVED | 12 | +| G5c KILLED | 1 | +| G5c EQUIVALENT | 11 | +| G5c unresolved | 0 | +| remaining for G5d | **4 — exactly the frozen NO_COVERAGE identities** | + +No widening. P1 mint remains blocked; P2 consumption remains blocked; the final full canonical campaign still runs only after G5d closes. + +## Prohibited changes + +No change to `mutation-baseline.json`, the population-admission ledger, the classification or enrollment ledgers, the mutation-evolution ledger, the mutator set, timeout policy, target-family semantics, mutation ceilings, or verifier/admission semantics. **No production code changed** — no production defect was discovered. The temporary measurement narrowing is evidence machinery only and was never committed to the Epic. + +## Verification + +- frozen parent digest unchanged; admission ledger still empty +- control and candidate campaigns: same base, same narrowing, same analyzer, `measuredCommit` equal to their throwaway commits, 918/918 identities shared +- 12/12 recovered; 12/12 mappings EXACT; 0 UNDETERMINED +- exactly one status movement, 0 KILLED regressions, 0 identity loss/gain, 0 new timeouts +- focused test class green; `spotlessCheck`, `verifyStaticAnalysis`, `verifyChangePolicy -PchangePolicyBase=42d9d9487ccf…` +- exact-head CI is the final authority + +## Limits + +EQUIVALENT here means: on every reachable path, the mutant and the original cannot be distinguished by any observable behaviour — proven at instruction/control-flow level. It does not claim the instruction is unreachable, and it does not claim PIT's TIMED_OUT machinery was involved (no identity in this cohort was a tooling limitation). The four NO_COVERAGE identities are untouched and are G5d's subject. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5d-NO-COVERAGE-4-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G5d-NO-COVERAGE-4-MANIFEST.json new file mode 100644 index 000000000..13a9bef26 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5d-NO-COVERAGE-4-MANIFEST.json @@ -0,0 +1,303 @@ +{ + "schemaVersion": 1, + "task": "TASK-0.7.1g1G5d", + "parent": "TASK-0.7.1g1G5", + "base": "cd46f176b20ef7e0e0b2b0e251569214dfc90619", + "baseProvenance": "squash merge of #463; branch directly from it; working tree clean", + "cohort": { + "size": 4, + "note": "exactly the four frozen NO_COVERAGE identities; no widening, no substitution", + "mutatorNamingCorrection": "The task brief labels the two 'NRV' identities NonReturningVoidMethodCall. The frozen manifest records them as org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator, description 'replaced return value with null'. The frozen identities are authoritative and unchanged; this is a label discrepancy, not an identity discrepancy." + }, + "startGate": { + "baseMatches": "cd46f176b20ef7e0e0b2b0e251569214dfc90619", + "cleanTree": true, + "fourPresent": true, + "allFourNoCoverage": true, + "noBaselineOnlyTarget": true, + "noSubstitutedTarget": true, + "noSourceCoordinateApproximation": true, + "authoritativeView": "the G5c candidate campaign (7acbd868) whose production+test content is identical to the merged cd46f176 (the merge squashed that test plus docs), in which all four are present and NO_COVERAGE with numberOfTestsRun=0" + }, + "phaseA": { + "method": "each identity mapped by bracketing: pitBlock is monotone in pc within the method (verified True for both methods), and each method has in-method calibration points from the committed G5b/G5c manifests (pc, pitBlock, pitIndex). The target's pitBlock therefore brackets a byte window containing exactly one candidate instruction of the mutated kind.", + "calibrationPoints": { + "suspendToolExecution": [ + [ + 158, + 11, + 74 + ], + [ + 287, + 31, + 168 + ], + [ + 362, + 35, + 206 + ], + [ + 1072, + 109, + 668 + ], + [ + 1420, + 132, + 853 + ], + [ + 1785, + 157, + 1067 + ] + ], + "requestApproval-Atj0Sqo": [ + [ + 159, + 9, + 68 + ], + [ + 304, + 23, + 160 + ], + [ + 457, + 35, + 243 + ], + [ + 618, + 46, + 328 + ], + [ + 799, + 60, + 426 + ] + ] + }, + "windows": { + "suspendToolExecution": [ + { + "pitBlock": 13, + "between": "pc158 (block 11) and pc287 (block 31)", + "candidates": [ + 194 + ], + "unique": true + }, + { + "pitBlock": 12, + "between": "pc158 (block 11) and pc287 (block 31)", + "candidates": [ + 163 + ], + "unique": true + } + ], + "requestApproval-Atj0Sqo": [ + { + "pitBlock": 11, + "between": "pc159 (block 9) and pc304 (block 23)", + "candidates": [ + 204 + ], + "unique": true + }, + { + "pitBlock": 10, + "between": "pc159 (block 9) and pc304 (block 23)", + "candidates": [ + 164 + ], + "unique": true + } + ] + }, + "pitCounterNote": "PIT never reports bytecode offsets, and its block/index counter counts ASM nodes that javap does not expose (a label/line-count reconstruction matched only 4/36 calibration identities). Bracketing is therefore used, and it is validated by in-method monotonicity plus the uniqueness of each candidate window." + }, + "phaseC": { + "classification": "UNREACHABLE (all four)", + "proof": [ + "Each mutated instruction lies on the suspension protocol of one specific call: resolveGovernedSuspension() at source line 145 in suspendToolExecution, and resolveGovernedIdentity(workflowRunId) at source line 82 in requestApproval.", + "Both callees are declared suspend, but neither body contains a suspension point: resolveGovernedSuspension has 50 instructions with 0 getCOROUTINE_SUSPENDED loads and 0 if_acmpne; resolveGovernedIdentity-jFE5hGw has 32 instructions with 0 getCOROUTINE_SUSPENDED and 0 if_acmpne, invoking only Continuation.getContext, GovernedRunScope$Key accessors, GovernedRunIdentity/RunId getters, Intrinsics.areEqual, makeConcatWithConstants and an exception constructor.", + "GovernedRunScope.resolve(context) is an ordinary non-suspend function; currentCoroutineContext()/Continuation.getContext do not suspend.", + "A suspend function can return COROUTINE_SUSPENDED only through the sentinel comparison emitted after a call. With no such comparison and no suspending callee, both resolvers always return their value directly.", + "Therefore in each caller the sentinel test is always false, so the COROUTINE_SUSPENDED exit (pc163, pc164) cannot execute; and the resumed continuation for that call can only be constructed by a real suspension at that call, so the resumed-path throwOnFailure (pc194, pc204) cannot execute.", + "Nothing else branches to those instructions: no branch or switch entry in either method targets pc194 or pc204." + ], + "whyNotToolingLimitation": "No independent evidence shows the instruction executing while PIT fails to attribute coverage. The instructions cannot execute at all.", + "whyNotEquivalent": "Equivalence answers a different question. These mutants never cross the reachability boundary, so equivalence is neither claimed nor needed.", + "whyNoTestAdded": "Tests are required only for identities proven REACHABLE. A test cannot make an unreachable instruction execute without either changing production semantics or fabricating a coroutine state, both forbidden. Adding one would be 'making PIT green' rather than explaining behaviour." + }, + "phaseE": { + "suspendToolExecution": { + "members": [ + "564517b2fdab", + "66c311cf4f0a" + ], + "sameConstruct": "YES \u2014 one compiler-generated suspension: block 12 is the COROUTINE_SUSPENDED exit, block 13 is the resumed-path throwOnFailure, one block apart", + "sameInstruction": "NO", + "dominates": "NO", + "wouldOneTestCoverBoth": "irrelevant \u2014 neither is reachable", + "statusesDiffer": "they do not" + }, + "requestApproval-Atj0Sqo": { + "members": [ + "5a7edac3a982", + "e52f1c1f6570" + ], + "sameConstruct": "YES \u2014 block 10 = exit, block 11 = resumed unwrap", + "sameInstruction": "NO", + "dominates": "NO", + "wouldOneTestCoverBoth": "irrelevant \u2014 neither is reachable", + "statusesDiffer": "they do not" + }, + "crossMethodNote": "Both pairs attach to a suspend-typed resolver that has no suspension point, so both pairs share one root cause. Block/index proximity was not used as evidence of equivalence." + }, + "finalTotals": { + "KILLED": 0, + "EQUIVALENT": 0, + "UNREACHABLE": 4, + "TOOLING_LIMITATION": 0, + "UNDETERMINED": 0 + }, + "parentAccounting": { + "parent": 52, + "settledByG5b": 36, + "settledByG5c": 12, + "settledByG5d": 4, + "remaining": 0 + }, + "identities": [ + { + "identity": "564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "pitBlock": 13, + "pitIndex": 96, + "bytecodePc": 194, + "instruction": "invokestatic kotlin/ResultKt.throwOnFailure", + "suspensionRole": "resumed-path unwrap of the call to resolveGovernedSuspension() (line 145)", + "originalState": "NO_COVERAGE", + "finalState": "UNREACHABLE", + "finalClassification": "UNREACHABLE", + "reachabilityEvidence": [ + "Each mutated instruction lies on the suspension protocol of one specific call: resolveGovernedSuspension() at source line 145 in suspendToolExecution, and resolveGovernedIdentity(workflowRunId) at source line 82 in requestApproval.", + "Both callees are declared suspend, but neither body contains a suspension point: resolveGovernedSuspension has 50 instructions with 0 getCOROUTINE_SUSPENDED loads and 0 if_acmpne; resolveGovernedIdentity-jFE5hGw has 32 instructions with 0 getCOROUTINE_SUSPENDED and 0 if_acmpne, invoking only Continuation.getContext, GovernedRunScope$Key accessors, GovernedRunIdentity/RunId getters, Intrinsics.areEqual, makeConcatWithConstants and an exception constructor.", + "Therefore in each caller the sentinel test is always false, so the COROUTINE_SUSPENDED exit (pc163, pc164) cannot execute; and the resumed continuation for that call can only be constructed by a real suspension at that call, so the resumed-path throwOnFailure (pc194, pc204) cannot execute." + ], + "testAdded": null, + "killingTest": null, + "evidenceProvenance": "javap at the G5d base (classes identical to the measured campaign); PIT row (line/block/index) from the candidate campaign 7acbd868; pitBlock bracketing against in-method calibration points from the committed G5b and G5c manifests" + }, + { + "identity": "66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for ApprovalSuspensionCoordinator::suspendToolExecution", + "pitBlock": 12, + "pitIndex": 78, + "bytecodePc": 163, + "instruction": "areturn (aload 20 \u2014 the COROUTINE_SUSPENDED exit)", + "suspensionRole": "suspend exit of the call to resolveGovernedSuspension() (line 145)", + "originalState": "NO_COVERAGE", + "finalState": "UNREACHABLE", + "finalClassification": "UNREACHABLE", + "reachabilityEvidence": [ + "Each mutated instruction lies on the suspension protocol of one specific call: resolveGovernedSuspension() at source line 145 in suspendToolExecution, and resolveGovernedIdentity(workflowRunId) at source line 82 in requestApproval.", + "Both callees are declared suspend, but neither body contains a suspension point: resolveGovernedSuspension has 50 instructions with 0 getCOROUTINE_SUSPENDED loads and 0 if_acmpne; resolveGovernedIdentity-jFE5hGw has 32 instructions with 0 getCOROUTINE_SUSPENDED and 0 if_acmpne, invoking only Continuation.getContext, GovernedRunScope$Key accessors, GovernedRunIdentity/RunId getters, Intrinsics.areEqual, makeConcatWithConstants and an exception constructor.", + "Therefore in each caller the sentinel test is always false, so the COROUTINE_SUSPENDED exit (pc163, pc164) cannot execute; and the resumed continuation for that call can only be constructed by a real suspension at that call, so the resumed-path throwOnFailure (pc194, pc204) cannot execute." + ], + "testAdded": null, + "killingTest": null, + "evidenceProvenance": "javap at the G5d base (classes identical to the measured campaign); PIT row (line/block/index) from the candidate campaign 7acbd868; pitBlock bracketing against in-method calibration points from the committed G5b and G5c manifests" + }, + { + "identity": "5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "pitBlock": 11, + "pitIndex": 92, + "bytecodePc": 204, + "instruction": "invokestatic kotlin/ResultKt.throwOnFailure", + "suspensionRole": "resumed-path unwrap of the call to resolveGovernedIdentity(workflowRunId) (line 82)", + "originalState": "NO_COVERAGE", + "finalState": "UNREACHABLE", + "finalClassification": "UNREACHABLE", + "reachabilityEvidence": [ + "Each mutated instruction lies on the suspension protocol of one specific call: resolveGovernedSuspension() at source line 145 in suspendToolExecution, and resolveGovernedIdentity(workflowRunId) at source line 82 in requestApproval.", + "Both callees are declared suspend, but neither body contains a suspension point: resolveGovernedSuspension has 50 instructions with 0 getCOROUTINE_SUSPENDED loads and 0 if_acmpne; resolveGovernedIdentity-jFE5hGw has 32 instructions with 0 getCOROUTINE_SUSPENDED and 0 if_acmpne, invoking only Continuation.getContext, GovernedRunScope$Key accessors, GovernedRunIdentity/RunId getters, Intrinsics.areEqual, makeConcatWithConstants and an exception constructor.", + "Therefore in each caller the sentinel test is always false, so the COROUTINE_SUSPENDED exit (pc163, pc164) cannot execute; and the resumed continuation for that call can only be constructed by a real suspension at that call, so the resumed-path throwOnFailure (pc194, pc204) cannot execute." + ], + "testAdded": null, + "killingTest": null, + "evidenceProvenance": "javap at the G5d base (classes identical to the measured campaign); PIT row (line/block/index) from the candidate campaign 7acbd868; pitBlock bracketing against in-method calibration points from the committed G5b and G5c manifests" + }, + { + "identity": "e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for DefaultApprovalGateway::requestApproval-Atj0Sqo", + "pitBlock": 10, + "pitIndex": 72, + "bytecodePc": 164, + "instruction": "areturn (aload 12 \u2014 the COROUTINE_SUSPENDED exit)", + "suspensionRole": "suspend exit of the call to resolveGovernedIdentity(workflowRunId) (line 82)", + "originalState": "NO_COVERAGE", + "finalState": "UNREACHABLE", + "finalClassification": "UNREACHABLE", + "reachabilityEvidence": [ + "Each mutated instruction lies on the suspension protocol of one specific call: resolveGovernedSuspension() at source line 145 in suspendToolExecution, and resolveGovernedIdentity(workflowRunId) at source line 82 in requestApproval.", + "Both callees are declared suspend, but neither body contains a suspension point: resolveGovernedSuspension has 50 instructions with 0 getCOROUTINE_SUSPENDED loads and 0 if_acmpne; resolveGovernedIdentity-jFE5hGw has 32 instructions with 0 getCOROUTINE_SUSPENDED and 0 if_acmpne, invoking only Continuation.getContext, GovernedRunScope$Key accessors, GovernedRunIdentity/RunId getters, Intrinsics.areEqual, makeConcatWithConstants and an exception constructor.", + "Therefore in each caller the sentinel test is always false, so the COROUTINE_SUSPENDED exit (pc163, pc164) cannot execute; and the resumed continuation for that call can only be constructed by a real suspension at that call, so the resumed-path throwOnFailure (pc194, pc204) cannot execute." + ], + "testAdded": null, + "killingTest": null, + "evidenceProvenance": "javap at the G5d base (classes identical to the measured campaign); PIT row (line/block/index) from the candidate campaign 7acbd868; pitBlock bracketing against in-method calibration points from the committed G5b and G5c manifests" + } + ], + "regressionCampaign": { + "campaign": { + "throwaway": "56364ae3970dec4b6edd7e1507b98a9f0d2fc5f1", + "base": "cd46f176b20ef7e0e0b2b0e251569214dfc90619", + "buildResult": "BUILD SUCCESSFUL", + "durationSeconds": 1033, + "population": 918, + "statuses": { + "KILLED": 662, + "NO_COVERAGE": 62, + "SURVIVED": 132, + "TIMED_OUT": 62 + }, + "configDiff": "family-list narrowing only: 53 deletions, approval block byte-identical" + }, + "reference": { + "campaign": "G5c candidate 7acbd868", + "note": "identical production+test content to the G5d base" + }, + "sharedIdentities": 918, + "g5dOnly": 0, + "referenceOnly": 0, + "statusMovements": 0, + "previouslyKilledRegressions": 0, + "newTimeouts": 0, + "cohort": { + "allFourPresent": true, + "allFourNoCoverage": true, + "widening": false + } + } +} \ No newline at end of file diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G5d-NO-COVERAGE-REACHABILITY.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G5d-NO-COVERAGE-REACHABILITY.md new file mode 100644 index 000000000..11c8b0723 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G5d-NO-COVERAGE-REACHABILITY.md @@ -0,0 +1,122 @@ +# TASK-0.7.1g1G5d — Exact NO_COVERAGE reachability closure + +Parent: **TASK-0.7.1g1G5**. Scope: exactly the **four frozen NO_COVERAGE identities**. No widening, no substitution, no production change. +Base: **`cd46f176b20ef7e0e0b2b0e251569214dfc90619`** — the squash merge of #463, branched directly from it, clean tree. + +**G5D COMPLETE — FINAL G5 NO_COVERAGE COHORT CLOSED.** (subject to the regression campaign recorded below) + +## Baseline and start gate + +| condition | result | +| --- | --- | +| HEAD == `cd46f176b20e…` | yes, worktree branched directly from that SHA | +| clean working tree | yes | +| four canonical identities exist | 4/4 | +| all four still NO_COVERAGE | 4/4, `numberOfTestsRun = 0` | +| baseline-only target / substituted target | none | +| source-coordinate approximation as the same mutant | none | + +The authoritative view is the G5c candidate campaign (`7acbd868`), whose production+test content is identical to the merged `cd46f176` (the merge squashed exactly that test plus docs). In it all four are present and NO_COVERAGE. + +**Mutator naming:** the task brief labels the two "NRV" identities `NonReturningVoidMethodCall`. The frozen manifest records `org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator`, description *"replaced return value with null"*. The frozen identities are authoritative and unchanged — a label discrepancy, not an identity discrepancy. + +## Identity table + +| identity | owner | mutator | PIT block/index | PC | mutated instruction | state | +| --- | --- | --- | --- | --- | --- | --- | +| `564517b2fdab` | ApprovalSuspensionCoordinator.suspendToolExecution | VoidMethodCallMutator | 13/96 | **pc194** | invokestatic kotlin/ResultKt.throwOnFailure | NO_COVERAGE → **UNREACHABLE** | +| `66c311cf4f0a` | ApprovalSuspensionCoordinator.suspendToolExecution | NullReturnValsMutator | 12/78 | **pc163** | areturn (aload 20 — the COROUTINE_SUSPENDED exit) | NO_COVERAGE → **UNREACHABLE** | +| `5a7edac3a982` | DefaultApprovalGateway.requestApproval-Atj0Sqo | VoidMethodCallMutator | 11/92 | **pc204** | invokestatic kotlin/ResultKt.throwOnFailure | NO_COVERAGE → **UNREACHABLE** | +| `e52f1c1f6570` | DefaultApprovalGateway.requestApproval-Atj0Sqo | NullReturnValsMutator | 10/72 | **pc164** | areturn (aload 12 — the COROUTINE_SUSPENDED exit) | NO_COVERAGE → **UNREACHABLE** | + +## Phase A — bytecode-exact mapping + +PIT never reports bytecode offsets, and its block/index counter includes ASM node kinds that `javap` does not expose — a label-and-line-count reconstruction of that counter matched only 4 of 36 calibration identities, so it was abandoned rather than tuned until it fit. + +Instead each identity is bracketed. `pitBlock` is **monotone in pc within a method** (verified true for both methods), and each method carries in-method calibration points from the committed G5b/G5c manifests with both a known pc and known PIT coordinates: + +- `suspendToolExecution`: (158, 11, 74) · (287, 31, 168) · (362, 35, 206) · (1072, 109, 668) · (1420, 132, 853) · (1785, 157, 1067) +- `requestApproval-Atj0Sqo`: (159, 9, 68) · (304, 23, 160) · (457, 35, 243) · (618, 46, 328) · (799, 60, 426) + +Each target's block therefore brackets a pc window, and exactly one candidate instruction of the mutated kind lies inside: + +| identity | window | candidates in window | selected | +| --- | --- | --- | --- | +| `564517b2fdab` (block 13) | pc158 … pc287 | `throwOnFailure` at 194 | **pc194** | +| `66c311cf4f0a` (block 12) | pc158 … pc287 | `areturn` at 163 | **pc163** | +| `5a7edac3a982` (block 11) | pc159 … pc304 | `throwOnFailure` at 204 | **pc204** | +| `e52f1c1f6570` (block 10) | pc159 … pc304 | `areturn` at 164 | **pc164** | + +Both `areturn` sites are preceded by a load of a local holding `COROUTINE_SUSPENDED` — they are the suspension exits; both `throwOnFailure` sites are the resumed-path unwraps of the same call, one block later. The mapping is additional to, and independent of, the source line, which is useless here: **line 142 is the function header of `suspendToolExecution` and line 73 the header of `requestApproval`**, so every state-machine instruction inherits it. + +## Reachability evidence — all four UNREACHABLE + +Each mutated instruction is on the suspension protocol of one specific call: `resolveGovernedSuspension()` (source line 145) in `suspendToolExecution`, and `resolveGovernedIdentity(workflowRunId)` (source line 82) in `requestApproval`. + +**Neither callee can suspend.** + +- `resolveGovernedSuspension`: 50 instructions, **0 `getCOROUTINE_SUSPENDED` loads, 0 `if_acmpne`** — no sentinel comparison at all. +- `resolveGovernedIdentity-jFE5hGw`: 32 instructions, **0 `getCOROUTINE_SUSPENDED`, 0 `if_acmpne`**; its invokes are only `Continuation.getContext`, `GovernedRunScope$Key` accessors, `GovernedRunIdentity`/`RunId` getters, `Intrinsics.areEqual`, `makeConcatWithConstants` and an exception constructor. +- `GovernedRunScope.resolve(context)` is an ordinary **non-suspend** function, and `currentCoroutineContext()`/`Continuation.getContext` do not suspend. + +A suspend function can return `COROUTINE_SUSPENDED` only through the sentinel comparison emitted after a call. With no such comparison and no suspending callee, both resolvers always return their value directly. Therefore: + +1. in each caller the sentinel test is always false, so the `COROUTINE_SUSPENDED` exit (pc163, pc164) **cannot execute** — the NullReturnVals identity; +2. the resumed continuation for that call can only be constructed by a real suspension at that call, so the resumed-path `throwOnFailure` (pc194, pc204) **cannot execute** — the VoidMethodCall identity; +3. nothing else reaches them: **no branch or switch entry in either method targets pc194 or pc204**. + +That is control-flow dominance plus callee analysis, not "I could not find a test". No legal production execution reaches any of the four. + +**Why not TOOLING_LIMITATION:** there is no independent evidence that the instruction executes while PIT fails to attribute coverage. The instructions cannot execute at all. +**Why not EQUIVALENT:** equivalence answers a different question, and these mutants never cross the reachability boundary. It is neither claimed nor needed. + +## Test evidence + +**No tests were added, and that is the correct outcome.** Tests are permitted only for identities proven REACHABLE. Making these instructions execute would require either changing production semantics (forbidden) or fabricating a coroutine state the runtime cannot produce. A test whose only purpose would be to move PIT's coverage counter is exactly what this task forbids. + +## Phase E — pair analysis + +| pair | same compiler-generated construct | same instruction | one dominates the other | +| --- | --- | --- | --- | +| `564517b2fdab` / `66c311cf4f0a` (`suspendToolExecution`) | **YES** — block 12 is the `COROUTINE_SUSPENDED` exit, block 13 the resumed-path `throwOnFailure`, one block apart | NO | NO | +| `5a7edac3a982` / `e52f1c1f6570` (`requestApproval-Atj0Sqo`) | **YES** — block 10 the exit, block 11 the resumed unwrap | NO | NO | + +Both pairs attach to a suspend-typed resolver with no suspension point, so they share one root cause across both methods. Whether one legitimate test would cover both is moot: neither member is reachable. Block/index adjacency was not treated as evidence of equivalence. + +## Final classification + +| state | count | +| --- | --- | +| KILLED | 0 | +| EQUIVALENT | 0 | +| **UNREACHABLE** | **4** | +| TOOLING_LIMITATION | 0 | +| UNDETERMINED | **0** | + +Parent accounting: 52 = 36 (G5b TOOLING_LIMITATION) + 12 (G5c: 1 KILLED, 11 EQUIVALENT) + 4 (G5d UNREACHABLE). **G5 remainder: 0.** + +## Diff scope + +Tests: none. Production: none. Documentation: this report and the four-identity manifest. The measurement narrowing is evidence machinery, never committed. + +## Regression gate — PASSED + +Identity-exact campaign from a fresh worktree at `cd46f176b20ef7e0e0b2b0e251569214dfc90619`, throwaway `56364ae3970dec4b6edd7e1507b98a9f0d2fc5f1` (family-list narrowing only: 53 deletions, `approval` block byte-identical), BUILD SUCCESSFUL in 17m13s, `measuredCommit` equal to the throwaway commit. + +| requirement | result | +| --- | --- | +| population | 918 (662 KILLED / 132 SURVIVED / 62 NO_COVERAGE / 62 TIMED_OUT) | +| 4/4 frozen identities accounted for | yes — all four present, all four NO_COVERAGE | +| identity loss | 0 | +| identity gain / substitution | 0 | +| shared identities vs the reference campaign | 918 / 918 | +| status movements | 0 | +| new timeouts | 0 | +| regressions of previously KILLED identities | 0 | +| widening beyond the four-target cohort | none (population size unchanged) | + +The reference is the G5c candidate campaign (`7acbd868`), whose production+test content is identical to this base; the two campaigns agree exactly — identity for identity, status for status. + +## Limits + +`UNREACHABLE` here rests on the callees being unable to suspend (no sentinel comparison, no suspending callee) plus the absence of any other branch into the resumed block. It is a statement about this compiled artifact at this base: if either resolver ever gains a suspension point — or if a call site acquires a genuine suspension — these instructions become live and these identities must be re-adjudicated rather than inherited. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G6-CANONICAL-POPULATION-FREEZE.json b/docs/roadmap/0.7.0/TASK-0.7.1g1G6-CANONICAL-POPULATION-FREEZE.json new file mode 100644 index 000000000..be2a6fbdb --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G6-CANONICAL-POPULATION-FREEZE.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "task": "TASK-0.7.1g1G6", + "parent": "TASK-0.7.1", + "title": "Final canonical population reconciliation and digest freeze", + "checkpoint": "284faa4fe590eaf20c91843c119053fb6c384b4d", + "checkpointProvenance": "squash merge of #465; G5 closed at 52/52 with remainder 0; admission ledger admissions: []", + "campaign": { + "configuration": "the real committed 7-family canonical configuration, NO narrowing (worktree config diff empty)", + "worktree": "/tmp/tramai-g6-measure at the checkpoint", + "measuredCommit": "284faa4fe590eaf20c91843c119053fb6c384b4d", + "buildResult": "BUILD SUCCESSFUL", + "durationSeconds": 2943, + "reportCount": 14, + "families": [ + "approval", + "evidence", + "policy", + "retry", + "routing", + "structuredOutput", + "tools" + ], + "analyzer": { + "pluginVersion": "1.19.0", + "engineVersion": "1.22.1", + "mutators": [ + "CONDITIONALS_BOUNDARY", + "INCREMENTS", + "INVERT_NEGS", + "MATH", + "NEGATE_CONDITIONALS", + "TRUE_RETURNS", + "FALSE_RETURNS", + "PRIMITIVE_RETURNS", + "EMPTY_RETURNS", + "NULL_RETURNS", + "VOID_METHOD_CALLS" + ], + "timeoutConst": 4000, + "timeoutFactor": 1.25 + } + }, + "frozenPopulation": { + "rows": 2544, + "statuses": { + "KILLED": 1836, + "NO_COVERAGE": 190, + "SURVIVED": 448, + "TIMED_OUT": 70 + }, + "outcomes": { + "KILLED": 1836, + "NON_KILLED": 708 + }, + "byFamily": { + "approval": 918, + "evidence": 808, + "policy": 209, + "retry": 155, + "routing": 15, + "structuredOutput": 382, + "tools": 57 + }, + "completePopulationDigest": "9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0", + "digestRecipe": "the repository's own MutationRatchetVerifier.canonicalProjection(): sorted 'identity|status|outcome|family|module' lines, then 'topology=' over family:modules, then 'analyzer=plugin|engine|sorted mutators|timeoutConst|timeoutFactor', SHA-256", + "digestImplementationValidated": "the same implementation reproduces the committed population's digest 9e2febc775dd1c2c33d8cabc805e54690b2eaacc6d9ce82bea2758e1c7866230 exactly", + "supersededDigest": "7081ed74\u2026 is NOT reused: the measured artifact and custody record both moved since it was produced" + }, + "reconciliation": { + "committedRows": 2384, + "freshRows": 2544, + "shared": 2195, + "baseOnly": 189, + "candidateOnly": 349, + "changedStatusAmongShared": 52, + "candidateOnlyByStatus": { + "SURVIVED": 27, + "KILLED": 282, + "TIMED_OUT": 36, + "NO_COVERAGE": 4 + }, + "candidateOnlyByFamily": { + "approval": 296, + "evidence": 53 + }, + "identityLoss": 0, + "identityGainOrSubstitution": 0, + "unexplainedDisappearance": 0 + }, + "disappearanceCustody": { + "baseOnlyRows": 189, + "withSourceLevelCounterpart": 189, + "withoutCounterpart": 0, + "mechanism": "the canonical identity deliberately excludes source line but includes PIT block/index, so an edited mutation site keeps its source-level key while its identity changes. All 189 base-only rows match a fresh row on (module, class, method, descriptor, mutator, description).", + "caveat": "this is a key-level relocation match, not a proof of semantic identity; the evolution ledger (mutation-evolution.yml) is empty and cites none of the 189, so it is not the custody mechanism here." + }, + "admissibility": { + "appearingNonKilled": 67, + "covered": 67, + "missing": [], + "byDocument": { + "P1-manifest": 16, + "G5b": 36, + "G5c": 11, + "G5d": 4 + }, + "doubleCoverage": 0, + "note": "16 prior adjudications recorded in the P1 candidate-only manifest, 36 G5b TOOLING_LIMITATION, 11 G5c EQUIVALENT (the twelfth G5c identity was KILLED and is not in the appearing population), 4 G5d UNREACHABLE" + }, + "determinism": { + "independentAggregation": "re-derived from the 14 raw PIT reports with XML unescaping: 2544 unique identities, identical identity set, 0 status mismatches, 0 family mismatches", + "digestReproduced": true + }, + "artifactRelativeCaveat": "The four G5d UNREACHABLE classifications rest on resolveGovernedSuspension()/resolveGovernedIdentity() being unable to suspend. If either resolver ever gains a genuine suspension point, those four must become candidates for re-adjudication rather than remain grandfathered.", + "authorityChanges": "none \u2014 ledger remains admissions: []; no baseline, classification, enrollment, mutator, timeout or ceiling change; the campaign was run in an isolated worktree and its outputs were not committed", + "endpoint": "CANONICAL POPULATION FROZEN \u2014 P1 MINT UNBLOCKED", + "notDone": [ + "P1 authorizations were not minted", + "P2 consumption was not performed", + "no authority file was modified" + ] +} \ No newline at end of file diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1G6-CANONICAL-POPULATION-FREEZE.md b/docs/roadmap/0.7.0/TASK-0.7.1g1G6-CANONICAL-POPULATION-FREEZE.md new file mode 100644 index 000000000..1b1ac226e --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1G6-CANONICAL-POPULATION-FREEZE.md @@ -0,0 +1,72 @@ +# TASK-0.7.1g1G6 — Final canonical population reconciliation and digest freeze + +Parent: **TASK-0.7.1**. This is the gate between G5 closure and P1, not further mutant adjudication. +Checkpoint: **`284faa4fe590eaf20c91843c119053fb6c384b4d`** (the squash merge of #465). Admission ledger: `admissions: []` — unchanged by this task. + +**Endpoint: CANONICAL POPULATION FROZEN — P1 MINT UNBLOCKED** + +## Why this gate exists + +G5 closed 52 = 36 + 12 + 4 with remainder 0. That proves the *known* custody set is closed. It does not prove that the final unrestricted canonical campaign contains the population we believe it does — which is precisely why remainder-0 is not itself mint authority. + +## Campaign + +The **real committed canonical configuration with no narrowing** (worktree config diff empty), at the checkpoint, in an isolated worktree: **BUILD SUCCESSFUL in 49m 3s**, 14 reports across all 7 families, `measuredCommit` equal to the checkpoint, analyzer semantics unchanged. + +| | | +| --- | --- | +| rows | **2544** | +| statuses | 1836 KILLED · 448 SURVIVED · 190 NO_COVERAGE · 70 TIMED_OUT | +| outcomes | 1836 KILLED · 708 NON_KILLED | +| by family | approval 918 · evidence 808 · structuredOutput 382 · policy 209 · retry 155 · tools 57 · routing 15 | +| **complete-population digest** | **`9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0`** | + +The digest is the repository's own `MutationRatchetVerifier.canonicalProjection()` recipe. My implementation was **validated before use** by reproducing the committed population's digest `9e2febc775dd1c2c33d8cabc805e54690b2eaacc6d9ce82bea2758e1c7866230` exactly. `7081ed74…` is **not reused** — the measured artifact and custody record have both moved since it was produced. + +## Reconciliation + +| | | +| --- | --- | +| committed population | 2384 | +| fresh population | 2544 | +| shared | 2195 | +| base-only (disappeared) | 189 | +| candidate-only (appeared) | 349 — approval 296, evidence 53 | +| changed status among shared | 52 | +| identity loss / gain / substitution | 0 / 0 / 0 | +| **unexplained disappearance** | **0** | + +## Disappearance custody + +All **189/189** base-only identities match a fresh row on the source-level key `(module, class, method, descriptor, mutator, description)`. The canonical identity deliberately excludes the source line but includes PIT's block/index, so an edited mutation site keeps its key while its identity changes — the relocation the G1 lineage described. + +**Caveat, stated plainly:** this is a key-level relocation match, not a proof of semantic identity, and `mutation-evolution.yml` is empty and cites none of the 189, so it is *not* the custody mechanism here. The match is the evidence; the ledger is not covering this. + +## Appearing-population admissibility + +**67 appearing NON_KILLED identities, 67 covered, 0 missing, 0 double-covered:** + +| source | count | +| --- | --- | +| prior adjudications in the P1 candidate-only manifest | 16 | +| G5b TOOLING_LIMITATION | 36 | +| G5c EQUIVALENT | 11 | +| G5d UNREACHABLE | 4 | + +The twelfth G5c identity was **KILLED**, so it is not in the appearing population. The union is exact and disjoint. + +## Determinism + +The population was independently re-derived from the 14 raw PIT reports (with XML unescaping): **2544 unique identities, identical identity set, 0 status mismatches, 0 family mismatches, and the identical digest**. The projection is reproducible, not merely asserted. + +## Authority + +**No authority-semantic change was made.** The ledger is still `admissions: []`; no baseline, classification, enrollment, mutator, timeout or ceiling file changed; the campaign ran in an isolated worktree whose outputs were not committed. Nothing was modified to satisfy the measurement. + +## Preserved caveat + +The four G5d **UNREACHABLE** classifications are artifact-relative: they rest on `resolveGovernedSuspension()` and `resolveGovernedIdentity()` being unable to suspend. If either resolver ever gains a genuine suspension point, those four become re-adjudication candidates rather than remaining grandfathered. + +## Not done + +P1 authorizations were not minted. P2 consumption was not performed. No authority file was touched. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json b/docs/roadmap/0.7.0/TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json new file mode 100644 index 000000000..f3e19658c --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json @@ -0,0 +1,1649 @@ +{ + "schemaVersion": 1, + "record": "TASK-0.7.1g1P1 STOP evidence \u2014 candidate-only NON_KILLED 68", + "purpose": "evidence only - NOT mutation authority, NOT admission authority, NOT P1 mint input", + "measuredBaseSha": "18f10dbb2c90fbfeafc374e1d7fe0d8814e342b1", + "discoveryPopulationDigest": "7081ed7416f759ecca7cae00c7cf4e6261ef0cd16db49fe32bc3bb6f280bbddd", + "digestStatus": "PRE-ADJUDICATION DISCOVERY MEASUREMENT \u2014 NOT YET P1 MINT AUTHORITY", + "cohort": { + "total": 2544, + "KILLED": 1835, + "SURVIVED": 449, + "NO_COVERAGE": 190, + "TIMED_OUT": 70, + "committed": 2384, + "shared": 2195, + "baseOnly": 189, + "candidateOnly": 349, + "candidateOnlyKilled": 281, + "candidateOnlyNonKilled": 68, + "changedSharedRows": 52 + }, + "split": { + "priorAdjudication": 16, + "unresolved": 52, + "unresolvedByStatus": { + "TIMED_OUT": 36, + "SURVIVED": 12, + "NO_COVERAGE": 4 + } + }, + "unresolvedDigest": "92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584", + "identities": [ + { + "identity": "050d2b83c8527c768c73ba7fdaa77021fd04a05638d3b665a724cfccd98f9164", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 12, + "index": 74, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "098258d70a2379a58e39d24f5f85824705998bb6d3e20cf0bec54ffecc4db0a6", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 7, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "11", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "09ea31d4d0e8b35c80543a6c812d206ce3d949fad35e2c5ad14e48e1332f89c0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 5, + "index": 32, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "10512d6c6e0078e866ab5926dcb17a5b3d77edf65a3e0bb77df3820bc8ba65e1", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 31, + "index": 170, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "16f71d24dc95007e1a903b98c660c2bf0ec975915a1e9c41ed727689f2ccd5a2", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 132, + "index": 853, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "1b17d7d30c234dcb3e0e8081542d742c2c5ec6b4716c5ba787b4edf3be5a22a1", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 14, + "index": 79, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "1ce5967ee60b6f4380d69dead598dde89212eeb17c2b9be6348bf9dfc3928981", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation", + "line": null, + "block": 91, + "index": 641, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "20", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "2436750ccd7646d3803f335216c2fdecf27a58b90acb8250b5929a634201d5c5", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 17, + "index": 101, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "252596080a3a8e97a347ef98bc08ae9af6fa6c3db5d8c432744148d5109f9848", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 54, + "index": 356, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "2b197f5cd3cba2e7f02c6ebfc6701d945e397b4166f04f4218a2e591892b85fe", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 10, + "index": 63, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "3658ba45eaedbf6a946036d210a91578d65fda8b0f0f295137393d3454e9a5e0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/jvm/internal/Intrinsics::checkNotNull", + "line": null, + "block": 31, + "index": 168, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "95", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/jvm/internal/Intrinsics::checkNotNull", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "3a5c5213b226f1f86b48c12172489ee618b9af890d750ab3ac1d7d2032854e0a", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep", + "line": null, + "block": 17, + "index": 106, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "13", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::compensateStep", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "4245a1aa59c91bc59bd4fe50b0a42bcef26b9c5b4c39e10771b3f6e3602de63d", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 35, + "index": 243, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "43dd0cdff07a1d862bc17951e50cbb1b7d8a5d77d91348744b2910467c01f204", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 109, + "index": 668, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "4865b74caf50c74ee8bfb7994960e3bf640e3a8c6ecc32cd9a99eef5bafc94bf", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 9, + "index": 59, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "492f9a132e141f512c5690d8b0d43c6e5925b488de13d80ef71504bcf61b62d4", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 7, + "index": 40, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "3", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json", + "disposition": "EQUIVALENT" + }, + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "UNDETERMINED" + } + ], + "unresolved": false + }, + { + "identity": "564517b2fdab899eceefac4eb9dbb40f3a60139a5c6f917f5fe7c1b172fc9d67", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 13, + "index": 96, + "freshRawStatus": "NO_COVERAGE", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "56d3366434a6608d7b5ccd59b6c1377be7324ab2078ec7a09b872153968e4466", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches", + "line": null, + "block": 9, + "index": 50, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "6", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "5a7edac3a982221ce692e032184362a1db8efc49292d9ea0b5716824303ecef1", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 11, + "index": 92, + "freshRawStatus": "NO_COVERAGE", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "5d700610726248849d6141d8f8159c688932d1759e3f3d20bbb6fe966cb670e4", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 19, + "index": 167, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "60f7b020e009cc99a8a80e19e34a956e570614449294444db057993e433b1ac6", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 7, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "14", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "611bdacfe9f54230f4c152506c0491fa01445042d992cacdfaad2cee031d2cd4", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 14, + "index": 95, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "62911071da6c7a9fe85f76a41c5a23d56b1156e2d1447dd96ccce962aabeff3b", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "revealAndValidateReplayPayload", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 33, + "index": 205, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "1", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|revealAndValidateReplayPayload|(Ldev/tramai/engine/approval/ResumeUncertainOutcome;Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/SuspendedInvocationMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json", + "disposition": "EQUIVALENT" + }, + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "UNDETERMINED" + } + ], + "unresolved": false + }, + { + "identity": "63e5372ba37bc3b097def60c22bddd050404d168b26c2fa73bc94a99a6705c1e", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 55, + "index": 292, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "6", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-11-MANIFEST.json", + "disposition": "EQUIVALENT" + }, + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "UNDETERMINED" + } + ], + "unresolved": false + }, + { + "identity": "66c311cf4f0ae36396880146f9059264308168b0286cdee72b852076560df61e", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution", + "line": null, + "block": 12, + "index": 78, + "freshRawStatus": "NO_COVERAGE", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::suspendToolExecution", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "6a1ce2f46d2ad5cdb7fb857a56fcf4ca0d3d87ffc3825279121386e04c3f7c88", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches", + "line": null, + "block": 26, + "index": 141, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "2", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requireExistingAttributionMatches", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "6cadd3397125ba1bdc4b72d2267c73f6b9ab4fdcd77205691f0d10adc07e5101", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 54, + "index": 441, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "6cef3fd2e23d3df1571d390564b8274596c70c97965bc5719603d39116b74b8c", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 2, + "index": 12, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "13", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "6e83174c379e04bed5f19fcf1b87028e3230064cd5c85e570c00e164acf9dada", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 11, + "index": 74, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "6f9609fdbbabaf152fc2ccc4f9728c40bcefc962cd0377592fc6684ae18acb58", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 23, + "index": 160, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "70d345f72064e7cea4ebb9cff97f74946f00d582ba38a4e62c5498261494b0a0", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend", + "line": null, + "block": 8, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "10", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$3::invokeSuspend", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (discarded lambda result)" + } + ], + "unresolved": false + }, + { + "identity": "7f06a51c79515d82dcabce9af7ae1d77d8a3691fd2b99528f3e17212204ee512", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 30, + "index": 239, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "827431d2f104e64e451d5a2c6993ba211604b8ca06048ea4b681fc017ab4815f", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 67, + "index": 459, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "8610dc7e970e4c48ae0d47971626c7769a141837b34d53d5610af7d596bf8593", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 12, + "index": 90, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "8d5bb41c2eaa72679422c10b5d20524d7605a23c839b989c83952f93eb0cebcf", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 5, + "index": 28, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "8e18b4b48968d65f181bd226d6e81d99c6c9abb3427463ae77773eb4c901e658", + "className": "dev.tramai.engine.approval.ApprovalRunAttributionKt", + "method": "decodeApprovalAttribution", + "methodDescription": "(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 22, + "index": 127, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "5", + "sourceFile": "ApprovalRunAttribution.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalRunAttributionKt|decodeApprovalAttribution|(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "9b9bfadffcdc04d8b2b0b8c2a07f6d86863e455634d52cded8e90259765ea318", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 42, + "index": 264, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "9e3d60d7a85525416ee27ca9ce1abac74612ec1ba1134a64f13b3148d85cbd04", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 11, + "index": 68, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "9f7ce30fc6da401efe187481149db12210e987367a547e032c9cc182d0e187be", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 60, + "index": 426, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "a25dbb8c0826d5df57d94e0003d897c2ac066acefe43d7f1e087414be5fc3feb", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 61, + "index": 423, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "a542bbe6db3f9f551bbbea6972a28dc670945d1232221bd1d9a3f98814a6defb", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned", + "line": null, + "block": 35, + "index": 204, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "3", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistGoverned", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "a9760bea3a92a752255a2becee7edb3ffa0957cf0e88cb75a0a11db850dcc747", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "resume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 76, + "index": 533, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "5", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|resume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "ab78ed8577ff2073f4690730365919999ad7f76ddb68bef5c56a08deeef15c31", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 7, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "106", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "ae609c23ce293d1e7c19723fbfe113c0644d63437e360e323bad136091f7d1e1", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 25, + "index": 142, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "c3509c6487f8933fbec46bb166eba697f54ba5808453016a45978d21953c7e53", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "prepareResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 53, + "index": 332, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|prepareResume|(Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "c3a3a1cf672fe44f75d9cee697e72d78449b353258d8cba49600c62021beaa31", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 35, + "index": 206, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "c7774df8ccdcefaaac0c151f68b24313cc511a6a0800951cd375dfe217d265b6", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateStep", + "methodDescription": "(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation", + "line": null, + "block": 16, + "index": 101, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "4", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateStep|(Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to dev/tramai/core/coroutines/CancellationKt::rethrowIfCancellation", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "c98ac92dc495b7162789fc13c1d2318f1f5d2aa07bb3c051df7946fe638d3cff", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 22, + "index": 132, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "ce65c0b75a83a7cbaf0ff52ed87aee8fe45d571587e78da263663124ea64326d", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "suspendToolExecution", + "methodDescription": "(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 157, + "index": 1067, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|suspendToolExecution|(Ldev/tramai/engine/approval/SuspendToolExecutionRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "d38ac52f2843448a4df4cba789ae27ddf9df3f73356c9c8ef972f2f03cc180c3", + "className": "dev.tramai.engine.approval.ApprovalRunAttributionKt", + "method": "decodeApprovalAttribution", + "methodDescription": "(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 24, + "index": 131, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "5", + "sourceFile": "ApprovalRunAttribution.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalRunAttributionKt|decodeApprovalAttribution|(Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "d5b70d305cec04c7aca39a5a9c629221c88509f4a8ee07d9643b2f8e37d1277f", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 7, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "6", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "d68d0252ca2d0c4e59ad7655508624e507633be914605effb2324b63022ba289", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 10, + "index": 61, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "e0ae69141eabff4855f7acc9e498139795c71ae3aca5a096d468101f3ceca1f0", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 2, + "index": 12, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "5", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator$resume$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "e22c7dcc1e36b6956165d91403a12a340160639592be69fe070caa3736bd8851", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 4, + "index": 29, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "e44e3a71b7ef3e7a1ac87d6f7c96dfb9d33350285ed9dc9512e3d99a88276a42", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 46, + "index": 328, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "e52f1c1f657020d5f63b247fbc7fd432c886258cf5059a5e2a9a5fdc4981bc32", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo", + "line": null, + "block": 10, + "index": 72, + "freshRawStatus": "NO_COVERAGE", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::requestApproval-Atj0Sqo", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "e602fa9990536c07017cc7764ed989526f964e395f82e05d456f7dca523c4610", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistGoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 31, + "index": 181, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistGoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "eb21aaed31923633c541c603ebf29c170343b1e822874768f732093a099b3a18", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "persistSuspendedInvocation", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation", + "line": null, + "block": 9, + "index": 38, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "4", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|persistSuspendedInvocation|(Ldev/tramai/engine/approval/ApprovalSuspensionCoordinator$GovernedSuspension;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator::persistSuspendedInvocation", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "eb8827c4f4ca1d6b1f90686e5335ffb189d78b4694548a6843f7ebc5d1d95a79", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requireExistingAttributionMatches", + "methodDescription": "(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 7, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "11", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requireExistingAttributionMatches|(Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "ebc96b3e8599f671eb60c470b2a259a474aa994c2599547731b9944800e60b57", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 2, + "index": 12, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "14", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "eec481d97a434f85884bdc18877d8a54801c8ba289658e0fb49d92e286cad2f7", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 2, + "index": 12, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "12", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$3|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "f175e8698db5459d85e11e56b04155c7b963d148d906f57908c6440dd533fead", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend", + "line": null, + "block": 8, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "10", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$2|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$2::invokeSuspend", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (discarded lambda result)" + } + ], + "unresolved": false + }, + { + "identity": "f23b4d0921a0e5daca1b271f41357982ca9e52de09f0e1e5d72933b1b87f3ac6", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "executeClaimedResume", + "methodDescription": "(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 39, + "index": 323, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|executeClaimedResume|(Ldev/tramai/engine/approval/ResumeExecutionContext;Ldev/tramai/core/approval/ClaimedApprovalContinuation;Ldev/tramai/core/approval/ApprovalContinuationStore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "f4463604928886b61038161dfacada30be5fa9dcd62178ac329b83c4568e3ffd", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "persistUngoverned", + "methodDescription": "(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned", + "line": null, + "block": 29, + "index": 161, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "7", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|persistUngoverned|(Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/DefaultApprovalGateway::persistUngoverned", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "f571f62614e6f584322a29fa5ef9b4f9bb0e6bf6e5d48f72181d7778088e3a53", + "className": "dev.tramai.engine.approval.ApprovalResumeCoordinator", + "method": "authorizeResume", + "methodDescription": "(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 11, + "index": 67, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "ApprovalResumeCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalResumeCoordinator|authorizeResume|(Ldev/tramai/engine/ResumeApprovalCommand;Ldev/tramai/engine/approval/ApprovalResumeCoordinator$ResumePreparation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "f5e8c455520a32d816751b0ec4adb9d4619a38afdf67b81256a615a68b779590", + "className": "dev.tramai.engine.approval.DefaultApprovalGateway", + "method": "requestApproval-Atj0Sqo", + "methodDescription": "(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator", + "description": "negated conditional", + "line": null, + "block": 9, + "index": 68, + "freshRawStatus": "TIMED_OUT", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "0", + "sourceFile": "DefaultApprovalGateway.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.DefaultApprovalGateway|requestApproval-Atj0Sqo|(Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator|negated conditional", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [], + "unresolved": true + }, + { + "identity": "f60db12bdb9ea3f1b61d5c01867a18b9ebe7f2a8726a8287f8129dad183a4625", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator", + "method": "compensateSuspension", + "methodDescription": "(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator", + "description": "removed call to kotlin/ResultKt::throwOnFailure", + "line": null, + "block": 7, + "index": 44, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "16", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator|compensateSuspension|(Ljava/lang/String;JLdev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/core/approval/ApprovalGateCoordinator;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator|removed call to kotlin/ResultKt::throwOnFailure", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (case-0 entry check)" + } + ], + "unresolved": false + }, + { + "identity": "f802165800d8737a082118c89e9c5eae4e7d8470cd15c54c57bc19476be9a15c", + "className": "dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1", + "method": "invokeSuspend", + "methodDescription": "(Ljava/lang/Object;)Ljava/lang/Object;", + "mutator": "org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator", + "description": "replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend", + "line": null, + "block": 9, + "index": 43, + "freshRawStatus": "SURVIVED", + "canonicalOutcome": "NON_KILLED", + "family": "approval", + "module": ":tramai-engine", + "numberOfTestsRun": "12", + "sourceFile": "ApprovalSuspensionCoordinator.kt", + "sourceLevelKey": ":tramai-engine|dev.tramai.engine.approval.ApprovalSuspensionCoordinator$compensateSuspension$2$1|invokeSuspend|(Ljava/lang/Object;)Ljava/lang/Object;|org.pitest.mutationtest.engine.gregor.mutators.returns.NullReturnValsMutator|replaced return value with null for dev/tramai/engine/approval/ApprovalSuspensionCoordinator$compensateSuspension$2$1::invokeSuspend", + "g1AObservation": "present in the g1A fresh-only inventory", + "g1BKeyCategory": "key-level A/B/C; not derived per identity in this record (context only, never disposition authority)", + "priorAdjudication": [ + { + "source": "TASK-0.7.1g1G4-RESIDUAL-25-MANIFEST.json", + "disposition": "EQUIVALENT (discarded lambda result)" + } + ], + "unresolved": false + } + ], + "artifactHashes": { + "mutation/approval/tramai-engine/mutations.xml": "575130f99444016e087e9e7e5600dbd87ae6afc079e37ddf9641a181a840356c", + "mutation/approval/tramai-security/mutations.xml": "1fcd782c5b3adb5701a9a82d60b8c40c98af64d4c7dad888e0af04a61d32c797", + "mutation/evidence/tramai-engine/mutations.xml": "c296ad498b269b1ccb920937ecf01b150db971f0bd0f581bf5802b8aaa10c748", + "mutation/evidence/tramai-security/mutations.xml": "e32f7293a700d1cc4da8aa2fd0f5096c0a31196ebc8d4d3ba47a9c82a02c2b2f", + "mutation/evidence/tramai-sovereign/mutations.xml": "0ca81a34128af03fe66564fbc7c1a49046469f2a272de216371caed5aa73c39e", + "mutation/policy/tramai-engine/mutations.xml": "2dddc2003c200ca91342ce1cf56f7c4d5e1bc72544bc7641e36178356fb52393", + "mutation/policy/tramai-security/mutations.xml": "ebd2f9e3757a47bf6cdfcb3cb35e3c2198c537f8e4d9b62c611d242786813d3c", + "mutation/policy/tramai-sovereign/mutations.xml": "e6b926523e3078d656677d47da3fd73498592366b426206c27bb2f2b5123c520", + "mutation/retry/tramai-core/mutations.xml": "45d012f7c02261a51458991b104786c2afc3f36b086b3d5dfed84ef70bf205fe", + "mutation/retry/tramai-engine/mutations.xml": "1cea82c512ac1db86fbdfc4945a5fdd3b66e2261f7724687936978c730da4049", + "mutation/routing/tramai-core/mutations.xml": "9679003517ac45ab960e39a92de3f437e634a49fc34084fe8ce33929e064d6bd", + "mutation/routing/tramai-engine/mutations.xml": "fbecfe240ac4b449d3106e506d7c8361ddfa901d090552f4c62fe3be5eace74e", + "mutation/structuredOutput/tramai-structured/mutations.xml": "c80954b3b4c44d330d7ed16cf95cd66ed6529e9398e0d2bc697c3dbf476fe81e", + "mutation/tools/tramai-engine/mutations.xml": "f27b813c883343ec76cdcdb4a5a115089d33ca07199d524657dccecc853245d0", + "mutation-baseline.GENERATED.json": "c49b7fcfe6411ee5db04dc0f67b5178a82062abf407ad25af4c559a2589e65d5", + "mutation.init.gradle": "6f3b493ac04aabf4a00f4def0a606a7ba4c0e2f30d36de43473866f4f50d792e" + }, + "artifactLocation": "/tmp/xk9p1-fresh/ (preserved outside git; raw PIT output is not a repository convention)", + "reproduction": "Parse each //mutations.xml (module=':'+dir, family=parent dir); identity=sha256 of 0x1f-joined module,mutatedClass,mutatedMethod,methodDescription,mutator,description,block,index (XML-unescaped, ints); outcome=KILLED if status==KILLED else NON_KILLED; digest=sha256 of sorted 'identity|status|outcome|family|module' lines then 'topology=' and 'analyzer=' lines, each newline-terminated. Independently reproduced: 2544/2544 identities, 0 row mismatches, 0 duplicates, identical digest.", + "independentAggregation": { + "identities": 2544, + "rowMismatches": 0, + "duplicates": 0, + "digestMatchesCanonicalGenerator": true, + "parserCorrection": "the first independent pass did not XML-unescape (<init>), which shifted identities; after unescaping the aggregation matched exactly" + } +} diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1P1-STOP-EVIDENCE-RECORD.md b/docs/roadmap/0.7.0/TASK-0.7.1g1P1-STOP-EVIDENCE-RECORD.md new file mode 100644 index 000000000..ed00aba76 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1P1-STOP-EVIDENCE-RECORD.md @@ -0,0 +1,204 @@ +# TASK-0.7.1g1P1 — STOP: population-admission scope gap + +**Status: STOPPED before minting. No population-admission authorization was created. P1 and P2 remain blocked.** + +This record preserves the completed fresh canonical measurement that stopped the first P1 attempt, and the +exact finding that stopped it. It is evidence only: it is **not** mutation authority, **not** admission +authority and **not** P1 mint input. + +## 1. Start gate (all conditions held) + +| condition | result | +|---|---| +| Epic tip | `18f10dbb2c90fbfeafc374e1d7fe0d8814e342b1` — exact | +| `#459` merged with that SHA | yes (`MERGED`, mergeCommit `18f10dbb…`) | +| working tree | clean | +| `config/quality/mutation-population-admissions.yml` | `schemaVersion: "1"`, `admissions: []` | +| committed population unchanged since the admission design froze | yes — `git diff 80d6847f..origin/epic -- config/quality/` is empty | + +Committed population at the gate: 2384 rows, canonical projection digest +`9e2febc775dd1c2c33d8cabc805e54690b2eaacc6d9ce82bea2758e1c7866230`, `measuredCommit 5856530e`. + +## 2. Campaign provenance (Phase 1 — complete) + +One **uninterrupted** full canonical campaign, no narrowing, in an isolated worktree +(`/tmp/tramai-g1p1-measure`, detached at the exact base; the real repository's baseline was never touched): + +``` +./gradlew generateCriticalMutationBaseline --no-configuration-cache --console=plain +JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 +BUILD SUCCESSFUL in 49m 20s +7 families / 14 family-module reports (canonicalMutationProbe invoked once per family) +measuredCommit reported by the generator: 18f10dbb2c90fbfeafc374e1d7fe0d8814e342b1 +``` + +Measured population: **2544 rows** — KILLED 1835, SURVIVED 449, NO_COVERAGE 190, TIMED_OUT 70. +Analyzer semantics: PIT plugin `1.19.0`, engine `1.22.1`, `timeoutConst 4000`, `timeoutFactor 1.25`, and the +11 mutators in declaration order: `CONDITIONALS_BOUNDARY, INCREMENTS, INVERT_NEGS, MATH, NEGATE_CONDITIONALS, +TRUE_RETURNS, FALSE_RETURNS, PRIMITIVE_RETURNS, EMPTY_RETURNS, NULL_RETURNS, VOID_METHOD_CALLS`. + +**Population digest** + +``` +7081ed7416f759ecca7cae00c7cf4e6261ef0cd16db49fe32bc3bb6f280bbddd +``` + +> **PRE-ADJUDICATION DISCOVERY MEASUREMENT — NOT YET P1 MINT AUTHORITY.** + +This digest is the digest of the discovery measurement. It must not be reused automatically: if any test or +production change moves a mutant's status, P1 requires a new full canonical campaign and a new digest. + +## 3. Fresh vs committed population (Phase 2 — complete) + +| set | count | +|---|---| +| committed (authority) | 2384 | +| fresh (measurement) | 2544 | +| shared | 2195 | +| base-only | 189 | +| candidate-only | 349 | +| candidate-only KILLED | 281 | +| **candidate-only NON_KILLED** | **68** | +| shared rows whose status changed | 52 | + +Integrity: fresh = shared + candidate-only; committed = shared + base-only; identity loss explained exactly; +0 duplicates on either side; 0 unexplained rows. + +## 4. The 68 candidate-only NON_KILLED cohort + +All 68 are `approval` / `:tramai-engine`: + +``` +TIMED_OUT 36 +SURVIVED 28 +NO_COVERAGE 4 +--------------- +total 68 +``` + +Exact inventory with every required field (class, method, methodDescription, mutator, description, line, block, +index, fresh status, canonical outcome, family, module, numberOfTestsRun, source-level key, g1A/g1B context, +prior adjudication and source): + +`docs/roadmap/0.7.0/TASK-0.7.1g1P1-CANDIDATE-ONLY-68-MANIFEST.json` + +Digest of the sorted 52 unresolved canonical identities (Phase 0 custody input for the successor task): + +``` +92a9d06d5a8e43c5fb659f9d65bdd2e73d6ed5f45c1a8536ac70781b03503584 +``` + +## 5. Phase 3 result — the STOP + +``` +fresh candidate-only NON_KILLED 68 +admission-ready (prior adjudication) 16 +unresolved 52 +``` + +The two N values differ, and a P1 admission basis must already exist before the task: P1 is an authorization +ceremony, not an adjudication task. **No partial ledger was minted.** + +### 5.1 The 16 with firm prior adjudication (final dispositions) + +All sixteen are `SURVIVED` in the fresh measurement and all sixteen have a final disposition of **EQUIVALENT**. +Three of them also appear in the residual-25 manifest as `UNDETERMINED`, which is their *intermediate* +pre-adjudication state; the residual-11 manifest records their **final** disposition. + +| identity | final disposition | source | +|---|---|---| +| `098258d70a23…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `492f9a132e14…` | EQUIVALENT (forwarding to `resume` case-5 check) | residual-11 manifest | +| `60f7b020e009…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `62911071da6c…` | EQUIVALENT (emit's only escape is cancellation; removal measured unobservable) | residual-11 manifest | +| `63e5372ba37b…` | EQUIVALENT (forwarded to `resume` case-2 check before the cast) | residual-11 manifest | +| `6cef3fd2e23d…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `70d345f72064…` | EQUIVALENT (discarded lambda result) | residual-25 manifest | +| `ab78ed8577ff…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `d5b70d305cec…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `e0ae69141eab…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `eb8827c4f4ca…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `ebc96b3e8599…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `eec481d97a43…` | EQUIVALENT (case-0 entry check; the "KILLED at base" anecdote was itself corrected in g1G4e) | residual-25 manifest | +| `f175e8698db5…` | EQUIVALENT (discarded lambda result) | residual-25 manifest | +| `f60db12bdb9e…` | EQUIVALENT (case-0 entry check) | residual-25 manifest | +| `f802165800d8…` | EQUIVALENT (discarded lambda result) | residual-25 manifest | + +**Explicit check required before any later P1:** a fresh NON_KILLED row may never be authorized on the authority +"this mutant is KILLED". Verified here: **no** identity in the 16 carries a final disposition of `KILLED`. +The one historical anomalous KILL in this lineage (`eec481d97a43`) is a raw-control correction whose final +adjudicated disposition is EQUIVALENT. No disposition/measurement drift was found in the 16. + +### 5.2 The 52 unresolved + +``` +TIMED_OUT 36 +SURVIVED 12 +NO_COVERAGE 4 +--------------- +total 52 +``` + +Every one of the 52 is referenced in-repo, but **only as observation or debt lineage**: + +- `TASK-0.7.1g1A` records each of them in its *complete fresh-only identity inventory* as an observation + (`STATUS / NON_KILLED`), not as a disposition; +- `TASK-0.7.1g1B` records their source-level key and the A/B/C debt category. A/B/C is a **lineage/debt + classification**, not a semantic disposition: A means predecessor rows at a source-level key were uniformly + non-killed, B means mixed predecessor outcomes, C means no predecessor at that key. None of the three proves + EQUIVALENT, TOOLING_LIMITATION or UNREACHABLE; +- **zero** of the 52 appear anywhere under `config/quality/**` (no classification, no enrollment, no admission); +- **zero** appear in `TASK-0.7.1g1G2`, which classifies mechanisms at group level, not identities. + +40 of the 52 report `numberOfTestsRun = 0` in the preserved PIT reports (relevant context for the TIMED_OUT +mechanism work, not a disposition). + +**These 52 are new relative to the committed mutation authority (`5856530e`), but they were not newly +discovered by this track.** `g1A` already recorded the larger fresh-only population. What this campaign +discovered is that 52 of those historical fresh-only identities escaped semantic adjudication when `g1G4` +scoped itself to the 118 cohort. That is a **scope gap**, not fresh code drift. The approval family grew from +768 to 918 mutants since the committed measurement, which is why the discovery measurement sees them at the +current base. + +## 6. Reproduction and independent validation + +The canonical aggregation was reproduced independently from the 14 preserved PIT reports: + +- first attempt disagreed — cause was in the independent parser, not the repository: it did not XML-unescape + (`<init>` and similar), which shifted identities; +- after unescaping, the second aggregation reproduced the canonical generator exactly: + **2544/2544 identities, identical identity set, 0 row mismatches, 0 duplicates, same digest + `7081ed74…`**. + +Recipe: read every `build/reports/maintainability/mutation///mutations.xml`; module = `:` + +report directory, family = parent directory; identity = SHA-256 of `\x1f`-joined +`module, mutatedClass, mutatedMethod, methodDescription, mutator, description, block, index` (XML-unescaped, +block/index as integers); outcome = `KILLED` if status is `KILLED`, otherwise `NON_KILLED`; digest = SHA-256 of +the sorted `identity|status|outcome|family|module` lines, then a `topology=` line (sorted family → sorted modules +joined by `+`, entries joined by `,`) and an `analyzer=` line (`pluginVersion|engineVersion|sorted mutators +joined by +|timeoutConst|timeoutFactor`), each terminated by a newline. + +## 7. Preserved artifacts + +Held outside git (raw PIT output is large and not a repository convention) at `/tmp/xk9p1-fresh/`; SHA-256 of +each is recorded in the manifest JSON under `artifactHashes` (14 `mutations.xml` reports, the generated +baseline, the generated init script). + +## 8. State after this record + +- no admission minted; admission ledger still `admissions: []`; +- `mutation-baseline.json`, `mutation-evolution.yml` (0 records), `mutation-classifications.yml`, the + classification-enrollment ledger, mutators, timeout policy, target families, deviation ceilings and gate + semantics are all unchanged; +- no production file and no test changed by the P1 attempt; +- P2 not executed; M06 unchanged; no M21 record added; no authorization consumed. + +## 9. Consequence for the track + +The ceremony did exactly what it was designed to do: it exposed the gap **before** authority was minted. A P2 +population transition would fail M06 for the 52 until something adjudicates them. + +Next: an adjudication increment over exactly this frozen 52-identity cohort (A/B/C debt categories are +explicitly **not** admissible as disposition authority), starting from the Epic tip created by merging this +record. Only after all 52 are firmly disposed does the track rerun a full canonical campaign, derive the new +candidate-only NON_KILLED set and restart `P1 MINT → P2 CONSUME` against the new digest. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1g1P2-AUTHORITY-MODEL-RECONCILIATION.md b/docs/roadmap/0.7.0/TASK-0.7.1g1P2-AUTHORITY-MODEL-RECONCILIATION.md new file mode 100644 index 000000000..037e73746 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1g1P2-AUTHORITY-MODEL-RECONCILIATION.md @@ -0,0 +1,537 @@ +# TASK-0.7.1g1P2 — Authority Model Reconciliation (design record) + +**Status:** design only. No rule, code, authority or configuration change is made by this record. +**Endpoint:** AUTHORITY MODEL RECONCILED — IMPLEMENTATION SLICE SPECIFIED (implementation deferred). +**Base:** `689e8b65472d41f240add5bfb4a3cc4bb2b2baf7`. +**Scope guard:** P2 is not performed. No baseline, admissions, classifications, evolution or analyzer change. + +--- + +## 1. Empirical evidence — four complete unrestricted campaigns + +All four campaigns ran the committed 7-family configuration from the same effective PIT inputs +(identical analyzer semantics, mutators, timeouts, targets, tests, and config hashes). Raw PIT +evidence was preserved, SHA-256 manifest-verified, and independently re-aggregated from the XML — +not read from the generated baseline. + +| campaign | digest (current semantics) | KILLED | SURVIVED | NO_COVERAGE | TIMED_OUT | duration | +| --- | --- | --- | --- | --- | --- | --- | +| G6 | `9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0` | 1836 | 448 | 190 | 70 | 49m03s (session notes; log no longer on disk) | +| failed P2 | `31fc3b0e7c1ed28e6a1e284d36705c713c41cf4efafc2b5002d8544d01cd5f66` | 1836 | 445 | 190 | 73 | 49m27s | +| Run A | `f83b0feb0e1bd00eec7f744b54d38331f49f53f3e1cd099dc5c93dc06f92ef75` | 1836 | 447 | 190 | 71 | 46m04s | +| Run B | `8e00fd7e02ebf078f24aad5a5c2e75fb3a31f0b216571806ba28c3955686e620` | 1836 | 446 | 190 | 72 | 49m21s | + +**Stable in all four campaigns:** 2544 identities with an identical identity set in every pairwise +comparison; all seven family/module topology counts exact (918/808/382/209/155/57/15); 1836 KILLED; +190 NO_COVERAGE; canonical outcome totals 1836 KILLED / 708 NON_KILLED; shared 2195; base-only 189; +candidate-only 349; candidate-only KILLED 282; candidate-only NON_KILLED 67. + +**Unstable:** only the SURVIVED ↔ TIMED_OUT split, and only ever three identities (SURVIVED + +TIMED_OUT = 518 in every campaign), giving four distinct digests. + +| identity | G6 | failed P2 | Run A | Run B | +| --- | --- | --- | --- | --- | +| `b6c0300e1d1662d29d85df7ac1d6302f34c10418d318f79917d66d0637cd9014` | SURVIVED (437) | TIMED_OUT (0) | TIMED_OUT (0) | TIMED_OUT (0) | +| `eb1ef9ebbfaf9a9b91b2f072d593594491322991e88d145f60502ed5d438db2b` | SURVIVED (162) | TIMED_OUT (0) | SURVIVED (162) | SURVIVED (162) | +| `f711226127cd2c308288c897c928258f226de664dc4f55c003198a18ca42f4a8` | SURVIVED (372) | TIMED_OUT (0) | SURVIVED (372) | TIMED_OUT (0) | + +(`numberOfTestsRun` in parentheses.) All three are `PolicyEnforcementHelper` on +`:tramai-engine` (two NegateConditionals at line 59 in `logMigrationWarningOnce`, one void-call +removal of `kotlin/ResultKt::throwOnFailure` at line 50 in `enforce`). Every unstable row has +TIMED_OUT on at least one side; SURVIVED always carries the same test count that identity shows in +G6; TIMED_OUT always carries `detected=true` and `numberOfTestsRun=0`; `killingTest` is null +throughout. Confinement to the timeout boundary is an observation, not proof of cause. + +### 1.1 The two projections, computed over all four preserved measurements + +Raw measurement projection (current semantics): +`identity|status|outcome|family|module`, sorted, plus `topology=` and +`analyzer=`, SHA-256. + +This reproduces all four distinct digests above exactly, confirming the recipe. + +Proposed canonical authority projection: +`identity|outcome|family|module`, sorted, plus the same topology and analyzer lines, SHA-256. + +| campaign | authority projection digest | +| --- | --- | +| G6 | `e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad` | +| failed P2 | `e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad` | +| Run A | `e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad` | +| Run B | `e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad` | + +**The critical discriminator passes: all four authority digests are identical.** No averaging or +majority vote was used; each digest is an independent single-campaign computation. + +Evidence envelopes (raw 14 reports + generated baseline + independent projection + metadata + +verified SHA-256 manifest; `sha256sum -c` fully clean): + +| envelope | files | manifest fingerprint (sha256 of manifest.sha256) | +| --- | --- | --- | +| g6 | 134 | `5cb0573511ac4bf02df386ce…` | +| failedp2 | 135 | `78aa41dc376214b2b041c570…` | +| a | 135 | `35988fb702df2581ea0fbb38…` | +| b | 135 | `c73df331e4435e58fa628ed4…` | + +--- + +## 2. The existing contract, from repository code + +### 2.1 Record shape + +`BaselineModel.kt` `MutationOutcome` carries both `status` (raw PIT: +`SURVIVED|NO_COVERAGE|TIMED_OUT|KILLED|…`) and `outcome` (canonical ratchet state: +`KILLED|NON_KILLED`), defaulting to `SURVIVED` / `NON_KILLED` respectively. + +### 2.2 C7 — raw status is diagnostic, never authority + +`BaselineModel.kt:348-354`, on `MutationOutcome` (C7): + +> `outcome` is the canonical ratchet state: KILLED | NON_KILLED. Raw PIT `status` +> (SURVIVED/NO_COVERAGE/TIMED_OUT/...) is preserved as diagnostic evidence but is NOT authority — +> TIMED_OUT↔SURVIVED scheduler races must not churn the 10.3c2 ratchet (C7). + +`MutationRatchetVerifier.kt:15-17`, restating it for the verifier: + +> canonical outcomes are exactly KILLED | NON_KILLED; raw PIT statuses are diagnostic evidence and +> never participate in the ratchet (C7), but every persisted row MUST be self-consistent: its +> identity must equal the SHA-256 recomputed over its own fields, and its stored outcome must equal +> `MutationOutcome.canonical` of its raw status. + +### 2.3 M01 / M06 / M07 / M21 + +From the discriminator matrix in `MutationRatchetVerifier.kt`: + +- **M01** base KILLED → candidate NON_KILLED = regression (fail). +- **M06** new NON_KILLED identity = new survivor (fail unless exactly base-authorized). +- **M07** new KILLED identity = pass, only if the row is self-consistent. +- **M21** base identity absent from the candidate population = fail by default; a recorded evolution + invocation may downgrade individually recorded removals to warnings only when the candidate has a + fresh measured population. +- **M13** unknown/non-canonical outcome or raw status, or a stored outcome contradicting + `canonical(raw status)` = fail closed. This is the existing guard against a tool-failure status + becoming authority. +- **M12 / M14 / M16-M19 / M20** duplicates; family narrowing; analyzer/mutator/timeout drift; + identity-schema drift; malformed or self-inconsistent authority. + +### 2.4 M30-M39 — the population-admission ceremony + +`MutationPopulationAdmissionCeremony.kt`, the rule set that governs P1/P2: + +- **M30** appearing NON_KILLED with an exact base authorization → accepted, authorization consumed + in the same transition. +- **M31** authorization created by the same transition → fail. +- **M32** authorized identity, different **row** → fail. (`admitsRow` binds exact + identity + status + outcome + family + module.) +- **M33** authorized row, different **analyzer semantics** → fail. +- **M34** authorized row, **different population digest** → fail: + *"An authorization binds the complete measured population."* +- **M35** new authorization bound to another base SHA → fail at mint. +- **M36** retained authorization **rewritten** → fail. +- **M37** authorization removed without a valid consumption → fail. +- **M38** authorization retained after being consumed → fail (single use). +- **M39** obsolete authorization → warning, never forced into an admission. + +The ceremony's own trust statement is explicit that the digest binds the *neighbours*: + +> The digest binds the *whole* transition, not just the identity's row: … if any other identity in +> the population changes between minting and consumption, the digest no longer matches and the +> authorization must be re-minted against the new measurement. A candidate can never construct that +> measurement, because the hash compared is never read from the ledger. + +`MutationPopulationEvolutionProof.projectionHash` (M34's input) is the **raw** projection, i.e. the +same recipe as `1.1` above. + +### 2.5 The contradiction, reconciled + +The three statements are each individually coherent and were each written for a different purpose: + +- **(A) C7** governs the *ratchet*: whether a passing/failing transition is caused by raw status + churn. It declares raw status non-authority for that purpose, and explicitly anticipates + TIMED_OUT↔SURVIVED scheduler races. +- **(B) M32** governs *one authorized identity*. It binds the exact persisted row, status included. + That is deliberate and correct: the adjudications are status-specific. G5b TOOLING_LIMITATION + asserts that PIT *times out* on a suspension sentinel; G5d UNREACHABLE asserts the mutated + instruction *cannot execute* (a SURVIVED reading would mean it executed and falsify the proof); + G5c EQUIVALENT asserts a structural equivalence proven against a *surviving* observation. Removing + status from M32 would let a `NO_COVERAGE → SURVIVED` transition silently invalidate an UNREACHABLE + adjudication. +- **(C) M34** governs *whole-population context*. It intends to bind the adjudication's neighbours so + an authorization cannot be replayed onto a materially different population. + +**The defect is not a deliberate override of C7.** M34 was built on the measurement-proof projection +(`projectionHash`), whose raw-status content is *correct for its own purpose* — trust question A +below requires it to stay raw-exact. Using that same hash as the authority digest imported raw +status into the authority path by construction, without a decision that raw status should be +authority. So C7 and M34 both hold; the reconciliation is that **one hash was reused for two +different trust questions**, and only one of them may contain a diagnostic. + +The empirical consequence is severe because M34's binding is *whole-population*: 3 identities of +2544 (0.118%) oscillating between two raw statuses — a variation C7 explicitly declares +non-authoritative — make all 67 P1 authorizations unconsumable, while every authorized row, every +canonical outcome, every family/module, the analyzer semantics, and the identity set remain +byte-identical. + +--- + +## 3. Three independent trust questions + +### A. Fresh measurement ↔ committed candidate baseline — keep raw-exact + +`MutationPopulationEvolutionProof.exactComparison()` must continue to prove the candidate baseline +equals the verifier's actual fresh measurement including raw status. **No relaxation.** A fresh +measurement that differs from the committed candidate in any persisted field must fail. Raw status +belongs here. + +### B. Individual P1 admission row — keep exact, status included + +Measured over the four campaigns: the 67 authorized rows are **exactly stable in every one** — 0 +status mismatches, 0 exact-row mismatches, 0 missing, including raw statuses (36 TIMED_OUT / 27 +SURVIVED / 4 NO_COVERAGE). Exact-row binding therefore costs nothing in practice and protects real +adjudication content. Assessed transitions: + +- **TIMED_OUT → SURVIVED** — invalidates G5b TOOLING_LIMITATION (which asserts a timeout mechanism) + and G5d UNREACHABLE (a surviving mutant executed). **Must keep failing.** +- **SURVIVED → TIMED_OUT** — weakens G5c EQUIVALENT's observation and turns a "ran and was not + detected" claim into "did not complete". **Must keep failing.** +- **NO_COVERAGE → SURVIVED** — directly falsifies an UNREACHABLE proof. **Must keep failing.** +- **SURVIVED → NO_COVERAGE** — contradicts the recorded observation and hides a genuinely + undetected survivor behind a coverage claim. **Must keep failing.** + +**Conclusion: retain exact-status binding for individual authorizations (M32 unchanged).** The +principle is that an adjudication's rationale is admitted only for the observation it was made +against; that is precisely what C7 permits, because it constrains the *ratchet*, not the +authorization payload. + +### C. P1 whole-population context — the actual defect + +Should unrelated neighbouring raw-status changes invalidate P1 authority when identity, canonical +outcome, family/module, analyzer semantics and the authorized rows are all unchanged? Measured +answer: the neighbours in question are 3 non-authorized identities (0.118% of the population), each +NON_KILLED in all four campaigns with family/module preserved, oscillating only between two raw +statuses that C7 declares non-authoritative. **No — not on raw status.** The context binding should +reject only changes that are authority-relevant. + +--- + +## 4. Threat model of the proposed split + +The correction must keep rejecting, with the rejecting rule named. "Existing" = no change required; +"M32 + artifact" = M32 retains raw-status binding for the authorized row, while the whole-population +digest uses the authority projection. + +| # | attack | rejecting rule | +| --- | --- | --- | +| 1 | previously KILLED → NON_KILLED regression | **M01** (existing) | +| 2 | unauthorized appearing NON_KILLED identity | **M06** (existing); malformed/self-inconsistent rows via **M20** | +| 3 | identity substitution | identity recomputation over the row's own fields + **M12** duplicates + **M19** schema drift (existing) | +| 4 | family/module re-homing | **M32** exact-row (`admitsRow` binds family + module) + **M14** narrowing (existing) | +| 5 | analyzer/mutator/timeout semantic drift | **M33** + **M16/M17/M18**, and the analyzer line stays **inside** the authority digest (existing + proposed) | +| 6 | disappearance without M21 custody | **M21** (existing) | +| 7 | candidate baseline differing from the verifier's exact fresh measurement | `exactComparison()`, still raw-exact — trust question A (existing, unchanged) | +| 8 | candidate-created same-transition admission | **M31** (+ **M23** for classifications) (existing) | +| 9 | retained consumed authority | **M38** (existing) | +| 10 | re-use of consumed authority | **M37** + **M38** single-use (existing) | +| 11 | authorized identity changing its adjudication-significant row | **M32**, retained status-inclusive — trust question B (existing, deliberately unchanged) | +| 12 | unknown/tool-failure PIT status becoming NON_KILLED authority | **M13** fail-closed on non-canonical status/outcome + `MutationOutcome.canonical` mapping, unchanged (existing) | + +No attack in the list becomes admissible under the split. Attacks 1-12 above keep their current +rejections; the only relaxation is that a *non-authorized neighbour's* raw status no longer +invalidates whole-population context, while its canonical outcome, identity, family/module and +analyzer semantics still do. The migration-certificate design adds its own discriminators, T13-T19 +in §7, covering its full lifecycle. + +--- + +## 5. Is M34 redundant or merely over-specified? + +### Option A — canonical authority digest (retain M34, correct its projection) + +M34 keeps its function; its digest binds `identity|outcome|family|module` + topology + analyzer. +Raw status stays inside the measurement proof and inside individual admission rows. + +- **Property preserved:** the authorization is still bound to the population context it was + adjudicated against, so an authorization cannot be replayed onto a materially different + population; the hash is still computed by the verifier from its own fresh measurement and never + read from the candidate (anti-self-authorization intact). +- **Defect removed:** diagnostic raw status no longer carries authority, matching C7. + +### Option B — remove whole-population digest binding + +Rely on exact individual base authorization, M01, M06/M07, M14+, M21's exact fresh/candidate proof, +analyzer semantics, and the single-use rules. + +- Individually, each authorization is still fully checked (M32/M33) and the population is still + governed (M01/M06/M14-M19/M21). +- **What is lost:** nothing rejects a *stale-context* authorization — one minted against one + population, then consumed in a transition whose population composition changed in ways that are + legal for the *other* identities but which no longer resemble the context the adjudication was + made in. M34 is the only rule that binds the adjudicated context as a whole. + +**Recommendation: Option A.** It retains a demonstrable property (context binding) that no other +rule provides, and the correction is confined to which projection the digest hashes — the smallest +model that preserves the property. Option B discards a real property to remove a defect that Option +A removes anyway. Note the property is only worth keeping because the authority projection is +empirically stable across four independent campaigns. + +--- + +## 6. Migration of the existing 67 v1 authorizations + +### 6.1 Correction — the earlier revision of this section was wrong + +An earlier revision of this section proposed migrating the digest semantics *in place* on the 67 +records while claiming that M36 remained unchanged. That claim was false, and the repository's own +code proves it: + +- `MutationPopulationAdmissions.kt:75-88` — `enforcedPayload()` = identity, status, outcome, family, + module, analyzer, `fromBaseSha`, **`populationDigest`**, reason, issue, targetPhase. Its own doc + comment says base/candidate byte-identity is judged over exactly this list. +- `MutationPopulationAdmissionCeremony.kt:314-317` — `isRetainedRewrite()` is + `candidate.enforcedPayload() != base.enforcedPayload()`, and M36 fails on it. + +Changing `populationDigest` on a retained record is therefore a retained-authority rewrite that M36 +rejects by construction. The earlier proposal would have needed a "M36 except during migration" +branch — precisely the kind of exception this track refuses to add. The design record must not claim +an invariant it does not keep, so that proposal is withdrawn and replaced by the design below, which +keeps **M36 and M37 literally unchanged**. + +The empirical conclusion, the C7 reconciliation, the authority-v2 projection, Option A and +individual-row binding are unaffected by this correction. + +### 6.2 Corrected design — a bounded base-side digest-migration certificate + +**Not one byte of the 67 admissions changes.** The semantic upgrade is carried by a separate +base-authoritative artifact: a digest-migration certificate in its own ledger, so M36's payload +comparison never sees it. + +Certificate fields: + +- `fromAlgorithm: raw-v1`, `fromDigest: 9aebd3202288c82ff006f2db33c95cac0772746fa3c3061569167cd3f45df9b0` +- `toAlgorithm: authority-v2`, `toDigest: e6ad01dc1d2966894a6555304bc8ca9a04c8174e3c83ae88760fcfebf1464dad` +- `admissionSetDigest` — SHA-256 over the sorted exact identities the certificate covers (the 67) +- `fromBaseSha` — mint-time anti-replay binding, enforceable only at introduction, then immutable + provenance (same semantics as `MutationPopulationAdmission.fromBaseSha`) +- `reason` — the recorded provenance of the supersession + +Sequence: + +``` +P1 merged (67 v1 authorizations, byte-identical and untouched) + ↓ +P1M — mint the bounded digest-migration certificate + admissions untouched; no population transition; no consumption; + the transition changes only the certificate ledger + ↓ +certificate exists in BASE + ↓ +P2 — consume the original 67 admissions using: + exact admission row (M32), analyzer (M33), + and authority-v2 context certified by the base certificate + then remove the admissions and the certificate +``` + +Consumption-time check (M34, generalised): the verifier's own fresh **authority** projection must +equal either the admission's own `toAlgorithm`-semantics digest, or the `toDigest` of a +**base-side** certificate whose `fromAlgorithm`/`fromDigest` match the cited admission's +`populationDigest` and whose `admissionSetDigest` matches the exact set of authorizations present. + +Proposed rules, all fail-closed. The certificate's **enforced payload** — the field set over which +retained immutability is judged, mirroring `MutationPopulationAdmission.enforcedPayload()` — is: +`fromAlgorithm, fromDigest, toAlgorithm, toDigest, admissionSetDigest, fromBaseSha, reason`. Audit-only +metadata (who/when) is excluded, exactly as the admissions ledger excludes `authorizedBy`/`authorizedAt`. + +Target lifecycle, each step with its own rule: + +``` +mint against the exact base (M45) + → retain byte-identically (M46) + → consume only from base (M43 + M42 + M41 + M40) + → remove in the valid consuming transition (M44 + M47) +``` + +- **M40** consumption citing a certificate whose `toDigest` ≠ the verifier's fresh authority + projection → fail. +- **M41** certificate whose `fromAlgorithm`/`fromDigest` does not match the cited admission's + `populationDigest` → fail. +- **M42** certificate whose `admissionSetDigest` ≠ the exact set of base authorizations → fail + (this is what makes the certificate *bounded*: it cannot cover a different or later set). +- **M43** certificate introduced by the same transition that consumes it → fail (the M31 analogue + for this new authority: the consuming candidate cannot invent the semantic upgrade it consumes). +- **M44** certificate retained after the consumption it authorised completed → fail (single use, + the M38 analogue). +- **M45** **mint-time base binding:** a newly introduced certificate whose `fromBaseSha` ≠ the + authority base the transition is proposed against → fail (the M35 analogue). +- **M46** **retained immutability:** a certificate present in the base whose enforced payload differs + from the base copy in any field → fail (the M36 analogue). A certificate is immutable from the + moment it is introduced; correcting a certificate means minting a new one in a later transition, + never editing a retained one. +- **M47** **removal custody:** a base certificate that is absent from the candidate, unless it was + validly consumed by that same transition, → fail (the M37 analogue). A certificate may only + disappear by being consumed; it may not be cancelled silently. + +**These rules are explicit and must not be assumed from existing machinery.** The existing loaders +(`MutationPopulationAdmissionLoader`, `MutationClassificationEnrollmentLoader`) validate *shape* +only — required fields, canonical 64-hex identity, 40-hex `fromBaseSha`, duplicate ids, +`schemaVersion` — and cannot enforce mint-time base binding, retained immutability or removal +custody. For the admissions ledger those guarantees come from the ceremony rules themselves +(`MutationPopulationAdmissionCeremony.kt`: mint binding at `:190-213`, retention/removal/single-use +at `:218-281`, `isRetainedRewrite()` at `:314-317`). The certificate needs its own equivalent rules +for the same reason; a new ledger file whose lifecycle is left to a generic loader would have none +of these properties. + +### 6.3 Why the certificate design is the right one + +- M36 remains **literally** unchanged: no exception, no permitted-field list, no special branch. +- M37 remains **literally** unchanged: the 67 are consumed by the same transition as before; the + certificate is not an admission and its removal is not a consumption. +- The P1 records keep their exact historical bytes and provenance — the audit trail of what was + actually minted is preserved, not rewritten. +- The candidate performing P2 cannot invent the semantic upgrade it consumes: the certificate must + already exist in the base (the same temporal trust rule as every other authority in this track). +- Nothing is grandfathered: the certificate names one exact `fromDigest`, one exact `toDigest`, and + one exact admission-set digest. +- If the 67 must ever change their own bytes, that is a different problem requiring its own + ceremony, and this record does not authorise it. + +--- + +## 7. Adversarial discriminator matrix required before implementation + +Each attack needs a pure-verifier test *and* a real-task authority-transport test (per +`AGENTS.md`: a verifier-level discriminator cannot detect a missing `Loader.load(rootDir)`). + +> **Amended by Step 3c — see §11.** This two-level rule is superseded in *scope*, not in intent. It +> is now read as **boundary-level transport assurance**, not per-discriminator duplication. +> Requiring all nineteen attacks to be re-proven through the Gradle task asks the same fact to be +> established repeatedly at every layer, which is not how layered verification works. The rule the +> closed Step 3c work actually applies is: +> +> 1. **Every attack** needs semantic discriminator evidence at the verifier/ceremony level. +> 2. **Every authority transport boundary** — task wiring, admission-ledger loading, candidate +> population transport, certificate-ledger and base-revision loading, fresh-measurement +> provenance, and the single shared `AdmissionAuthority` seam — needs independent real-task +> non-vacuity evidence, proven once per boundary. +> 3. **Attacks whose security property depends on provenance** — where a value came from, which +> revision owned it, whether it came from the fresh measurement or from base authority — need +> end-to-end evidence, because that is the property under test. +> +> The warning that motivated the original wording still holds and is honoured: a verifier-level +> discriminator cannot detect a missing `Loader.load(rootDir)`, so every boundary that performs a +> load is asserted through the real task. What is no longer required is proving each individual +> attack twice. + +| # | discriminator | expected | +| --- | --- | --- | +| T1 | neighbour raw status flips SURVIVED↔TIMED_OUT, outcome/family/module unchanged | authority digest **unchanged**, M34 pass (the Case-3 discriminator) | +| T2 | neighbour outcome flips NON_KILLED→KILLED | authority digest **changes**, M34 fail | +| T3 | authorized row's own raw status changes | M32 fail (status-inclusive binding retained) | +| T4 | v1-digest record presented under v2 semantics | fail closed | +| T5 | migration names an identity absent from the v1 ledger | fail | +| T6 | certificate omits or misstates `fromDigest` / `toDigest` / `admissionSetDigest` | fail | +| T7 | authority digest computed from candidate-supplied data rather than the verifier's fresh measurement | fail (transport test: the fresh path is used) | +| T8 | analyzer/mutator/timeout semantics drift under the authority digest | fail (analyzer stays inside the digest) | +| T9 | family/module re-homing of an authorized identity | M32 fail | +| T10 | unknown PIT status or stored outcome contradicting `canonical(status)` | M13 fail closed | +| T11 | KILLED→NON_KILLED regression | M01 fail | +| T12 | unauthorized appearing NON_KILLED alongside an authorized one | M06 fail for the unauthorized identity | +| T13 | migration cannot rewrite admission authority: given an existing v1 admission, `identity/status/outcome/family/module/analyzer/fromBaseSha/reason/issue/targetPhase` must stay **byte-identical**; any modification fails M36; only a separately base-minted digest-migration certificate may translate raw-v1 population context to authority-v2 context | M36 fail on any enforced-payload change (the admitted defect in §6.1) | +| T14 | candidate creates a digest-migration certificate **and** consumes it in the same transition (the M31 analogue for the new authority) | M43 fail | +| T15 | consumption citing a certificate whose `fromDigest` does not match the cited admission's `populationDigest`, or whose `admissionSetDigest` does not match the exact base authorization set | M41 / M42 fail (the certificate is bounded, not a general licence) | +| T16 | certificate retained after the consumption it authorised completed | M44 fail (single use) | +| T17 | newly introduced certificate whose `fromBaseSha` ≠ the authority base the transition is proposed against | M45 fail (mint-time base binding) | +| T18 | certificate present in the base with any enforced payload field rewritten (`fromAlgorithm/fromDigest/toAlgorithm/toDigest/admissionSetDigest/fromBaseSha/reason`) | M46 fail (retained immutability) | +| T19 | base certificate absent from the candidate without a valid consumption in that same transition | M47 fail (removal custody — never silent cancellation) | + +--- + +## 8. Files and rules that would change (implementation slice, not performed here) + +- `build-logic/.../quality/MutationRatchetVerifier.kt` — split the projections: keep + `projectionHash` raw-exact for the measurement proof; add the authority projection used by M34. +- `build-logic/.../quality/MutationPopulationAdmissionCeremony.kt` — M34 compares the authority + projection; add the certificate-aware consumption check and the M40-M44 fail-closed branches. + `isRetainedRewrite()` and the M36 path are **not** touched. +- New certificate authority: a ledger file (e.g. `config/quality/mutation-population-digest-certificates.yml`) + with its own loader following the existing loader pattern, plus its `schemaVersion` and + fail-closed validation. Separate from the admissions ledger so M36's payload comparison never + sees it. +- Tests: the T1-T19 matrix, verifier-level and real-task level. +- Docs: this record plus the rule-list updates in the ceremony/verifier headers. + +**Not changed:** `MutationPopulationAdmissions.kt` enums/payload (no new enforced field), +`MutationPopulationAdmissionLoader.kt` validation, `config/quality/mutation-population-admissions.yml` +(byte-identical), the committed baseline, classifications, and the evolution ledger. + +## 9. Explicitly out of scope + +- P2 consumption, baseline transition, M21 records, classification changes. +- PIT timeout values, mutator set, targets, tests, analyzer semantics, canonical outcome mapping + (`MutationOutcome.canonical`), M06, M13, M36, M37 (weakened by nothing in this design). +- Any change to the 67 admissions' identity, status, outcome, family, module, analyzer, + `fromBaseSha`, reason, issue or targetPhase. +- Killing the three oscillating mutants, or otherwise seeking to recover the digest `9aebd320…`. +- Treating raw-status nondeterminism as harmless: the nondeterminism is real and unresolved; this + record only establishes that raw status is the wrong thing for the *authority* digest to bind, + which is what C7 already says. + +## 10. Non-goals honoured by this record + +No full campaign rerun; no PIT timeout tuning; no mutant killing; no change to the committed +baseline, P1 admissions, M21 records, classification authority or canonical outcome mapping; no P2. + +## 11. Step 3c closure — T1–T19 evidence ledger + +Step 3c is complete. The §7 matrix above is closed as follows, under the amended rule in §7. + +### 11.1 Authority transport boundaries — each proven once, through the real task + +| boundary | real-task evidence | +| --- | --- | +| task wiring (`verifyMutationRatchet` registered and executed) | `MutationPopulationAdmissionWiringTest` and `MutationPopulationAdmissionIntegrationTest`, both via `GradleRunner` running the real task; production registration at `MaintainabilityBaselinePlugin.kt:804` | +| candidate population transport → verifier | `MutationPopulationAdmissionIntegrationTest` (real `verifyMutationRatchet` → `RECORDED_EVOLUTION` → canonical probe → fresh measurement) | +| admission ledger loading | `MutationPopulationAdmissionWiringTest` asserts M06/M34/M35/M36/M37 from real task output, including the loader's own hard failure for a malformed ledger | +| certificate ledger + base-revision loading | `CertificateLedgerTransportTest`, `CertificateCustodyTransportTest`, `CertificateBaseRevisionTransportTest` — real committed and re-read base-revision ledgers through the production loaders | +| fresh-measurement provenance | T7 discriminator, end-to-end (see 11.2) | +| single shared `AdmissionAuthority` seam | `CertificateCustodyTransportTest` → "the authority facts are produced exactly once and shared with every consumer"; one production call site (`MutationRatchetVerifier.kt:96`) plus its definition | + +### 11.2 Canonical attacks — semantic evidence, plus end-to-end where provenance is the property + +| row | semantic evidence | transport | +| --- | --- | --- | +| T1 | `MutationPopulationAdmissionCeremonyTest` "T1 a neighbour's raw status movement does not invalidate an authorized consumption" (authority digest unchanged, raw digest changed, M34 pass) | boundary: admission ledger + task wiring | +| T2 | `MutationPopulationEvolutionProofTest` outcome-change projection test + ceremony "authorized row with a different population digest fails M34" | boundary: admission ledger | +| T3 | ceremony "authorized identity with a different raw status fails M32" | boundary: admission ledger | +| T4 | ceremony M34 certified-migration pair (accepts when a base certificate covers the historical digest; fails with no certified migration) | **real task**: wiring test asserts M34 fail-closed before admission, M33 absent | +| T5 | "M42 a certificate that also covers an unauthorized identity is refused" | boundary: certificate ledger loading | +| T6 | M41 (source digest, migration pair) + M42 (admission set); omission arm rests on schema-required fields and fail-closed loading | boundary: certificate ledger loading | +| T7 | `MutationPopulationEvolutionProofTest` (identical populations → proof; every candidate-side difference → null) | **end-to-end**: `MutationPopulationAdmissionIntegrationTest` T7 discriminator + custody "the real committed baseline cannot supply the authority context itself" | +| T8 | "T8 analyzer semantics change the authority projection" + ceremony "…different analyzer semantics fails M33" | boundary: task wiring (`M33` absent-when-consistent asserted at task level) | +| T9 | ceremony "T9 a family-only re-homing keeps identity and fails M32, while a module move changes identity" | boundary: admission ledger | +| T10 | ratchet "M13 unknown raw status fails closed", "M07 forged kill with raw SURVIVED status fails closed" (contradiction arm), "tool failure statuses fail the measurement instead of becoming NON_KILLED", parser "unknown status fails closed" | boundary: candidate population transport | +| T11 | "M01 base KILLED to candidate NON_KILLED fails" | boundary: candidate population transport | +| T12 | ceremony "appearing survivor without any authorization still fails M06" (+ the authorized-appearing-row pass case) | **real task**: wiring test asserts M06 from real task output | +| T13 | "every bound field independently rejects a retained rewrite and audit fields do not", "rewriting only fromBaseSha while pending fails M36" | **real task**: wiring test asserts M36 for a bound-field rewrite | +| T14 | M43 (candidate-minted, absent-from-base, rewritten-base-payload) + custody "T14 a certificate absent from the base cannot be consumed, however it is cited" | boundary: certificate ledger loading | +| T15 | M41 source-digest / migration-pair + M42 admission-set refusals | boundary: certificate ledger loading | +| T16 | "T16 M44 a consumed certificate retained in the candidate fails" + custody "cannot be dropped without a proven consumption" | boundary: certificate ledger loading | +| T17 | ceremony "M45 a certificate minted against a different base is rejected" + custody "T17 a certificate introduced against another base fails M45" | boundary: certificate ledger + base-revision loading | +| T18 | ceremony "M46 a retained certificate rewritten in any enforced field is rejected" + custody "T18 …rewritten fails M46" | boundary: certificate ledger loading | +| T19 | ceremony "M47 removal custody is never satisfied by the mere absence of the certificate" + consumption "T19 M47 a removed certificate with no established consumption still fails" + custody "cannot be dropped without a proven consumption" | boundary: certificate ledger loading | + +### 11.3 Recorded numbers at closure + +- Verifier/ceremony suites: 122 tests, 0 failures (10 classes). +- Transport: `MutationPopulationAdmissionIntegrationTest` 3 tests, 0 failures. +- `CanonicalProbeFunctionalTest:507` (`IllegalStateException`, module-catalog fixture) is a + pre-existing unrelated red; that file's last change is #431 and no Step 3c change touches it. +- Historical authority: 67 admissions unchanged (SHA-256 `314b9c18…`, one distinct `fromBaseSha` + `5bcb030c`, one distinct `populationDigest` `9aebd320`, 36 TIMED_OUT / 27 SURVIVED / 4 + NO_COVERAGE); the P1M certificate unchanged (fromDigest `9aebd320…`, toDigest `e6ad01dc…`, + admissionSetDigest `98a9587a…`, fromBaseSha `64d05450…`). +- Step 3c produced two code PRs (#476, #478), one zero-diff audit (T10) and this record; **no + production behaviour changed**, no new `AdmissionAuthority` production site, no new + `CertificateConsumption.Valid` construction path. + +**Verdict: 0.7.1g1P2 Step 3c — COMPLETE.** Mutation-authority work is closed at this point; the +0.7.1 roadmap continues elsewhere. diff --git a/docs/roadmap/0.7.0/TASK-0.7.1i-INTEGRATION-CLOSURE.md b/docs/roadmap/0.7.0/TASK-0.7.1i-INTEGRATION-CLOSURE.md new file mode 100644 index 000000000..72e2ffd52 --- /dev/null +++ b/docs/roadmap/0.7.0/TASK-0.7.1i-INTEGRATION-CLOSURE.md @@ -0,0 +1,85 @@ +# TASK-0.7.1i — Integration Closure / Master Promotion Authority + +**Status:** ⛔ Recorded as a mandatory 0.7.1 closure gate (blocking) +**Owner:** maintainer +**Depends on:** 0.7.1 content freeze (0.7.1a–0.7.1h plus the mutation-population work merged into the Epic, and no further Epic commits planned) +**Class:** docs-only slice to record the requirement. The remediation work it describes is its own slice, later. + +## Why this exists + +Every 0.7.1 task so far is verified as an **Epic-targeted PR**: each one is small, separately reviewed, and green against its own base. The Epic→master promotion (PR #440) is judged by a different set of verifiers, which evaluate the **cumulative** transition. Those verifiers are currently red for three reasons, and none of them is a flake or a consequence of any individual PR. + +Observed on #440 at head `66cb3c13e9464bb31becb6726f9fe9fa33a8a24f`: + +| Verifier | Result | +|---|---| +| CI | passed | +| Architecture Authority | **failed** | +| Maintainability / change policy | **failed** on `analyzer-runtime-separation` | +| release-sovereign lane | **failed** — population pin `expected 179, discovered 181` | + +The consequence is the reason this record exists: **nothing else in the repository prevents us from finishing the mutation work and declaring 0.7.1 complete while #440 remains structurally impossible to merge.** Epic-level green does not imply promotion-level green, and no 0.7.1 task owns the difference. + +## Gate 1 — API migration closure + +The API migration ledger (`config/quality/api-migrations.yml`) records, per module, the `fromSha256 → toSha256` pair a consumer must be told about. On the Epic it accumulates **intermediate hops** because each task appends its own entry as it lands. + +Independently re-derived for this record at `305f1f8e`: + +- 12 entries across 10 modules; +- two modules carry **chains**: `:tramai-control-plane` and `:tramai-orchestration`; +- `config/quality/api-migrations.yml:103-108` — `:tramai-control-plane`, `e3b0c44298… → 0c656c8654…` (rationale: 0.7.1e control-plane authority contract); +- `config/quality/api-migrations.yml:110-115` — `:tramai-control-plane`, `0c656c8654… → 71acccdcab…` (rationale: 0.7.1f safe exposure model); +- one entry is anchored on `e3b0c44298…`, which is the SHA-256 of the empty string — a module with no `master` API dump at all. + +The promotion transition is `master → final-0.7.1`, so the ledger must carry that pair, not a path through intermediate Epic states. + +**Required result:** once the Epic is frozen, bind the migration evidence to the actual `master → final-0.7.1` API hashes for each affected module — `:tramai-control-plane`, `:tramai-engine`, `:tramai-persistence-file`, `:tramai-persistence-jdbc`, `:tramai-spring-boot-starter-sovereign-ops`, `:tramai-spring-boot-starter-sovereign-persistence-jdbc` and any others the verifier names — and prove it by running the architecture gate at the frozen head. Rebinding before the freeze is premature churn: every subsequent Epic commit invalidates the binding. + +## Gate 2 — Final population certification + +The `release-sovereign` lane in `.github/workflows/maintainability-baseline.yml` pins `expected: 179` for its six filter sets. Measured for this record with those six filters (local, JDK 21): + +| Tree | tests | failing locally | +|---|---|---| +| `origin/master` `41ac9151` | **179** — pin-accurate | 47 | +| Epic `305f1f8e` | **181** | 14 | + +The delta is fully accounted for, and it is **not** general drift: `TramaiDocsGuardsPluginTest` is the only class whose count differs (17 → 19), and the two additions are version-alignment cases: + +- `verifyVersionAlignment fails when the promoted release is no longer named current` +- `verifyVersionAlignment fails when the promoted release date drifts` + +Every other class in the lane is count-identical between `master` and the Epic. + +Two consequences for closing this gate: + +1. **The pin change is a 179 → 181 edit backed by that accounting** — two named tests, one class, no unexplained residue. +2. **A local run cannot certify it.** The same filters fail locally on *both* trees — 14 on the Epic and 47 on `master`, sharing the same ten `ReleaseVerificationPluginTest` TestKit fixture failures — so those failures are environmental rather than Epic drift. And because `maintainability-baseline.yml` triggers only for master-targeted PRs, **this lane has never been exercised on the Epic line in CI at all**. The corrected pin must therefore be verified in the lane's real environment at the frozen head before it counts as certified. + +**Required result:** bind the 179 → 181 correction to the two named tests, and show the lane green with the new pin, in CI, at the frozen head. + +## Gate 3 — Master-promotion separation + +`analyzer-runtime-separation` fails because the cumulative Epic→master diff contains **both** `build-logic`/analyzer changes **and** runtime production changes. This is by design, not a defect in the gate: the rule exists so that an analyzer can never change in the same governed transition as the runtime it judges. An Epic roll-up breaks exactly that property, even though every constituent change was separately reviewed — the final master transition carries no encoded proof of the separation. + +Note what does **not** fix it: `baseline-migration`. That change class permits analyzer and baseline changes together; it still forbids runtime production changes. So the roll-up fails under it too, and reaching for the label is the wrong instinct. + +**Required result:** a promotion sequence in which analyzer/build-logic changes and runtime production changes do not appear in the same governed transition. The expected shape is ordered transitions — land the independently valid tooling/analyzer transition first, then reconstruct the runtime promotion so that its master diff no longer contains analyzer changes. + +## Non-goal (binding) + +> **Do not weaken `verifyChangePolicy` merely to permit an Epic roll-up PR.** + +No exemption, no widened allowlist, no `epic-integration` carve-out, no analyzer+runtime exception, no "provenance is implied by the constituent PRs" argument invented to obtain a green. If the promotion needs a path, it must be a sequence that satisfies the rule **as written**. + +## Trigger + +After the 0.7.1 content freeze. Until then this task stays recorded and unstarted, and `#440` stays open as the integration canary — not merged, not closed, not "temporarily waived". + +## Exit + +- the promotion PR is green at its exact head with **no workflow exempted, weakened or bypassed**; +- each of the three gates above has recorded evidence, including the lane re-measurement behind any pin change; +- no authority file was changed to accommodate the transition; +- the promotion remains a certification event rather than a first-time integration. diff --git a/examples/spring-sovereign-starter/src/test/kotlin/dev/tramai/examples/spring/JdbcSovereignRuntimeE2ETest.kt b/examples/spring-sovereign-starter/src/test/kotlin/dev/tramai/examples/spring/JdbcSovereignRuntimeE2ETest.kt index 6b00defdc..e6262d7a3 100644 --- a/examples/spring-sovereign-starter/src/test/kotlin/dev/tramai/examples/spring/JdbcSovereignRuntimeE2ETest.kt +++ b/examples/spring-sovereign-starter/src/test/kotlin/dev/tramai/examples/spring/JdbcSovereignRuntimeE2ETest.kt @@ -4,6 +4,7 @@ import com.zaxxer.hikari.HikariDataSource import dev.tramai.core.approval.ApprovalContinuationStore import dev.tramai.core.approval.ApprovalStore import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.persistence.jdbc.GovernedJdbcSuspendedInvocationStore import dev.tramai.persistence.jdbc.JdbcApprovalContinuationStore import dev.tramai.persistence.jdbc.JdbcApprovalStore import dev.tramai.persistence.jdbc.JdbcAuditStore @@ -124,8 +125,12 @@ class JdbcSovereignRuntimeE2ETest { .isExactlyInstanceOf(JdbcApprovalStore::class.java) assertThat(ctx).hasSingleBean(SuspendedInvocationStore::class.java) + // The governed suspension capability composes ON TOP of the JDBC store (0.7.1d): + // one row, one transaction, whole-run identity inside that row's payload. The + // store is still PostgreSQL-backed, it is just wrapped. assertThat(ctx.getBean(SuspendedInvocationStore::class.java)) - .isExactlyInstanceOf(JdbcSuspendedInvocationStore::class.java) + .withFailMessage("the governed suspension capability must wrap the JDBC store, not replace it") + .isExactlyInstanceOf(GovernedJdbcSuspendedInvocationStore::class.java) assertThat(ctx).hasSingleBean(ApprovalContinuationStore::class.java) assertThat(ctx.getBean(ApprovalContinuationStore::class.java)) diff --git a/gradle.properties b/gradle.properties index 701ea43fd..390228c45 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.warning.mode=all org.gradle.java.installations.auto-detect=true kotlin.daemon.jvmargs=-Xmx2G tramaiGroup=dev.tramai -tramaiVersion=0.6.0 +tramaiVersion=0.7.0-SNAPSHOT tramaiProjectUrl=https://github.com/GionaGranchelli/tramAI tramaiScmUrl=https://github.com/GionaGranchelli/tramAI.git tramaiScmConnection=scm:git:https://github.com/GionaGranchelli/tramAI.git diff --git a/tramai-control-plane/api/tramai-control-plane.api b/tramai-control-plane/api/tramai-control-plane.api index 5e3de6f33..3d1a0f00a 100644 --- a/tramai-control-plane/api/tramai-control-plane.api +++ b/tramai-control-plane/api/tramai-control-plane.api @@ -1,3 +1,18 @@ +public final class dev/tramai/controlplane/ClassifiedRead { + public fun (Ldev/tramai/controlplane/WorkloadExposure;Ldev/tramai/controlplane/QueryConsistency;Ldev/tramai/controlplane/WorkloadStateVersion;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun component2 ()Ldev/tramai/controlplane/QueryConsistency; + public final fun component3 ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;Ldev/tramai/controlplane/QueryConsistency;Ldev/tramai/controlplane/WorkloadStateVersion;)Ldev/tramai/controlplane/ClassifiedRead; + public static synthetic fun copy$default (Ldev/tramai/controlplane/ClassifiedRead;Ldev/tramai/controlplane/WorkloadExposure;Ldev/tramai/controlplane/QueryConsistency;Ldev/tramai/controlplane/WorkloadStateVersion;ILjava/lang/Object;)Ldev/tramai/controlplane/ClassifiedRead; + public fun equals (Ljava/lang/Object;)Z + public final fun getConsistency ()Ldev/tramai/controlplane/QueryConsistency; + public final fun getExposure ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun getObservedVersion ()Ldev/tramai/controlplane/WorkloadStateVersion; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/tramai/controlplane/ConfigurationFingerprint { public static final field Companion Ldev/tramai/controlplane/ConfigurationFingerprint$Companion; public static final field FINGERPRINT_MAX_LENGTH I @@ -63,12 +78,12 @@ public abstract interface class dev/tramai/controlplane/LifecycleTransitionOutco } public final class dev/tramai/controlplane/LifecycleTransitionOutcome$Applied : dev/tramai/controlplane/LifecycleTransitionOutcome { - public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V - public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; - public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied; - public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied; + public fun (Ldev/tramai/controlplane/WorkloadExposure;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied; + public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied;Ldev/tramai/controlplane/WorkloadExposure;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Applied; public fun equals (Ljava/lang/Object;)Z - public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getExposure ()Ldev/tramai/controlplane/WorkloadExposure; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -107,12 +122,12 @@ public final class dev/tramai/controlplane/LifecycleTransitionOutcome$Stale : de } public final class dev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged : dev/tramai/controlplane/LifecycleTransitionOutcome { - public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V - public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; - public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged; - public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged; + public fun (Ldev/tramai/controlplane/WorkloadExposure;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged; + public static synthetic fun copy$default (Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged;Ldev/tramai/controlplane/WorkloadExposure;ILjava/lang/Object;)Ldev/tramai/controlplane/LifecycleTransitionOutcome$Unchanged; public fun equals (Ljava/lang/Object;)Z - public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getExposure ()Ldev/tramai/controlplane/WorkloadExposure; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -121,12 +136,12 @@ public abstract interface class dev/tramai/controlplane/MetadataUpdateOutcome { } public final class dev/tramai/controlplane/MetadataUpdateOutcome$Applied : dev/tramai/controlplane/MetadataUpdateOutcome { - public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V - public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; - public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied; - public static synthetic fun copy$default (Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied; + public fun (Ldev/tramai/controlplane/WorkloadExposure;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied; + public static synthetic fun copy$default (Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied;Ldev/tramai/controlplane/WorkloadExposure;ILjava/lang/Object;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Applied; public fun equals (Ljava/lang/Object;)Z - public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getExposure ()Ldev/tramai/controlplane/WorkloadExposure; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -152,49 +167,57 @@ public final class dev/tramai/controlplane/MetadataUpdateOutcome$Stale : dev/tra } public final class dev/tramai/controlplane/MetadataUpdateOutcome$Unchanged : dev/tramai/controlplane/MetadataUpdateOutcome { - public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V - public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; - public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged; - public static synthetic fun copy$default (Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged; + public fun (Ldev/tramai/controlplane/WorkloadExposure;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged; + public static synthetic fun copy$default (Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged;Ldev/tramai/controlplane/WorkloadExposure;ILjava/lang/Object;)Ldev/tramai/controlplane/MetadataUpdateOutcome$Unchanged; public fun equals (Ljava/lang/Object;)Z - public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getExposure ()Ldev/tramai/controlplane/WorkloadExposure; public fun hashCode ()I public fun toString ()Ljava/lang/String; } +public final class dev/tramai/controlplane/QueryConsistency : java/lang/Enum { + public static final field AUTHORITATIVE Ldev/tramai/controlplane/QueryConsistency; + public static final field PROJECTION Ldev/tramai/controlplane/QueryConsistency; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/tramai/controlplane/QueryConsistency; + public static fun values ()[Ldev/tramai/controlplane/QueryConsistency; +} + public abstract interface class dev/tramai/controlplane/RegisterOutcome { } public final class dev/tramai/controlplane/RegisterOutcome$AlreadyRegistered : dev/tramai/controlplane/RegisterOutcome { - public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V - public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; - public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered; - public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered; + public fun (Ldev/tramai/controlplane/WorkloadExposure;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;)Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered; + public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered;Ldev/tramai/controlplane/WorkloadExposure;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$AlreadyRegistered; public fun equals (Ljava/lang/Object;)Z - public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getExposure ()Ldev/tramai/controlplane/WorkloadExposure; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class dev/tramai/controlplane/RegisterOutcome$Created : dev/tramai/controlplane/RegisterOutcome { - public fun (Ldev/tramai/controlplane/RegisteredWorkload;)V - public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; - public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/RegisterOutcome$Created; - public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$Created;Ldev/tramai/controlplane/RegisteredWorkload;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$Created; + public fun (Ldev/tramai/controlplane/WorkloadExposure;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;)Ldev/tramai/controlplane/RegisterOutcome$Created; + public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$Created;Ldev/tramai/controlplane/WorkloadExposure;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$Created; public fun equals (Ljava/lang/Object;)Z - public final fun getRegistration ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getExposure ()Ldev/tramai/controlplane/WorkloadExposure; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class dev/tramai/controlplane/RegisterOutcome$Rejected : dev/tramai/controlplane/RegisterOutcome { - public fun (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;)V - public final fun component1 ()Ldev/tramai/controlplane/RegisteredWorkload; + public fun (Ldev/tramai/controlplane/WorkloadExposure;Ldev/tramai/controlplane/RegistrationConflictReason;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadExposure; public final fun component2 ()Ldev/tramai/controlplane/RegistrationConflictReason; - public final fun copy (Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;)Ldev/tramai/controlplane/RegisterOutcome$Rejected; - public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$Rejected;Ldev/tramai/controlplane/RegisteredWorkload;Ldev/tramai/controlplane/RegistrationConflictReason;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$Rejected; + public final fun copy (Ldev/tramai/controlplane/WorkloadExposure;Ldev/tramai/controlplane/RegistrationConflictReason;)Ldev/tramai/controlplane/RegisterOutcome$Rejected; + public static synthetic fun copy$default (Ldev/tramai/controlplane/RegisterOutcome$Rejected;Ldev/tramai/controlplane/WorkloadExposure;Ldev/tramai/controlplane/RegistrationConflictReason;ILjava/lang/Object;)Ldev/tramai/controlplane/RegisterOutcome$Rejected; public fun equals (Ljava/lang/Object;)Z - public final fun getExisting ()Ldev/tramai/controlplane/RegisteredWorkload; + public final fun getExisting ()Ldev/tramai/controlplane/WorkloadExposure; public final fun getReason ()Ldev/tramai/controlplane/RegistrationConflictReason; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -227,6 +250,22 @@ public final class dev/tramai/controlplane/RegistrationConflictReason : java/lan public static fun values ()[Ldev/tramai/controlplane/RegistrationConflictReason; } +public final class dev/tramai/controlplane/WorkloadAdmissionRejectedException : java/lang/RuntimeException { + public fun (Ljava/lang/String;)V + public final fun getReason ()Ljava/lang/String; +} + +public abstract interface class dev/tramai/controlplane/WorkloadControlPlaneCommands { + public abstract fun register (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/controlplane/ConfigurationFingerprint;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun transitionLifecycle (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadLifecycleState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun updateMetadata (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class dev/tramai/controlplane/WorkloadControlPlaneQueries { + public abstract fun authoritative (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun projection (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/controlplane/WorkloadDeploymentScope { public fun (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;)V public final fun component1 ()Ldev/tramai/core/identity/WorkloadId; @@ -242,6 +281,28 @@ public final class dev/tramai/controlplane/WorkloadDeploymentScope { public fun toString ()Ljava/lang/String; } +public final class dev/tramai/controlplane/WorkloadExposure { + public static final field Companion Ldev/tramai/controlplane/WorkloadExposure$Companion; + public fun (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/core/identity/WorkloadMetadata;Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadStateVersion;)V + public final fun component1 ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun component2 ()Ldev/tramai/core/identity/WorkloadMetadata; + public final fun component3 ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun component4 ()Ldev/tramai/controlplane/WorkloadStateVersion; + public final fun copy (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/core/identity/WorkloadMetadata;Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadStateVersion;)Ldev/tramai/controlplane/WorkloadExposure; + public static synthetic fun copy$default (Ldev/tramai/controlplane/WorkloadExposure;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/core/identity/WorkloadMetadata;Ldev/tramai/controlplane/WorkloadLifecycleState;Ldev/tramai/controlplane/WorkloadStateVersion;ILjava/lang/Object;)Ldev/tramai/controlplane/WorkloadExposure; + public fun equals (Ljava/lang/Object;)Z + public final fun getIdentity ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun getLifecycle ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun getMetadata ()Ldev/tramai/core/identity/WorkloadMetadata; + public final fun getStateVersion ()Ldev/tramai/controlplane/WorkloadStateVersion; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/controlplane/WorkloadExposure$Companion { + public final fun from (Ldev/tramai/controlplane/RegisteredWorkload;)Ldev/tramai/controlplane/WorkloadExposure; +} + public final class dev/tramai/controlplane/WorkloadLifecycleState : java/lang/Enum { public static final field ACTIVE Ldev/tramai/controlplane/WorkloadLifecycleState; public static final field RETIRED Ldev/tramai/controlplane/WorkloadLifecycleState; @@ -252,11 +313,14 @@ public final class dev/tramai/controlplane/WorkloadLifecycleState : java/lang/En public static fun values ()[Ldev/tramai/controlplane/WorkloadLifecycleState; } -public final class dev/tramai/controlplane/WorkloadRegistrationAuthority { +public final class dev/tramai/controlplane/WorkloadRegistrationAuthority : dev/tramai/controlplane/WorkloadControlPlaneCommands, dev/tramai/controlplane/WorkloadControlPlaneQueries { public fun (Ldev/tramai/controlplane/WorkloadRegistrationStore;)V - public final fun register (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/controlplane/ConfigurationFingerprint;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun transitionLifecycle (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadLifecycleState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun updateMetadata (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun authoritative (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun projection (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun register (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/controlplane/ConfigurationFingerprint;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun resolveForNewRun (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun transitionLifecycle (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/controlplane/WorkloadLifecycleState;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun updateMetadata (Ldev/tramai/core/identity/WorkloadId;Ldev/tramai/core/identity/EnvironmentId;Ldev/tramai/core/identity/DeploymentId;Ldev/tramai/controlplane/WorkloadStateVersion;Ldev/tramai/core/identity/WorkloadMetadata;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class dev/tramai/controlplane/WorkloadRegistrationStore { diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadAdmissionRejectedException.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadAdmissionRejectedException.kt new file mode 100644 index 000000000..2ef143ff8 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadAdmissionRejectedException.kt @@ -0,0 +1,18 @@ +package dev.tramai.controlplane + +/** + * Raised when a new governed execution is not authorized by the authoritative + * registration (0.7.1d). + * + * [reason] is a stable machine-readable code, not a message for end users: + * - `workload-deployment-not-registered` + * - `workload-deployment-configuration-mismatch` + * - `workload-deployment-not-active` + * + * Failing closed is the point: admitting a run whose deployment is not the + * authoritative registration would create execution attribution that nothing + * authoritative backs. + */ +class WorkloadAdmissionRejectedException( + val reason: String, +) : RuntimeException(reason) diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadControlPlaneCommands.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadControlPlaneCommands.kt new file mode 100644 index 000000000..eeb495244 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadControlPlaneCommands.kt @@ -0,0 +1,79 @@ +package dev.tramai.controlplane + +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.identity.WorkloadMetadata + +/** + * Framework-neutral command authority for workload registration state (0.7.1e). + * + * This port exposes the semantic shape of [WorkloadRegistrationAuthority] — it is + * implemented BY that authority, not wrapped by a forwarding layer, so no + * lifecycle rule, fingerprint check or compare-and-set exists in two places. + * + * Every state-dependent mutation requires the [WorkloadStateVersion] the caller + * observed. That is a structural guarantee, not a convention: + * + * - the port has no operation that stores a record unconditionally, so + * "read, modify, write" is not expressible here; + * - an expected version is a bare version, never a record, so a value obtained + * from any read (authoritative or projection) cannot be replayed as a mutation + * witness; + * - stale expectations are reported as the typed + * [MetadataUpdateOutcome.Stale] / [LifecycleTransitionOutcome.Stale] carrying + * the CURRENT authoritative version, never as a false/null and never as a + * generic persistence failure. + * + * HTTP mechanics — `ETag`/`If-Match` parsing, status codes, problem DTOs — are + * deliberately NOT part of this contract: they belong to the adapter that + * consumes this port. + */ +interface WorkloadControlPlaneCommands { + /** + * Registers a workload deployment as ACTIVE at state version 1. + * + * Idempotent for an identical declaration; a conflicting declaration is + * rejected, never silently applied. + */ + suspend fun register( + identity: WorkloadDeploymentIdentity, + configurationFingerprint: ConfigurationFingerprint, + metadata: WorkloadMetadata, + ): RegisterOutcome + + /** + * Updates owner/purpose metadata. Identity is unchanged; a successful change + * advances the state version exactly once. + * + * @param expectedVersion version observed by the caller — a mutation based on + * a stale version fails with [MetadataUpdateOutcome.Stale] carrying the + * current authoritative version. + */ + suspend fun updateMetadata( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + expectedVersion: WorkloadStateVersion, + metadata: WorkloadMetadata, + ): MetadataUpdateOutcome + + /** + * Applies an authoritative lifecycle transition. RETIRED is terminal; + * suspending a registration never touches running workflows. + * + * @param expectedVersion version observed by the caller — a mutation based on + * a stale version fails with [LifecycleTransitionOutcome.Stale]; a current + * version with an illegal target fails with + * [LifecycleTransitionOutcome.InvalidTransition]. The two are distinct + * outcomes on purpose. + */ + suspend fun transitionLifecycle( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + expectedVersion: WorkloadStateVersion, + target: WorkloadLifecycleState, + ): LifecycleTransitionOutcome +} diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadControlPlaneQueries.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadControlPlaneQueries.kt new file mode 100644 index 000000000..7456a82e3 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadControlPlaneQueries.kt @@ -0,0 +1,95 @@ +package dev.tramai.controlplane + +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadId + +/** + * How a read relates to the authoritative state (0.7.1e). + * + * Read paths are separated from command paths structurally: [WorkloadControlPlaneQueries] + * exposes nothing that mutates, and this classification is what a caller must use to + * reason about freshness — instead of an unqualified "eventually consistent: true". + */ +enum class QueryConsistency { + /** Read directly from the authority; current according to that authority. */ + AUTHORITATIVE, + + /** + * Read from derived state that may lag. Always reports the authoritative version it + * observed, so a caller can compare it with the authoritative read rather than guess. + */ + PROJECTION, +} + +/** + * A read result labelled with the consistency class that produced it and the + * authoritative [WorkloadStateVersion] it reflects. It returns the safe + * [WorkloadExposure], never the internal authority record. + * + * Immutable by construction, and never accepted as a mutation input AS A RECORD: commands take + * `(identity fields, expectedVersion, payload)`, so a read result cannot be handed over as a + * mutation witness. + * + * Its [observedVersion] may legitimately be submitted as an explicit expected version like any other + * version. That is NOT the read becoming authoritative: the authority re-reads and answers + * `Stale(current, expected)` unless its current version still equals what was submitted. Projection + * lag is defeated by the compare-and-set, not by the type system — so there is deliberately no + * separate "version token" type here. + */ +data class ClassifiedRead( + val exposure: WorkloadExposure, + val consistency: QueryConsistency, + val observedVersion: WorkloadStateVersion, +) { + init { + // A read must not contradict itself: the version it reports is the version of the state it + // carries. A future projection implementation that observed one version and shipped another + // fails here instead of returning contradictory state/version evidence. + require(exposure.stateVersion == observedVersion) { + "read state/version mismatch: exposure.stateVersion=" + + "${exposure.stateVersion.value} but observedVersion=${observedVersion.value}" + } + } +} + +/** + * Read authority for workload registration state (0.7.1e). + * + * A read-only port: there is no operation here that can change state, so a query path cannot + * become a mutation authority. Implemented by [WorkloadRegistrationAuthority] over the same + * authority as [WorkloadControlPlaneCommands] — no second copy of the state. + * + * Returns null when the deployment scope has no registration. Absence is not an error on the + * read path; on the command path it is a typed `NotFound` outcome. + */ +interface WorkloadControlPlaneQueries { + /** + * Reads current authoritative state. The result carries + * [QueryConsistency.AUTHORITATIVE] and an [ClassifiedRead.observedVersion] equal to the + * registration's own state version. + */ + suspend fun authoritative( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): ClassifiedRead? + + /** + * Reads a projection snapshot: read-only derived state that may lag the authoritative + * version because it reflects the moment it was observed, not the authority's present. + * + * The result carries [QueryConsistency.PROJECTION] and the version it observed. A + * lagging observation never authorizes a command — the authority still rejects a command + * conditioned on it as [MetadataUpdateOutcome.Stale] / [LifecycleTransitionOutcome.Stale]. + * + * ponytail: 0.7.1e ships the classification and the read-only guarantee, not a projection + * engine. There is no event bus, no materialised store and no refresh policy; a real + * projection source arrives with the slices that need one (0.7.4 evidence, 0.7.8 dashboard). + */ + suspend fun projection( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): ClassifiedRead? +} diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadExposure.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadExposure.kt new file mode 100644 index 000000000..cbbda2a63 --- /dev/null +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadExposure.kt @@ -0,0 +1,45 @@ +package dev.tramai.controlplane + +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadMetadata + +/** + * The complete set of registration facts a generic control-plane surface may expose. + * + * Four categories bound this boundary: + * + * 1. SAFE TO READ — [identity], [metadata], [lifecycle], [stateVersion]. These are the allowlist. + * 2. DECLARATION INPUT, NOT READABLE — [ConfigurationFingerprint]. A client legitimately *originates* + * it when it declares a registration (`register(...)`, and the HTTP registration body), so it is + * neither secret material nor a mutation credential. What it must never be is *reflected back*: it + * is the witness [WorkloadRegistrationStore.compareAndSet] compares to decide mutation authority + * and the value that makes a `(configurationId, version)` binding unrebindable, so it is absent + * from every read model and from every command *outcome*. + * 3. INTERNAL AUTHORITY / STORAGE — [RegisteredWorkload] (which necessarily carries the witness), + * `CreateResult` and persistence records. Reachable through [WorkloadRegistrationStore], never + * through a generic control-plane port. + * 4. PROTECTED PAYLOAD — prompts, model input/output, user content, tool arguments/results, + * credentials, PII, approval/suspension/replay payloads, evidence bodies and provider traffic. + * Not representable here at all. + * + * This type carries category 1 only. No `Map`, `Any` or `JsonNode` property may ever be added: adding + * a property is a deliberate exposure decision that must update the exact-shape tests, which fail + * closed otherwise. + */ +data class WorkloadExposure( + val identity: WorkloadDeploymentIdentity, + val metadata: WorkloadMetadata, + val lifecycle: WorkloadLifecycleState, + val stateVersion: WorkloadStateVersion, +) { + companion object { + /** The explicit allowlist mapper: the ONLY path from the authority record to an exposure. */ + fun from(registration: RegisteredWorkload): WorkloadExposure = + WorkloadExposure( + identity = registration.identity, + metadata = registration.metadata, + lifecycle = registration.lifecycle, + stateVersion = registration.stateVersion, + ) + } +} diff --git a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt index 9de63a473..822835d26 100644 --- a/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt +++ b/tramai-control-plane/src/main/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthority.kt @@ -20,20 +20,118 @@ import dev.tramai.core.identity.WorkloadMetadata * - stale writers lose (every mutation is a compare-and-set on the version * the caller observed). * - * This authority does NOT cancel running workflows, replace [WorkflowRegistry] - * executable-definition lookup, or expose any REST/query surface — those - * belong to later candidates. + * This authority does NOT cancel running workflows or replace [WorkflowRegistry] + * executable-definition lookup. It implements the framework-neutral command and read + * contracts ([WorkloadControlPlaneCommands], [WorkloadControlPlaneQueries]) as of 0.7.1e; + * HTTP mechanics stay in the server adapter. */ class WorkloadRegistrationAuthority( private val store: WorkloadRegistrationStore, -) { +) : WorkloadControlPlaneCommands, + WorkloadControlPlaneQueries { + /** + * Resolves the authoritative registration that admits a NEW governed run (0.7.1d). + * + * This is the minimal internal admission lookup, NOT a query surface: the only + * thing a caller can learn is whether its configured deployment is authoritative + * for new runs. The caller supplies the identity it intends to run as; the + * authority answers with the registered identity it must use. + * + * Rejects, never coerces: + * - no registration for the deployment scope; + * - the registered identity differs anywhere, including configuration + * (id or version) — an identity is only authoritative in full; + * - the registration is not [WorkloadLifecycleState.ACTIVE]. + * + * Lifecycle is checked HERE, at new-execution admission. A later transition to + * SUSPENDED or RETIRED does not retroactively invalidate a run that was + * already admitted, and must never be consulted for continuations. + */ + suspend fun resolveForNewRun(expectedIdentity: WorkloadDeploymentIdentity): WorkloadDeploymentIdentity { + val registered = + store.find( + expectedIdentity.workloadId, + expectedIdentity.environmentId, + expectedIdentity.deploymentId, + ) ?: throw WorkloadAdmissionRejectedException("workload-deployment-not-registered") + + val rejection = admissionRejection(registered, expectedIdentity) + if (rejection != null) { + throw WorkloadAdmissionRejectedException(rejection) + } + return registered.identity + } + + /** + * Reads current authoritative state (0.7.1e). + * + * Read-only: nothing on this path mutates, so a query cannot become a mutation authority. + * The observed version equals the registration's own state version because this read IS + * the authority's present state, not a derived view of it. + */ + override suspend fun authoritative( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): ClassifiedRead? { + val registration = + store.find(workloadId, environmentId, deploymentId) ?: return null + return ClassifiedRead( + exposure = WorkloadExposure.from(registration), + consistency = QueryConsistency.AUTHORITATIVE, + observedVersion = registration.stateVersion, + ) + } + + /** + * Reads a projection snapshot (0.7.1e). + * + * In 0.7.1e the projection source is an OBSERVATION of the authoritative store — there is + * no derived store yet — so this read is honest about what a projection is here: a + * read-only observation stamped with the version it saw. Lag is real the moment a caller + * holds the observation across a mutation, and it never buys mutation authority: the + * authority still rejects a command conditioned on the observed version as + * [MetadataUpdateOutcome.Stale] / [LifecycleTransitionOutcome.Stale]. + * + * ponytail: no projection engine (no event bus, no materialised store, no refresh policy) + * until a slice needs one — 0.7.4 evidence and 0.7.8 dashboard are the candidates. + */ + override suspend fun projection( + workloadId: WorkloadId, + environmentId: EnvironmentId, + deploymentId: DeploymentId, + ): ClassifiedRead? { + val registration = + store.find(workloadId, environmentId, deploymentId) ?: return null + return ClassifiedRead( + exposure = WorkloadExposure.from(registration), + consistency = QueryConsistency.PROJECTION, + observedVersion = registration.stateVersion, + ) + } + + /** + * Why an existing registration may not admit this deployment, or null when it may. + * Returned rather than thrown so the reason codes stay in one place and the admission + * path keeps a single failure surface. + */ + private fun admissionRejection( + registered: RegisteredWorkload, + expectedIdentity: WorkloadDeploymentIdentity, + ): String? = + when { + registered.identity != expectedIdentity -> "workload-deployment-configuration-mismatch" + registered.lifecycle != WorkloadLifecycleState.ACTIVE -> "workload-deployment-not-active" + else -> null + } + /** * Registers a workload deployment as ACTIVE at state version 1. * * Idempotent for an identical declaration; conflicting declarations are * rejected, never silently applied. */ - suspend fun register( + override suspend fun register( identity: WorkloadDeploymentIdentity, configurationFingerprint: ConfigurationFingerprint, metadata: WorkloadMetadata, @@ -41,10 +139,10 @@ class WorkloadRegistrationAuthority( val existing = store.find(identity.workloadId, identity.environmentId, identity.deploymentId) if (existing != null) { return if (existing.isSameDeclaration(identity, configurationFingerprint, metadata)) { - RegisterOutcome.AlreadyRegistered(existing) + RegisterOutcome.AlreadyRegistered(WorkloadExposure.from(existing)) } else { RegisterOutcome.Rejected( - existing = existing, + existing = WorkloadExposure.from(existing), reason = existing.rejectionReason(identity, configurationFingerprint), ) } @@ -59,9 +157,17 @@ class WorkloadRegistrationAuthority( stateVersion = WorkloadStateVersion.INITIAL, ) return when (val result = store.create(desired)) { - is CreateResult.Created -> RegisterOutcome.Created(result.registration) - is CreateResult.Idempotent -> RegisterOutcome.AlreadyRegistered(result.registration) - is CreateResult.Conflicting -> RegisterOutcome.Rejected(result.existing, result.reason) + is CreateResult.Created -> { + RegisterOutcome.Created(WorkloadExposure.from(result.registration)) + } + + is CreateResult.Idempotent -> { + RegisterOutcome.AlreadyRegistered(WorkloadExposure.from(result.registration)) + } + + is CreateResult.Conflicting -> { + RegisterOutcome.Rejected(WorkloadExposure.from(result.existing), result.reason) + } } } @@ -72,7 +178,7 @@ class WorkloadRegistrationAuthority( * @param expectedVersion version observed by the caller — a mutation based * on a stale version fails with [MetadataUpdateOutcome.Stale]. */ - suspend fun updateMetadata( + override suspend fun updateMetadata( workloadId: WorkloadId, environmentId: EnvironmentId, deploymentId: DeploymentId, @@ -88,7 +194,7 @@ class WorkloadRegistrationAuthority( } current.metadata == metadata -> { - MetadataUpdateOutcome.Unchanged(current) + MetadataUpdateOutcome.Unchanged(WorkloadExposure.from(current)) } else -> { @@ -102,7 +208,7 @@ class WorkloadRegistrationAuthority( stateVersion = current.stateVersion.next(), ) if (store.compareAndSet(current, updated)) { - MetadataUpdateOutcome.Applied(updated) + MetadataUpdateOutcome.Applied(WorkloadExposure.from(updated)) } else { // Lost the race: report the CURRENT authoritative version, // not the version observed before the race (0.7.1e command @@ -123,7 +229,7 @@ class WorkloadRegistrationAuthority( * Applies an authoritative lifecycle transition. RETIRED is terminal; * suspending a workload never touches running workflows. */ - suspend fun transitionLifecycle( + override suspend fun transitionLifecycle( workloadId: WorkloadId, environmentId: EnvironmentId, deploymentId: DeploymentId, @@ -139,7 +245,7 @@ class WorkloadRegistrationAuthority( } current.lifecycle == target -> { - LifecycleTransitionOutcome.Unchanged(current) + LifecycleTransitionOutcome.Unchanged(WorkloadExposure.from(current)) } !current.lifecycle.canTransitionTo(target) -> { @@ -157,7 +263,7 @@ class WorkloadRegistrationAuthority( stateVersion = current.stateVersion.next(), ) if (store.compareAndSet(current, updated)) { - LifecycleTransitionOutcome.Applied(updated) + LifecycleTransitionOutcome.Applied(WorkloadExposure.from(updated)) } else { // Lost the race: report the CURRENT authoritative version. val latest = @@ -177,17 +283,17 @@ class WorkloadRegistrationAuthority( sealed interface RegisterOutcome { /** A new registration was created (ACTIVE, state version 1). */ data class Created( - val registration: RegisteredWorkload, + val exposure: WorkloadExposure, ) : RegisterOutcome /** The identical declaration was already authoritative; nothing changed. */ data class AlreadyRegistered( - val registration: RegisteredWorkload, + val exposure: WorkloadExposure, ) : RegisterOutcome /** The declaration conflicts with the authoritative record; nothing changed. */ data class Rejected( - val existing: RegisteredWorkload, + val existing: WorkloadExposure, val reason: RegistrationConflictReason, ) : RegisterOutcome } @@ -196,12 +302,12 @@ sealed interface RegisterOutcome { sealed interface MetadataUpdateOutcome { /** Metadata changed; identity unchanged; state version advanced. */ data class Applied( - val registration: RegisteredWorkload, + val exposure: WorkloadExposure, ) : MetadataUpdateOutcome /** Requested metadata equals current metadata; no authoritative mutation occurred. */ data class Unchanged( - val registration: RegisteredWorkload, + val exposure: WorkloadExposure, ) : MetadataUpdateOutcome /** Caller's expected version is stale; the store moved on. */ @@ -217,12 +323,12 @@ sealed interface MetadataUpdateOutcome { sealed interface LifecycleTransitionOutcome { /** Lifecycle changed; state version advanced. */ data class Applied( - val registration: RegisteredWorkload, + val exposure: WorkloadExposure, ) : LifecycleTransitionOutcome /** Target state equals current state; no authoritative mutation occurred. */ data class Unchanged( - val registration: RegisteredWorkload, + val exposure: WorkloadExposure, ) : LifecycleTransitionOutcome /** The requested transition is outside the authoritative lifecycle graph. */ diff --git a/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadControlPlaneContractTest.kt b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadControlPlaneContractTest.kt new file mode 100644 index 000000000..48a897877 --- /dev/null +++ b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadControlPlaneContractTest.kt @@ -0,0 +1,255 @@ +package dev.tramai.controlplane + +import dev.tramai.controlplane.testing.WorkloadRegistrationFixtures +import dev.tramai.controlplane.testing.runInParallel +import dev.tramai.core.identity.WorkloadMetadata +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * The public control-plane authority contract (0.7.1e). + * + * These tests exercise the authority through the DECLARED PORT TYPES + * ([WorkloadControlPlaneCommands], [WorkloadControlPlaneQueries]) rather than the concrete class, + * because the port is the deliverable: the guarantees must be reachable by a transport adapter + * that only ever sees the contract. + * + * The authority's own rules are pinned in [WorkloadRegistrationAuthorityTest]; these tests pin the + * contract around them — version-exact stale reporting through the port, read classification, + * projection lag never authorizing a command, and the structural claim that a read port cannot + * mutate and a read value cannot be used as a mutation witness. + */ +class WorkloadControlPlaneContractTest { + private lateinit var store: InMemoryWorkloadRegistrationStore + private lateinit var commands: WorkloadControlPlaneCommands + private lateinit var queries: WorkloadControlPlaneQueries + + private val identity = WorkloadRegistrationFixtures.identity(deployment = "contract-deployment") + private val fingerprint = WorkloadRegistrationFixtures.fingerprint() + private val metadata = WorkloadRegistrationFixtures.metadata() + + @BeforeEach + fun setUp() { + store = InMemoryWorkloadRegistrationStore() + // One authority object, two contracts: no second copy of the state, no forwarding wrapper. + val authority = WorkloadRegistrationAuthority(store) + commands = authority + queries = authority + } + + // ── Command contract ──────────────────────────────────────────── + + @Test + fun `concurrent commands on the same expected version yield exactly one winner and a truthful stale loser`() = + runBlocking { + // Reach the exact shape the contract cares about: authoritative version 7. + val version7 = advanceTo(7) + assertThat(version7).isEqualTo(WorkloadStateVersion(7)) + + val outcomes = + runInParallel( + { + commands.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + version7, + WorkloadMetadata(owner = "Writer A", purpose = "a"), + ) + }, + { + commands.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + version7, + WorkloadMetadata(owner = "Writer B", purpose = "b"), + ) + }, + ) + + // Not merely "one failed": exactly one Applied at v8, and one Stale that reports the + // winner's version as current rather than the version it read before the race. + val applied = outcomes.filterIsInstance().single() + assertThat(applied.exposure.stateVersion).isEqualTo(WorkloadStateVersion(8)) + + val stale = outcomes.filterIsInstance().single() + assertThat(stale.expectedVersion).isEqualTo(WorkloadStateVersion(7)) + assertThat(stale.currentVersion).isEqualTo(WorkloadStateVersion(8)) + + val settled = store.find(identity.workloadId, identity.environmentId, identity.deploymentId) + assertThat(settled?.stateVersion).isEqualTo(WorkloadStateVersion(8)) + } + + @Test + fun `RETIRED is terminal through the command port and stays distinct from a stale expectation`() = + runBlocking { + commands.register(identity, fingerprint, metadata) + val retired = + commands.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.RETIRED, + ) + assertThat(retired).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) + val version = (retired as LifecycleTransitionOutcome.Applied).exposure.stateVersion + + // Current version, illegal target -> domain conflict, NOT a precondition failure. + val illegal = + commands.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + version, + WorkloadLifecycleState.ACTIVE, + ) + assertThat(illegal).isInstanceOf(LifecycleTransitionOutcome.InvalidTransition::class.java) + + // Stale expectation -> precondition failure, NOT a domain conflict. + val stale = + commands.transitionLifecycle( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + WorkloadStateVersion.INITIAL, + WorkloadLifecycleState.ACTIVE, + ) + assertThat(stale).isInstanceOf(LifecycleTransitionOutcome.Stale::class.java) + + val settled = store.find(identity.workloadId, identity.environmentId, identity.deploymentId) + assertThat(settled?.lifecycle).isEqualTo(WorkloadLifecycleState.RETIRED) + } + + // ── Read contract ─────────────────────────────────────────────── + + @Test + fun `authoritative read is classified as such and reports the authoritative version`() = + runBlocking { + val version = advanceTo(3) + + val read = queries.authoritative(identity.workloadId, identity.environmentId, identity.deploymentId) + + assertThat(read).isNotNull + assertThat(read!!.consistency).isEqualTo(QueryConsistency.AUTHORITATIVE) + assertThat(read.observedVersion).isEqualTo(version) + assertThat(read.exposure.stateVersion).isEqualTo(version) + } + + @Test + fun `projection read states its consistency class and the version it observed`() = + runBlocking { + val version = advanceTo(3) + + val read = queries.projection(identity.workloadId, identity.environmentId, identity.deploymentId) + + assertThat(read).isNotNull + assertThat(read!!.consistency).isEqualTo(QueryConsistency.PROJECTION) + assertThat(read.observedVersion).isEqualTo(version) + } + + @Test + fun `a lagging projection observation never authorizes a command`() = + runBlocking { + // Projection observed v16, then the authority moved on to v17. + val version16 = advanceTo(16) + val observed = queries.projection(identity.workloadId, identity.environmentId, identity.deploymentId) + assertThat(observed!!.observedVersion).isEqualTo(version16) + + val winner = + commands.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + version16, + WorkloadMetadata(owner = "Concurrent writer", purpose = "advance"), + ) + assertThat(winner).isInstanceOf(MetadataUpdateOutcome.Applied::class.java) + val current = (winner as MetadataUpdateOutcome.Applied).exposure.stateVersion + assertThat(current).isEqualTo(WorkloadStateVersion(17)) + + // The lagging observation is spent: conditioning a command on it fails closed and + // reports the current authority, so a client can re-read and reconcile. + val late = + commands.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + observed.observedVersion, + WorkloadMetadata(owner = "Late writer", purpose = "stale"), + ) + + assertThat(late).isInstanceOf(MetadataUpdateOutcome.Stale::class.java) + (late as MetadataUpdateOutcome.Stale).let { + assertThat(it.expectedVersion).isEqualTo(WorkloadStateVersion(16)) + assertThat(it.currentVersion).isEqualTo(WorkloadStateVersion(17)) + } + val settled = store.find(identity.workloadId, identity.environmentId, identity.deploymentId) + assertThat(settled?.metadata?.owner).isNotEqualTo("Late writer") + } + + @Test + fun `absence is null on the read path, not an error`() = + runBlocking { + val authoritative = + queries.authoritative(identity.workloadId, identity.environmentId, identity.deploymentId) + assertThat(authoritative).isNull() + assertThat(queries.projection(identity.workloadId, identity.environmentId, identity.deploymentId)).isNull() + } + + // ── Structural guarantees ─────────────────────────────────────── + + @Test + fun `the read port exposes no operation that can mutate authoritative state`() { + val readOperations = + WorkloadControlPlaneQueries::class.java.methods + .map { it.name } + .toSet() + + assertThat(readOperations).contains("authoritative", "projection") + assertThat(readOperations) + .doesNotContainAnyElementsOf( + listOf("register", "updateMetadata", "transitionLifecycle", "compareAndSet", "create"), + ) + } + + @Test + fun `a command takes an explicit version, never a read record`() { + // The guarantee is about the RECORD, not about the version type. Commands take + // (identity fields, expectedVersion, payload), so a read result cannot be handed over as a + // mutation witness. The version a read observed may still be submitted as an explicit + // expected version — that is not the read becoming authoritative, because the authority + // re-reads and answers Stale unless its current version still equals it (pinned by + // `a lagging projection observation never authorizes a command`). + val commandParameters = + WorkloadControlPlaneCommands::class.java.methods + .flatMap { it.parameterTypes.toList() } + .toSet() + + assertThat(commandParameters).doesNotContain(RegisteredWorkload::class.java, ClassifiedRead::class.java) + assertThat(commandParameters).contains(WorkloadStateVersion::class.java) + } + + // ── helpers ───────────────────────────────────────────────────── + + /** Registers, then advances the authoritative version to [target] with distinct metadata. */ + private suspend fun advanceTo(target: Long): WorkloadStateVersion { + commands.register(identity, fingerprint, metadata) + var version = WorkloadStateVersion.INITIAL + while (version.value < target) { + val outcome = + commands.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + version, + WorkloadMetadata(owner = "owner-${version.value}", purpose = "advance"), + ) + version = (outcome as MetadataUpdateOutcome.Applied).exposure.stateVersion + } + return version + } +} diff --git a/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadExposureModelTest.kt b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadExposureModelTest.kt new file mode 100644 index 000000000..bc873a4b9 --- /dev/null +++ b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadExposureModelTest.kt @@ -0,0 +1,315 @@ +package dev.tramai.controlplane + +import dev.tramai.controlplane.testing.WorkloadRegistrationFixtures +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.identity.WorkloadMetadata +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalArgumentException +import org.junit.jupiter.api.Test +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.lang.reflect.ParameterizedType + +class WorkloadExposureModelTest { + @Test + fun `exposure has exactly the approved four properties`() { + assertThat(getters(WorkloadExposure::class.java)).isEqualTo( + mapOf( + "identity" to WorkloadDeploymentIdentity::class.java, + "metadata" to WorkloadMetadata::class.java, + "lifecycle" to WorkloadLifecycleState::class.java, + "stateVersion" to WorkloadStateVersion::class.java, + ), + ) + } + + @Test + fun `mapper excludes the authority fingerprint`() { + val record = + WorkloadRegistrationFixtures.registration( + configurationFingerprint = ConfigurationFingerprint("sha256:SENTINEL-0.7.1f-FINGERPRINT"), + ) + val exposure = WorkloadExposure.from(record) + + assertThat(exposure.toString()).doesNotContain("SENTINEL-0.7.1f-FINGERPRINT") + assertThat(WorkloadExposure::class.java.declaredFields) + .noneMatch { ConfigurationFingerprint::class.java.isAssignableFrom(it.type) } + } + + @Test + fun `query and command type graphs are exact and safe`() { + val output = reachableTypes(*ports, output = true) + val input = reachableTypes(*ports, output = false) + assertPortTypes(output, input) + } + + private val ports = arrayOf(WorkloadControlPlaneQueries::class.java, WorkloadControlPlaneCommands::class.java) + + /** The only packages a type reachable from the generic control-plane contract may live in. */ + private val approvedPackages = setOf("dev.tramai.controlplane", "dev.tramai.core.identity") + + private val approvedOutputTypes = + setOf( + ClassifiedRead::class.java, + WorkloadExposure::class.java, + WorkloadDeploymentIdentity::class.java, + WorkloadConfigurationIdentity::class.java, + WorkloadId::class.java, + ConfigurationId::class.java, + ConfigurationVersion::class.java, + EnvironmentId::class.java, + DeploymentId::class.java, + WorkloadMetadata::class.java, + WorkloadLifecycleState::class.java, + WorkloadStateVersion::class.java, + QueryConsistency::class.java, + RegisterOutcome::class.java, + RegisterOutcome.Created::class.java, + RegisterOutcome.AlreadyRegistered::class.java, + RegisterOutcome.Rejected::class.java, + RegistrationConflictReason::class.java, + MetadataUpdateOutcome::class.java, + MetadataUpdateOutcome.Applied::class.java, + MetadataUpdateOutcome.Unchanged::class.java, + MetadataUpdateOutcome.Stale::class.java, + MetadataUpdateOutcome.NotFound::class.java, + LifecycleTransitionOutcome::class.java, + LifecycleTransitionOutcome.Applied::class.java, + LifecycleTransitionOutcome.Unchanged::class.java, + LifecycleTransitionOutcome.InvalidTransition::class.java, + LifecycleTransitionOutcome.Stale::class.java, + LifecycleTransitionOutcome.NotFound::class.java, + ) + + private val approvedInputTypes = + setOf( + WorkloadDeploymentIdentity::class.java, + WorkloadConfigurationIdentity::class.java, + WorkloadId::class.java, + ConfigurationId::class.java, + ConfigurationVersion::class.java, + EnvironmentId::class.java, + DeploymentId::class.java, + ConfigurationFingerprint::class.java, + WorkloadMetadata::class.java, + WorkloadStateVersion::class.java, + WorkloadLifecycleState::class.java, + ) + + private fun assertPortTypes( + output: Set>, + input: Set>, + ) { + assertThat(output).isEqualTo(approvedOutputTypes) + assertThat(input).isEqualTo(approvedInputTypes) + assertThat(output).doesNotContain(RegisteredWorkload::class.java, ConfigurationFingerprint::class.java) + assertThat(input).doesNotContain( + RegisteredWorkload::class.java, + Map::class.java, + Any::class.java, + ) + (output + input).forEach { type -> + assertThat(getters(type).values).noneMatch { + it == Any::class.java || Map::class.java.isAssignableFrom(it) || + it.simpleName == "JsonNode" + } + } + } + + @Test + fun `port signatures expose no forbidden extension type`() { + // The type-graph walk below is deliberately scoped to the control-plane and core-identity + // packages, so a forbidden type (Map / Any / Object / JsonNode) reaching a port SIGNATURE would be + // filtered out of the walk before any assertion could see it. Assert the raw signatures directly + // instead of trusting a walk that drops exactly the types this guard exists to catch. + val raw = + ports + .flatMap { port -> + port.methods.flatMap { method -> + method.parameterTypes + .filterNot { it.name == "kotlin.coroutines.Continuation" } + .toList() + listOfNotNull(suspendReturn(method)) + } + }.toSet() + + assertThat(raw).isNotEmpty() + raw.forEach { type -> + assertThat(type) + .describedAs("port signature must not carry %s", type.name) + .isNotEqualTo(Any::class.java) + assertThat(Map::class.java.isAssignableFrom(type)) + .describedAs("port signature must not carry %s", type.name) + .isFalse() + assertThat(type.simpleName) + .describedAs("port signature must not carry %s", type.name) + .isNotEqualTo("JsonNode") + assertThat(type.packageName) + .describedAs("port signature type %s must live in an approved package", type.name) + .isIn(approvedPackages) + } + } + + @Test + fun `classified read exposes safe model and rejects version contradiction`() { + val exposure = WorkloadExposure.from(WorkloadRegistrationFixtures.registration()) + assertThat(ClassifiedRead(exposure, QueryConsistency.AUTHORITATIVE, exposure.stateVersion).exposure) + .isEqualTo(exposure) + assertThatIllegalArgumentException().isThrownBy { + ClassifiedRead( + exposure, + QueryConsistency.AUTHORITATIVE, + WorkloadStateVersion(exposure.stateVersion.value + 1), + ) + } + } + + @Test + fun `metadata has exactly owner and purpose`() { + assertThat(getters(WorkloadMetadata::class.java)).isEqualTo( + mapOf("owner" to String::class.java, "purpose" to String::class.java), + ) + } + + @Test + fun `authoritative and projection reads expose identical safe shape`() { + runBlocking { + val store = InMemoryWorkloadRegistrationStore() + val authority = WorkloadRegistrationAuthority(store) + val record = WorkloadRegistrationFixtures.registration() + store.create(record) + val authoritative = + authority.authoritative( + record.identity.workloadId, + record.identity.environmentId, + record.identity.deploymentId, + ) + val projection = + authority.projection( + record.identity.workloadId, + record.identity.environmentId, + record.identity.deploymentId, + ) + assertThat(authoritative!!.exposure).isEqualTo(projection!!.exposure) + assertThat(authoritative.exposure::class.java).isEqualTo(WorkloadExposure::class.java) + } + } + + @Test + fun `read consistency absence and projection lag semantics remain intact`() { + runBlocking { + val store = InMemoryWorkloadRegistrationStore() + val authority = WorkloadRegistrationAuthority(store) + val identity = WorkloadRegistrationFixtures.identity(deployment = "exposure-lag") + assertThat( + authority.authoritative(identity.workloadId, identity.environmentId, identity.deploymentId), + ).isNull() + authority.register( + identity, + WorkloadRegistrationFixtures.fingerprint(), + WorkloadRegistrationFixtures.metadata(), + ) + val observed = + authority.projection(identity.workloadId, identity.environmentId, identity.deploymentId)!! + val applied = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + observed.observedVersion, + WorkloadMetadata("new-owner", "purpose"), + ) + assertThat(applied).isInstanceOf(MetadataUpdateOutcome.Applied::class.java) + val late = + authority.updateMetadata( + identity.workloadId, + identity.environmentId, + identity.deploymentId, + observed.observedVersion, + WorkloadMetadata("late", "purpose"), + ) + assertThat(late).isInstanceOf(MetadataUpdateOutcome.Stale::class.java) + assertThat(observed.consistency).isEqualTo(QueryConsistency.PROJECTION) + } + } + + private fun reachableTypes( + vararg ports: Class<*>, + output: Boolean, + ): Set> { + val seen = linkedSetOf>() + val queue = ArrayDeque>() + ports.flatMap { it.methods.toList() }.forEach { method -> + if (output) { + // Every port method is a suspend fun whose declared return type survives only in the + // synthetic Continuation parameter's generic signature. If it cannot be recovered the + // walk would silently skip that method, so fail loudly instead of falling back to the + // erased `Object` return type (review finding on the 0.7.1f discriminators). + queue.add( + suspendReturn(method) + ?: error( + "port method '${method.name}' has no recoverable suspend return type; " + + "the type-graph walk would silently skip it", + ), + ) + } else { + method.parameterTypes.filterNot { it.name == "kotlin.coroutines.Continuation" }.forEach(queue::add) + } + } + while (queue.isNotEmpty()) { + val type = queue.removeFirst() + if (!isTerminalScalar(type) && seen.add(type)) { + // Fail closed on everything that is not an approved contract type. Silently dropping an + // out-of-package type would satisfy the exact-set assertions while hiding exactly what they + // exist to catch: a nested getter of, say, java.sql.ResultSet on an outcome payload. + require(type.packageName in approvedPackages) { + "forbidden type reachable from the control-plane contract: ${type.name}" + } + if (type.simpleName.endsWith("Outcome") || type.simpleName.endsWith("Result")) { + type.declaredClasses.forEach(queue::add) + } + getters(type).values.forEach(queue::add) + } + } + return seen + } + + /** + * Types a contract property may legitimately terminate in: primitives (a non-null Kotlin `Long` + * compiles to `long`) and `String`. Anything else must be an approved contract type — the walk + * refuses to guess, so a new scalar has to be admitted here deliberately. + */ + private fun isTerminalScalar(type: Class<*>): Boolean = type.isPrimitive || type == String::class.java + + private fun suspendReturn(method: Method): Class<*>? = + method.genericParameterTypes + .lastOrNull() + ?.let { it as? ParameterizedType } + ?.actualTypeArguments + ?.singleOrNull() + ?.let { (it as? java.lang.reflect.WildcardType)?.lowerBounds?.singleOrNull() ?: it } + ?.let { it as? Class<*> } + + private fun getters(type: Class<*>): Map> = + type.declaredMethods + .filter { + // Instance accessors only: a static accessor (for example the enum `entries` getter the + // Kotlin compiler emits) is not a property of a contract value and would drag unrelated + // JDK/Kotlin types into the reachable set. + !Modifier.isStatic(it.modifiers) && + it.parameterCount == 0 && + (it.name.startsWith("get") || it.name.startsWith("is")) && + it.name !in setOf("getClass") + }.associate { + it.name + .removePrefix("get") + .removePrefix("is") + .replaceFirstChar(Char::lowercase) to it.returnType + } +} diff --git a/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt index 4b2714170..4ffea74ad 100644 --- a/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt +++ b/tramai-control-plane/src/test/kotlin/dev/tramai/controlplane/WorkloadRegistrationAuthorityTest.kt @@ -42,7 +42,7 @@ class WorkloadRegistrationAuthorityTest { val outcome = authority.register(identity, fingerprint, metadata) assertThat(outcome).isInstanceOf(RegisterOutcome.Created::class.java) - val created = (outcome as RegisterOutcome.Created).registration + val created = (outcome as RegisterOutcome.Created).exposure assertThat(created.lifecycle).isEqualTo(WorkloadLifecycleState.ACTIVE) assertThat(created.stateVersion).isEqualTo(WorkloadStateVersion.INITIAL) } @@ -55,7 +55,7 @@ class WorkloadRegistrationAuthorityTest { val again = authority.register(identity, fingerprint, metadata) assertThat(again).isInstanceOf(RegisterOutcome.AlreadyRegistered::class.java) - val existing = (again as RegisterOutcome.AlreadyRegistered).registration + val existing = (again as RegisterOutcome.AlreadyRegistered).exposure assertThat(existing.stateVersion).isEqualTo(WorkloadStateVersion.INITIAL) assertThat(existing.lifecycle).isEqualTo(WorkloadLifecycleState.ACTIVE) } @@ -192,7 +192,7 @@ class WorkloadRegistrationAuthorityTest { ) assertThat(outcome).isInstanceOf(MetadataUpdateOutcome.Applied::class.java) - (outcome as MetadataUpdateOutcome.Applied).registration.let { updated -> + (outcome as MetadataUpdateOutcome.Applied).exposure.let { updated -> assertThat(updated.metadata).isEqualTo(newMetadata) assertThat(updated.identity).isEqualTo(identity) assertThat(updated.stateVersion).isEqualTo(WorkloadStateVersion(2)) @@ -278,7 +278,7 @@ class WorkloadRegistrationAuthorityTest { WorkloadLifecycleState.SUSPENDED, ) assertThat(suspendOutcome).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) - assertThat((suspendOutcome as LifecycleTransitionOutcome.Applied).registration.stateVersion) + assertThat((suspendOutcome as LifecycleTransitionOutcome.Applied).exposure.stateVersion) .isEqualTo(WorkloadStateVersion(2)) val resumeOutcome = @@ -290,7 +290,7 @@ class WorkloadRegistrationAuthorityTest { WorkloadLifecycleState.ACTIVE, ) assertThat(resumeOutcome).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) - assertThat((resumeOutcome as LifecycleTransitionOutcome.Applied).registration.stateVersion) + assertThat((resumeOutcome as LifecycleTransitionOutcome.Applied).exposure.stateVersion) .isEqualTo(WorkloadStateVersion(3)) assertThat(current()?.lifecycle).isEqualTo(WorkloadLifecycleState.ACTIVE) } diff --git a/tramai-core/api/tramai-core.api b/tramai-core/api/tramai-core.api index 7f2da4677..e770ef8b5 100644 --- a/tramai-core/api/tramai-core.api +++ b/tramai-core/api/tramai-core.api @@ -916,6 +916,10 @@ public final class dev/tramai/core/exception/ConfigurationException : dev/tramai public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } +public final class dev/tramai/core/exception/GovernedRunContinuityException : java/lang/RuntimeException { + public fun (Ljava/lang/String;)V +} + public final class dev/tramai/core/exception/IllegalApprovalTransitionException : dev/tramai/core/exception/ApprovalException { public fun (Ljava/lang/String;Ldev/tramai/core/approval/ApprovalStatus;Ldev/tramai/core/approval/ApprovalStatus;Ljava/lang/String;)V public final fun getApprovalId ()Ljava/lang/String; diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/exception/GovernedRunContinuityException.kt b/tramai-core/src/main/kotlin/dev/tramai/core/exception/GovernedRunContinuityException.kt new file mode 100644 index 000000000..71db86be5 --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/exception/GovernedRunContinuityException.kt @@ -0,0 +1,17 @@ +package dev.tramai.core.exception + +/** + * Raised when a governed continuation cannot prove identity continuity (0.7.1d). + * + * A continuation RECOVERS identity from its durable witness; it never reconstructs or + * redefines it. This failure means the persisted witness and the execution asking to + * continue it disagree — for example a suspension created by an ungoverned execution + * being resumed from a governed one, or a governed suspension being resumed with a + * different workload/configuration/environment/deployment. + * + * Failing closed is the point: continuing with either identity would attribute one + * execution's work to another. + */ +class GovernedRunContinuityException( + message: String, +) : RuntimeException(message) diff --git a/tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunScope.kt b/tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunScope.kt new file mode 100644 index 000000000..f4b683cbb --- /dev/null +++ b/tramai-core/src/main/kotlin/dev/tramai/core/identity/GovernedRunScope.kt @@ -0,0 +1,80 @@ +package dev.tramai.core.identity + +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import kotlinx.coroutines.ThreadContextElement +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Execution-scoped TRANSPORT of the canonical [GovernedRunIdentity] (0.7.1d). + * + * Some subsystems are invoked from inside application code the framework does not own: + * an AI step's `invoke` lambda calls the engine, and the engine's invocation signature + * carries no run identity. Without a carrier the engine would mint its own workflow run + * id and one governed execution would end up with two competing run identities. + * + * This type is transport, never authority: + * + * - the framework installs it at the governed execution boundary; + * - consumers that must not invent identity read it and fail closed when it is absent; + * - it is never inferred, reconstructed or regenerated from runtime state — durable + * resume paths rebuild the identity from their persisted witness and install it. + * + * It is a [ThreadContextElement], so it also bridges the thread boundary that + * `runBlocking` creates for the blocking proxy path: [updateThreadContext] installs the + * identity for the duration of the coroutine on that thread and + * [restoreThreadContext] restores the previous value — including on cancellation and + * exceptions, which is why the bridge is implemented through the sanctioned mechanism + * instead of a hand-managed ThreadLocal. Nested and sequential executions therefore + * cannot leak identity into each other, and parallel executions on different threads + * have independent bridges. + * + * Invariant: [identity] is the identity of the governed execution the current call + * belongs to. Marked with the internal API marker on purpose: it is a plumbing type, + * not application vocabulary. + */ +@ExperimentalTramaiInternalApi +class GovernedRunScope( + val identity: GovernedRunIdentity, +) : AbstractCoroutineContextElement(GovernedRunScope), + ThreadContextElement { + override fun updateThreadContext(context: CoroutineContext): GovernedRunIdentity? = + Key.threadBridge.get().also { Key.threadBridge.set(identity) } + + override fun restoreThreadContext( + context: CoroutineContext, + oldState: GovernedRunIdentity?, + ) { + if (oldState == null) Key.threadBridge.remove() else Key.threadBridge.set(oldState) + } + + /** Correlation-key for [CoroutineContext] lookup. */ + @ExperimentalTramaiInternalApi + companion object Key : CoroutineContext.Key { + private val threadBridge: ThreadLocal = ThreadLocal() + + /** + * The identity in force for the current call: the coroutine context is + * authoritative, and the thread bridge is the transport fallback that covers + * call frames that are not coroutines (the blocking proxy path). + * + * Returns null — never a regenerated identity — when no governed execution is in + * force. + */ + fun resolve(context: CoroutineContext): GovernedRunIdentity? = + run { + val identity = contextIdentity(context) + identity ?: threadBridge.get() + } + + private fun contextIdentity(context: CoroutineContext) = context[GovernedRunScope]?.identity + + /** + * Identity of the governed execution currently running on THIS thread, if any. + * + * Used by the invoking adapter to capture attribution before it starts its own + * `runBlocking` context, and by tests to prove the bridge does not leak. + */ + fun currentThreadIdentity(): GovernedRunIdentity? = threadBridge.get() + } +} diff --git a/tramai-engine/api/tramai-engine.api b/tramai-engine/api/tramai-engine.api index 8ed09cdb1..0746a96f9 100644 --- a/tramai-engine/api/tramai-engine.api +++ b/tramai-engine/api/tramai-engine.api @@ -113,6 +113,28 @@ public final class dev/tramai/engine/ExecutionSecurityContext$Companion { public final fun fromArguments ([Ljava/lang/Object;)Ldev/tramai/engine/ExecutionSecurityContext; } +public final class dev/tramai/engine/GovernedSuspendedInvocation { + public fun (Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/identity/GovernedRunIdentity;)V + public final fun component1 ()Ldev/tramai/engine/SuspendedInvocationMetadata; + public final fun component2 ()Ldev/tramai/core/identity/GovernedRunIdentity; + public final fun copy (Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/identity/GovernedRunIdentity;)Ldev/tramai/engine/GovernedSuspendedInvocation; + public static synthetic fun copy$default (Ldev/tramai/engine/GovernedSuspendedInvocation;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/core/identity/GovernedRunIdentity;ILjava/lang/Object;)Ldev/tramai/engine/GovernedSuspendedInvocation; + public fun equals (Ljava/lang/Object;)Z + public final fun getMetadata ()Ldev/tramai/engine/SuspendedInvocationMetadata; + public final fun getRunIdentity ()Ldev/tramai/core/identity/GovernedRunIdentity; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/tramai/engine/GovernedSuspendedInvocationStore : dev/tramai/engine/SuspendedInvocationStore { + public abstract fun createGoverned (Ldev/tramai/engine/GovernedSuspendedInvocation;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun governedRunIdentity (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/tramai/engine/GovernedSuspendedInvocationStore$DefaultImpls { + public static fun revealSensitiveContext (Ldev/tramai/engine/GovernedSuspendedInvocationStore;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/engine/InMemoryOperationResponseCache : dev/tramai/engine/OperationResponseCache { public fun ()V public fun (ILkotlin/jvm/functions/Function0;)V @@ -497,6 +519,24 @@ public final class dev/tramai/engine/TramaiEngine : java/lang/AutoCloseable { public final fun resumeApproval (Ldev/tramai/engine/ResumeApprovalCommand;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class dev/tramai/engine/approval/ApprovalAttributionCollisionException : java/lang/IllegalArgumentException { + public fun (Ljava/lang/String;)V +} + +public final class dev/tramai/engine/approval/ApprovalAttributionCorruptionException : java/lang/IllegalStateException { + public fun (Ljava/lang/String;)V +} + +public final class dev/tramai/engine/approval/ApprovalAttributionKeys { + public static final field CONFIGURATION Ljava/lang/String; + public static final field CONFIGURATION_VERSION Ljava/lang/String; + public static final field DEPLOYMENT Ljava/lang/String; + public static final field ENVIRONMENT Ljava/lang/String; + public static final field INSTANCE Ldev/tramai/engine/approval/ApprovalAttributionKeys; + public static final field WORKLOAD Ljava/lang/String; + public final fun getALL ()Ljava/util/List; +} + public final class dev/tramai/engine/approval/ApprovalGatewayPersistenceRequest { public synthetic fun (Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/core/approval/ApprovalContinuation;Ldev/tramai/core/approval/SensitiveToolArguments;Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ldev/tramai/core/approval/ApprovalRequest; @@ -522,12 +562,48 @@ public abstract interface class dev/tramai/engine/approval/ApprovalGatewayReques public abstract fun createRequest-Atj0Sqo (Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class dev/tramai/engine/approval/ApprovalRunAttribution { +} + +public final class dev/tramai/engine/approval/ApprovalRunAttribution$Governed : dev/tramai/engine/approval/ApprovalRunAttribution { + public fun (Ldev/tramai/core/identity/GovernedRunIdentity;)V + public final fun component1 ()Ldev/tramai/core/identity/GovernedRunIdentity; + public final fun copy (Ldev/tramai/core/identity/GovernedRunIdentity;)Ldev/tramai/engine/approval/ApprovalRunAttribution$Governed; + public static synthetic fun copy$default (Ldev/tramai/engine/approval/ApprovalRunAttribution$Governed;Ldev/tramai/core/identity/GovernedRunIdentity;ILjava/lang/Object;)Ldev/tramai/engine/approval/ApprovalRunAttribution$Governed; + public fun equals (Ljava/lang/Object;)Z + public final fun getIdentity ()Ldev/tramai/core/identity/GovernedRunIdentity; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/engine/approval/ApprovalRunAttribution$Ungoverned : dev/tramai/engine/approval/ApprovalRunAttribution { + public static final field INSTANCE Ldev/tramai/engine/approval/ApprovalRunAttribution$Ungoverned; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/engine/approval/ApprovalRunAttributionKt { + public static final fun decodeApprovalAttribution (Ljava/lang/String;Ljava/util/Map;)Ldev/tramai/engine/approval/ApprovalRunAttribution; + public static final fun encodeApprovalAttribution (Ldev/tramai/core/identity/GovernedRunIdentity;)Ljava/util/Map; + public static final fun mergeApprovalAttribution (Ljava/util/Map;Ldev/tramai/engine/approval/ApprovalRunAttribution;)Ljava/util/Map; +} + public final class dev/tramai/engine/approval/DefaultApprovalGateway : dev/tramai/core/approval/gateway/ApprovalGateway { public fun (Ldev/tramai/core/approval/ApprovalStore;Ldev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/engine/SuspendedInvocationStore;Ldev/tramai/engine/approval/ApprovalGatewayRequestFactory;Ljava/time/Clock;)V public synthetic fun (Ldev/tramai/core/approval/ApprovalStore;Ldev/tramai/core/approval/ApprovalContinuationStore;Ldev/tramai/engine/SuspendedInvocationStore;Ldev/tramai/engine/approval/ApprovalGatewayRequestFactory;Ljava/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun requestApproval-Atj0Sqo (Ljava/lang/String;Ldev/tramai/core/approval/gateway/ApprovalRecommendation;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class dev/tramai/engine/approval/GovernedApprovalStore : dev/tramai/core/approval/ApprovalStore { + public abstract fun attributionOf (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun createGovernedApproval (Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution$Governed;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/tramai/engine/approval/GovernedApprovalStoreKt { + public static final fun requireAttributionMatchesBinding (Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution;)V +} + public final class dev/tramai/engine/evidence/ProviderRouteDecisionEvidenceSource { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/time/Instant;Ldev/tramai/engine/evidence/ProviderRouteDecisionKind;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/time/Instant;Ldev/tramai/engine/evidence/ProviderRouteDecisionKind;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/InMemorySuspendedInvocationStore.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/InMemorySuspendedInvocationStore.kt index f5444c356..5cf4e30d8 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/InMemorySuspendedInvocationStore.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/InMemorySuspendedInvocationStore.kt @@ -1,5 +1,6 @@ package dev.tramai.engine +import dev.tramai.core.identity.GovernedRunIdentity import java.util.concurrent.ConcurrentHashMap /** @@ -17,11 +18,13 @@ import java.util.concurrent.ConcurrentHashMap * toolName mismatch) and a [SuspendedInvocationMetadata.replayEnvelopeDigest] * that does not match the canonical digest of the envelope messages. */ -internal class InMemorySuspendedInvocationStore : SuspendedInvocationStore { - +internal class InMemorySuspendedInvocationStore : + SuspendedInvocationStore, + GovernedSuspendedInvocationStore { private data class StoredSuspendedInvocation( val metadata: SuspendedInvocationMetadata, val replayEnvelope: SensitiveReplayEnvelope, + val runIdentity: GovernedRunIdentity? = null, ) private val invocations = ConcurrentHashMap() @@ -29,12 +32,34 @@ internal class InMemorySuspendedInvocationStore : SuspendedInvocationStore { override suspend fun create( metadata: SuspendedInvocationMetadata, replayEnvelope: SensitiveReplayEnvelope, + ) = createInternal(metadata, replayEnvelope, runIdentity = null) + + /** One logical mutation: metadata, replay envelope and identity are stored together. */ + override suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) = createInternal(suspended.metadata, replayEnvelope, suspended.runIdentity) + + override suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? { + validateIdField(approvalId, "approvalId") + return invocations[approvalId]?.runIdentity + } + + private suspend fun createInternal( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + runIdentity: GovernedRunIdentity?, ) { validateCreateInput(metadata, replayEnvelope) - val existing = invocations.putIfAbsent( - metadata.approvalId, - StoredSuspendedInvocation(metadata = metadata, replayEnvelope = replayEnvelope), - ) + val existing = + invocations.putIfAbsent( + metadata.approvalId, + StoredSuspendedInvocation( + metadata = metadata, + replayEnvelope = replayEnvelope, + runIdentity = runIdentity, + ), + ) require(existing == null) { "Suspended invocation with approvalId '${metadata.approvalId}' already exists" } @@ -73,7 +98,10 @@ internal class InMemorySuspendedInvocationStore : SuspendedInvocationStore { } } - private fun validateIdField(value: String, fieldName: String) { + private fun validateIdField( + value: String, + fieldName: String, + ) { require(value.isNotBlank()) { "$fieldName must not be blank" } require(value.none { it.isISOControl() }) { "$fieldName must not contain control characters" } require(value.length <= 256) { "$fieldName exceeds maximum length of 256" } @@ -81,5 +109,4 @@ internal class InMemorySuspendedInvocationStore : SuspendedInvocationStore { } } -fun inMemorySuspendedInvocationStore(): SuspendedInvocationStore = - InMemorySuspendedInvocationStore() +fun inMemorySuspendedInvocationStore(): SuspendedInvocationStore = InMemorySuspendedInvocationStore() diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/SuspendedInvocationStore.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/SuspendedInvocationStore.kt index 363a9f7f9..cf1c26d9e 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/SuspendedInvocationStore.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/SuspendedInvocationStore.kt @@ -1,11 +1,65 @@ package dev.tramai.engine import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.identity.GovernedRunIdentity import dev.tramai.core.model.Message -import dev.tramai.core.model.ToolCall import dev.tramai.core.model.ResolvedTool +import dev.tramai.core.model.ToolCall import dev.tramai.core.policy.ToolSecurityMetadata +/** + * One durable GOVERNED suspension (0.7.1d): the existing suspension record plus the + * canonical run identity of the execution that created it. + * + * The engine identity and the canonical run identity must agree — a suspension whose + * engine identity says R1 while its canonical run identity says R2 is corruption, not a + * binding mismatch, so the invariant is checked here (and again when the record is + * decoded from durable storage). + * + * The persisted identity is the AUTHORITY for a standalone continuation. A caller-supplied + * identity is only a consistency precondition: requiring the caller to re-supply identity + * would create a second source that must then be reconciled against this one. + */ +data class GovernedSuspendedInvocation( + val metadata: SuspendedInvocationMetadata, + val runIdentity: GovernedRunIdentity, +) { + init { + require(runIdentity.runId.value == metadata.identity.workflowRunId) { + "Governed suspension identity is inconsistent: engine workflowRunId " + + "'${metadata.identity.workflowRunId}' != canonical runId '${runIdentity.runId.value}'" + } + } +} + +/** + * Additive durable capability: governed approval suspensions (0.7.1d). + * + * Deliberately a separate interface rather than new methods on [SuspendedInvocationStore]: + * third parties implement that SPI, and adding a required method would break them. A + * governed suspension therefore REQUIRES this capability and fails closed when the + * configured store does not provide it — before any approval or continuation state is + * durably created. The degradation this prevents is "looks governed until the first + * approval boundary, then silently continues with run-id-only semantics". + * + * Implementations must persist [GovernedSuspendedInvocation] and its replay envelope as + * ONE durable record (one encrypted file write, one JDBC row/transaction, one logical + * in-memory mutation): two independently written records could leave a governed + * suspension carrying only partial attribution after a crash. + */ +interface GovernedSuspendedInvocationStore : SuspendedInvocationStore { + suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) + + /** + * Canonical run identity of an existing governed suspension, or null when the record + * is a legacy (ungoverned) suspension. + */ + suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? +} + /** * Snapshot of token budget tracker state at the point of suspension. */ diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalResumeCoordinator.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalResumeCoordinator.kt index 338ba35ff..0cbf3c59d 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalResumeCoordinator.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalResumeCoordinator.kt @@ -1,16 +1,45 @@ @file:OptIn(ExperimentalTramaiInternalApi::class) -package dev.tramai.engine.approval +package dev.tramai.engine.approval -import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi -import dev.tramai.core.approval.* +import dev.tramai.core.approval.ApprovalAuthorization +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.ClaimedApprovalContinuation +import dev.tramai.core.approval.CreateApprovalCommand +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.ToolArgumentsDigester import dev.tramai.core.coroutines.rethrowIfCancellation -import dev.tramai.core.exception.* +import dev.tramai.core.exception.ApprovalNotFoundException +import dev.tramai.core.exception.ApprovalTokenRejectedException +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.exception.NestedApprovalNotSupportedException +import dev.tramai.core.exception.StructuredOutputException +import dev.tramai.core.identity.GovernedRunScope import dev.tramai.core.model.ResolvedTool -import dev.tramai.engine.* -import kotlinx.coroutines.CancellationException +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.core.observation.secondary.SecondaryEffectAuthority import dev.tramai.core.observation.secondary.SecondaryFailureDiagnostic +import dev.tramai.engine.EngineEventObserver +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.ReplayEnvelopeDigestHelper +import dev.tramai.engine.ReplayEnvelopeFactory +import dev.tramai.engine.ReplayPayload +import dev.tramai.engine.ResumeApprovalCommand +import dev.tramai.engine.ResumeToolDeclarationDigestHelper +import dev.tramai.engine.ResumeToolReference +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.ToolRegistry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext /** * Orchestrates the resume state machine for a suspended approval. @@ -33,52 +62,115 @@ internal class ApprovalResumeCoordinator( private val authorizationService: ReplayAuthorizationService, ) { suspend fun resume(command: ResumeApprovalCommand): Any? { - val store = requireApprovalContinuationStore() - val continuationSnapshot = store.get(command.approvalId) - - if ( - continuationSnapshot != null && - continuationSnapshot.status == ApprovalContinuationStatus.COMPLETED - ) { - throw ApprovalTokenRejectedException(command.approvalId) - } - - val metadata = suspendedInvocationStore.get(command.approvalId) - ?: throw ApprovalNotFoundException(command.approvalId) - val registered = resumeOperationRegistry.resolve(metadata.operationReference) - val existingContinuation = claimService.loadPendingForResume(store, command, metadata, registered) - val resolvedTool = resolveAndValidateResumeTool(command, metadata, existingContinuation) - - authorizationService.validateToken(command, metadata, existingContinuation) - when (val decision = authorizationService.decideResumePolicy(command, metadata, resolvedTool)) { - is ReplayAuthorizationDecision.Denied -> authorizationService.denyAndCancel(command, metadata, decision.decision, store) - ReplayAuthorizationDecision.RequiresNestedApproval -> authorizationService.cancelForNestedApproval(command, metadata, store) - is ReplayAuthorizationDecision.Allowed -> Unit - } - - val authorization = authorizationService.authorize(command, metadata, existingContinuation) - authorizationService.emitAuthorizationReplayed(authorization.replayed, command, metadata) + val prepared = prepareResume(command) + val authorization = authorizeResume(command, prepared) + authorizationService.emitAuthorizationReplayed(authorization.replayed, command, prepared.metadata) val claimed = claimService.claim(command.approvalId, command.continuationExpectedVersion, command.resumedBy) val uncertainOutcome = ResumeUncertainOutcome() - val context = ResumeExecutionContext(command, metadata, registered, resolvedTool, uncertainOutcome) + val context = + ResumeExecutionContext( + command, + prepared.metadata, + prepared.registered, + prepared.resolvedTool, + uncertainOutcome, + ) return try { - executeClaimedResume(context, claimed, store) + if (prepared.persistedGoverned == null) { + executeClaimedResume(context, claimed, prepared.store) + } else { + // Install the RECOVERED identity around the resumed execution, so the engine + // invocation that continues this run carries its canonical run id. + withContext(GovernedRunScope(prepared.persistedGoverned)) { + executeClaimedResume(context, claimed, prepared.store) + } + } } catch (e: NestedApprovalNotSupportedException) { - emitResumeUncertainOutcomeOnce(uncertainOutcome, command, metadata, "nested-approval-not-supported") + emitResumeUncertainOutcomeOnce( + uncertainOutcome, + command, + prepared.metadata, + "nested-approval-not-supported", + ) throw e } catch (e: StructuredOutputException) { - emitResumeUncertainOutcomeOnce(uncertainOutcome, command, metadata, "structured-parse-failed: ${e::class.simpleName ?: "unknown"}") + emitResumeUncertainOutcomeOnce( + uncertainOutcome, + command, + prepared.metadata, + "structured-parse-failed: ${e::class.simpleName ?: "unknown"}", + ) throw e } catch (e: CancellationException) { throw e } catch (e: Exception) { e.rethrowIfCancellation() - emitResumeUncertainOutcomeOnce(uncertainOutcome, command, metadata, "resume-failed: ${e::class.simpleName ?: "unknown"}") + emitResumeUncertainOutcomeOnce( + uncertainOutcome, + command, + prepared.metadata, + "resume-failed: ${e::class.simpleName ?: "unknown"}", + ) throw e } } + private suspend fun prepareResume(command: ResumeApprovalCommand): ResumePreparation { + val store = requireApprovalContinuationStore() + val continuationSnapshot = store.get(command.approvalId) + if (continuationSnapshot != null && continuationSnapshot.status == ApprovalContinuationStatus.COMPLETED) { + throw ApprovalTokenRejectedException(command.approvalId) + } + val metadata = + suspendedInvocationStore.get(command.approvalId) + ?: throw ApprovalNotFoundException(command.approvalId) + // 0.7.1d: recover the persisted identity before claim or execution. + val persistedGoverned = + (suspendedInvocationStore as? GovernedSuspendedInvocationStore) + ?.governedRunIdentity(command.approvalId) + requireGovernedContinuity( + approvalId = command.approvalId, + persisted = persistedGoverned, + requested = GovernedRunScope.resolve(currentCoroutineContext()), + ) + val registered = resumeOperationRegistry.resolve(metadata.operationReference) + val continuation = claimService.loadPendingForResume(store, command, metadata, registered) + val resolvedTool = resolveAndValidateResumeTool(command, metadata, continuation) + authorizationService.validateToken(command, metadata, continuation) + return ResumePreparation(store, metadata, persistedGoverned, registered, continuation, resolvedTool) + } + + private suspend fun authorizeResume( + command: ResumeApprovalCommand, + prepared: ResumePreparation, + ): ApprovalAuthorization { + val decision = authorizationService.decideResumePolicy(command, prepared.metadata, prepared.resolvedTool) + when (decision) { + is ReplayAuthorizationDecision.Denied -> { + authorizationService.denyAndCancel(command, prepared.metadata, decision.decision, prepared.store) + } + + ReplayAuthorizationDecision.RequiresNestedApproval -> { + authorizationService.cancelForNestedApproval(command, prepared.metadata, prepared.store) + } + + is ReplayAuthorizationDecision.Allowed -> { + Unit + } + } + return authorizationService.authorize(command, prepared.metadata, prepared.continuation) + } + + private data class ResumePreparation( + val store: ApprovalContinuationStore, + val metadata: SuspendedInvocationMetadata, + val persistedGoverned: dev.tramai.core.identity.GovernedRunIdentity?, + val registered: RegisteredResumeOperation, + val continuation: ApprovalContinuation, + val resolvedTool: ResolvedTool, + ) + private suspend fun executeClaimedResume( context: ResumeExecutionContext, claimed: ClaimedApprovalContinuation, @@ -87,26 +179,32 @@ internal class ApprovalResumeCoordinator( val command = context.command val metadata = context.metadata val replayPayload = revealAndValidateReplayPayload(context.uncertainOutcome, command, metadata) - val expectedArgsDigest = validateClaimedResumeArguments( - context.uncertainOutcome, command, metadata, claimed, requireToolArgumentsDigester(), - ) + val expectedArgsDigest = + validateClaimedResumeArguments( + context.uncertainOutcome, + command, + metadata, + claimed, + requireToolArgumentsDigester(), + ) val validatedInput = claimed.arguments.reveal() val rehydratedPayload = ReplayEnvelopeFactory.rehydrateAfterClaim(replayPayload, metadata, validatedInput) val emitter: suspend (String) -> Unit = { reason -> emitResumeUncertainOutcomeOnce(context.uncertainOutcome, command, metadata, reason) } - val result = context.registered.resumeExecutor.execute( - ClaimedResumeExecutionRequest( - command, - metadata, - context.registered, - context.resolvedTool, - rehydratedPayload, - validatedInput, - expectedArgsDigest, - emitter, - ), - ) + val result = + context.registered.resumeExecutor.execute( + ClaimedResumeExecutionRequest( + command, + metadata, + context.registered, + context.resolvedTool, + rehydratedPayload, + validatedInput, + expectedArgsDigest, + emitter, + ), + ) completeClaimedResume(command, metadata, claimed, store) return result } @@ -116,8 +214,9 @@ internal class ApprovalResumeCoordinator( command: ResumeApprovalCommand, metadata: SuspendedInvocationMetadata, ): ReplayPayload { - val replayEnvelope = suspendedInvocationStore.revealReplayEnvelope(command.approvalId) - ?: throw ConfigurationException("replay-envelope-not-found") + val replayEnvelope = + suspendedInvocationStore.revealReplayEnvelope(command.approvalId) + ?: throw ConfigurationException("replay-envelope-not-found") val replayPayload = replayEnvelope.revealForResume() val actualDigest = ReplayEnvelopeDigestHelper.compute(metadata.operationReference, replayPayload.messages) if (actualDigest != metadata.replayEnvelopeDigest) { @@ -253,8 +352,9 @@ internal class ApprovalResumeCoordinator( metadata: SuspendedInvocationMetadata, continuation: ApprovalContinuation, ): ResolvedTool { - val resolvedTool = toolRegistry.resolve(metadata.toolName) - ?: throw ConfigurationException("approved-tool-not-registered") + val resolvedTool = + toolRegistry.resolve(metadata.toolName) + ?: throw ConfigurationException("approved-tool-not-registered") require(ResumeToolDeclarationDigestHelper.compute(resolvedTool) == metadata.toolReference.declarationDigest) { "resume-tool-declaration-drift" } diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalRunAttribution.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalRunAttribution.kt new file mode 100644 index 000000000..1168e16c2 --- /dev/null +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalRunAttribution.kt @@ -0,0 +1,169 @@ +package dev.tramai.engine.approval + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId + +/** + * Reserved framework metadata keys carrying governed-run attribution on an approval record (0.7.1d1). + * + * Deliberately NOT prefixed `tramai.`: the architecture guard (`RuntimeEventCatalogueArchitectureTest`) + * reserves that namespace for runtime identifiers and configuration properties, and a persisted + * metadata key is neither. + * + * The run id is deliberately NOT among them: `ApprovalBinding.workflowRunId` is the single stored + * run-id source, so persistence cannot hold a second identifier that diverges from the run it + * describes. + */ +public object ApprovalAttributionKeys { + public const val WORKLOAD: String = "approval.identity.workload" + public const val CONFIGURATION: String = "approval.identity.configuration" + public const val CONFIGURATION_VERSION: String = "approval.identity.configuration_version" + public const val ENVIRONMENT: String = "approval.identity.environment" + public const val DEPLOYMENT: String = "approval.identity.deployment" + + /** All reserved keys, in canonical order. */ + public val ALL: List = + listOf(WORKLOAD, CONFIGURATION, CONFIGURATION_VERSION, ENVIRONMENT, DEPLOYMENT) +} + +/** + * Attribution of an approval creation path (0.7.1d1). + * + * Resolved by the gateway from the canonical runtime identity, never supplied by the application: + * the factory produces an identity-blind [ApprovalGatewayPersistenceRequest] and the gateway attaches + * this value afterwards, so the "governed run silently becomes un-attributed" downgrade cannot be + * expressed by a caller. + */ +public sealed interface ApprovalRunAttribution { + /** Intentionally un-attributed (legacy) approval: zero reserved keys, byte-compatible. */ + public data object Ungoverned : ApprovalRunAttribution + + /** Governed approval: the canonical identity of the run being suspended. */ + public data class Governed( + val identity: GovernedRunIdentity, + ) : ApprovalRunAttribution +} + +/** Application input supplied a reserved framework attribution key. */ +public class ApprovalAttributionCollisionException( + message: String, +) : IllegalArgumentException(message) + +/** Persisted attribution is partial, blank or otherwise unusable. */ +public class ApprovalAttributionCorruptionException( + message: String, +) : IllegalStateException(message) + +/** + * Encodes the five remaining identity components. The run id is not encoded — it is read back from + * `ApprovalBinding.workflowRunId` at [decodeApprovalAttribution] time. + */ +public fun encodeApprovalAttribution(identity: GovernedRunIdentity): Map { + val deployment = identity.deployment + return mapOf( + ApprovalAttributionKeys.WORKLOAD to deployment.workloadId.value, + ApprovalAttributionKeys.CONFIGURATION to deployment.configuration.id.value, + ApprovalAttributionKeys.CONFIGURATION_VERSION to deployment.configuration.version.value, + ApprovalAttributionKeys.ENVIRONMENT to deployment.environmentId.value, + ApprovalAttributionKeys.DEPLOYMENT to deployment.deploymentId.value, + ) +} + +/** + * Decodes persisted approval attribution against the approval's own `workflowRunId`, which stays the + * authoritative run id. + * + * Fail closed: all reserved keys absent means an intentionally un-attributed (legacy) approval; a + * PARTIAL set, a blank value, or an unparseable value is corruption and throws rather than yielding + * a partially-attributed identity. + */ +public fun decodeApprovalAttribution( + workflowRunId: String, + metadata: Map, +): ApprovalRunAttribution { + // Presence and validity are separate equivalence classes. Collapsing them would read a set of + // present-but-blank reserved keys as "all absent" and downgrade malformed governed state into + // legacy state — the silent downgrade this parser exists to prevent. + val present = ApprovalAttributionKeys.ALL.filter(metadata::containsKey) + if (present.isEmpty()) return ApprovalRunAttribution.Ungoverned + + val expected = ApprovalAttributionKeys.ALL + val defect = + when { + present.size != expected.size -> { + "is incomplete: all of ${expected.joinToString()} must be present, or none" + } + + expected.any { metadata.getValue(it).isBlank() } -> { + "is malformed: a present reserved key must carry a non-blank value" + } + + else -> { + null + } + } + if (defect != null) { + throw ApprovalAttributionCorruptionException( + "Persisted approval attribution $defect for workflow run '$workflowRunId'", + ) + } + return try { + ApprovalRunAttribution.Governed( + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(metadata.component(ApprovalAttributionKeys.WORKLOAD)), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(metadata.component(ApprovalAttributionKeys.CONFIGURATION)), + version = + ConfigurationVersion( + metadata.component(ApprovalAttributionKeys.CONFIGURATION_VERSION), + ), + ), + environmentId = EnvironmentId(metadata.component(ApprovalAttributionKeys.ENVIRONMENT)), + deploymentId = DeploymentId(metadata.component(ApprovalAttributionKeys.DEPLOYMENT)), + ), + runId = RunId(workflowRunId), + ), + ) + } catch (error: IllegalArgumentException) { + throw ApprovalAttributionCorruptionException( + "Persisted approval attribution is invalid for workflow run '$workflowRunId': ${error.message}", + ).apply { initCause(error) } + } +} + +/** + * Merges framework attribution into the approval's stored metadata. + * + * Application metadata can never determine attribution: a reserved key in [applicationMetadata] is + * rejected as a collision rather than silently overwritten, so an attribution-injection attempt is + * observable instead of hidden. + */ +public fun mergeApprovalAttribution( + applicationMetadata: Map, + attribution: ApprovalRunAttribution, +): Map { + val collisions = applicationMetadata.keys.filter { it in ApprovalAttributionKeys.ALL } + if (collisions.isNotEmpty()) { + throw ApprovalAttributionCollisionException( + "Application approval metadata may not supply reserved governed attribution keys: " + + collisions.sorted().joinToString(), + ) + } + return when (attribution) { + ApprovalRunAttribution.Ungoverned -> applicationMetadata + is ApprovalRunAttribution.Governed -> applicationMetadata + encodeApprovalAttribution(attribution.identity) + } +} + +private fun Map.component(key: String): String = + requireNotNull(get(key)?.takeIf { it.isNotBlank() }) { "missing attribution component '$key'" } diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt index bff0f113a..8b9155442 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/ApprovalSuspensionCoordinator.kt @@ -1,17 +1,44 @@ -package dev.tramai.engine.approval +@file:OptIn(ExperimentalTramaiInternalApi::class) -import dev.tramai.core.approval.* +package dev.tramai.engine.approval +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.ClaimedApprovalContinuation +import dev.tramai.core.approval.CreateApprovalCommand +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.ToolArgumentsDigester import dev.tramai.core.coroutines.rethrowIfCancellation +import dev.tramai.core.exception.ApprovalNotFoundException import dev.tramai.core.exception.ApprovalSuspendedException +import dev.tramai.core.exception.ApprovalTokenRejectedException import dev.tramai.core.exception.ConfigurationException import dev.tramai.core.exception.NestedApprovalNotSupportedException -import dev.tramai.core.model.* +import dev.tramai.core.exception.StructuredOutputException +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.core.policy.PolicyDecision -import dev.tramai.engine.* +import dev.tramai.engine.EngineEventObserver +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.ReplayEnvelopeDigestHelper +import dev.tramai.engine.ReplayEnvelopeFactory +import dev.tramai.engine.ReplayPayload +import dev.tramai.engine.ResumeApprovalCommand +import dev.tramai.engine.ResumeToolDeclarationDigestHelper +import dev.tramai.engine.ResumeToolReference +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.ToolRegistry import dev.tramai.engine.planning.ServiceDefinition import dev.tramai.engine.tool.ToolApprovalGate import dev.tramai.engine.tool.ToolExecutionRequest import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext import java.time.Clock /** @@ -35,7 +62,6 @@ internal class ApprovalSuspensionCoordinator( private val clock: Clock, private val approvalLifecycleAuditEmitter: ApprovalLifecycleAuditEmitter, ) : ToolApprovalGate { - override suspend fun requireApproval( request: ToolExecutionRequest, policyDecision: PolicyDecision.RequireApproval, @@ -78,8 +104,9 @@ internal class ApprovalSuspensionCoordinator( ) } val requirement = policyDecision.requirement - val digester = toolArgumentsDigester - ?: throw ConfigurationException("ToolArgumentsDigester is required for renewed approval validation") + val digester = + toolArgumentsDigester + ?: throw ConfigurationException("ToolArgumentsDigester is required for renewed approval validation") val renewedDigest = digester.digest(SensitiveToolArguments.of(input)) require(requirement.toolName == request.tool.name) { "Renewed approval requirement tool name mismatch: '${requirement.toolName}' != '${request.tool.name}'" @@ -99,8 +126,9 @@ internal class ApprovalSuspensionCoordinator( require(requirement.toolName == request.tool.name) { "Approval requirement tool binding mismatch: expected '${request.tool.name}', got '${requirement.toolName}'" } - val digester = toolArgumentsDigester - ?: throw ConfigurationException("ToolArgumentsDigester is required for approval binding validation") + val digester = + toolArgumentsDigester + ?: throw ConfigurationException("ToolArgumentsDigester is required for approval binding validation") val rawDigest = digester.digest(SensitiveToolArguments.of(input)) if (requirement.argumentsDigest.isNotEmpty()) { require(Sha256Digest.of(requirement.argumentsDigest) == rawDigest) { @@ -112,58 +140,65 @@ internal class ApprovalSuspensionCoordinator( } private suspend fun suspendToolExecution(request: SuspendToolExecutionRequest): Nothing { - val approvalGateCoordinator = approvalGateCoordinator - ?: throw ConfigurationException("ApprovalGateCoordinator is required for tool execution suspension") - val approvalContinuationStore = approvalContinuationStore - ?: throw ConfigurationException("ApprovalContinuationStore is required for tool execution suspension") + val approvalGateCoordinator = requireApprovalGateCoordinator() + val approvalContinuationStore = requireApprovalContinuationStore() + val governedSuspension = resolveGovernedSuspension() val sensitiveArgs = SensitiveToolArguments.of(request.input) val expiresAt = clock.instant().plusMillis(request.timeoutMillis) var createdChallengeId: String? = null var createdContinuationVersion = 0L try { - val challenge = approvalGateCoordinator.createApproval( - CreateApprovalCommand( - workflowRunId = request.identity.workflowRunId, - toolName = request.tool.name, - argumentsDigest = request.argumentsDigest, - policyVersion = request.identity.policyVersion, - workflowDigest = request.identity.workflowDigest, - requestedBy = request.identity.actorId, - expiresAt = expiresAt, - ), - ) + val challenge = + approvalGateCoordinator.createApproval( + CreateApprovalCommand( + workflowRunId = request.identity.workflowRunId, + toolName = request.tool.name, + argumentsDigest = request.argumentsDigest, + policyVersion = request.identity.policyVersion, + workflowDigest = request.identity.workflowDigest, + requestedBy = request.identity.actorId, + expiresAt = expiresAt, + ), + ) createdChallengeId = challenge.approvalId - val continuation = approvalContinuationStore.create( - ApprovalContinuation( - approvalId = challenge.approvalId, - workflowRunId = request.identity.workflowRunId, - correlationId = request.correlationId, - toolCallId = request.toolCall.id, - toolName = request.tool.name, - argumentsDigest = request.argumentsDigest, - policyVersion = request.identity.policyVersion, - workflowDigest = request.identity.workflowDigest, - status = ApprovalContinuationStatus.PENDING, - createdAt = clock.instant(), - approvalExpiresAt = challenge.expiresAt, - claimedBy = null, - claimedAt = null, - completedAt = null, - version = 0L, - ), - sensitiveArgs, - ) + val continuation = + approvalContinuationStore.create( + ApprovalContinuation( + approvalId = challenge.approvalId, + workflowRunId = request.identity.workflowRunId, + correlationId = request.correlationId, + toolCallId = request.toolCall.id, + toolName = request.tool.name, + argumentsDigest = request.argumentsDigest, + policyVersion = request.identity.policyVersion, + workflowDigest = request.identity.workflowDigest, + status = ApprovalContinuationStatus.PENDING, + createdAt = clock.instant(), + approvalExpiresAt = challenge.expiresAt, + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, + ), + sensitiveArgs, + ) createdContinuationVersion = continuation.version val budgetSnapshot = request.tokenBudgetTracker?.snapshot() val opRef = resumeOperationRegistry.register(serviceDefinition, request.operation, resumeExecutor) - val prepared = ReplayEnvelopeFactory.prepareForSuspension( - opRef, request.messages, request.toolCall.id, request.tool.name, request.toolCallIndex, - ) - val toolRef = ResumeToolReference( - request.tool.name, - ResumeToolDeclarationDigestHelper.compute(request.tool), - ) - suspendedInvocationStore.create( + val prepared = + ReplayEnvelopeFactory.prepareForSuspension( + opRef, + request.messages, + request.toolCall.id, + request.tool.name, + request.toolCallIndex, + ) + val toolRef = + ResumeToolReference( + request.tool.name, + ResumeToolDeclarationDigestHelper.compute(request.tool), + ) + val suspendedMetadata = SuspendedInvocationMetadata( approvalId = challenge.approvalId, toolCallId = request.toolCall.id, @@ -179,9 +214,8 @@ internal class ApprovalSuspensionCoordinator( tokenBudgetSnapshot = budgetSnapshot, toolReference = toolRef, toolSecurity = request.tool.security, - ), - prepared.envelope, - ) + ) + persistSuspendedInvocation(governedSuspension, suspendedMetadata, prepared.envelope) approvalLifecycleAuditEmitter.onToolExecutionSuspended( challenge.approvalId, request.identity.workflowRunId, @@ -202,32 +236,78 @@ internal class ApprovalSuspensionCoordinator( } catch (failure: Exception) { failure.rethrowIfCancellation() if (failure is ApprovalSuspendedException) throw failure - createdChallengeId?.let { approvalId -> - try { - suspendedInvocationStore.remove(approvalId) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (e: Exception) { - e.rethrowIfCancellation() - } - try { - approvalContinuationStore.cancel(approvalId, createdContinuationVersion) - } catch (cancellation: CancellationException) { - throw cancellation - } catch (e: Exception) { - e.rethrowIfCancellation() - // best-effort cleanup - } - try { - approvalGateCoordinator.cancelApproval(approvalId, 0L, "suspension-compensation") - } catch (cancellation: CancellationException) { - throw cancellation - } catch (e: Exception) { - e.rethrowIfCancellation() - // best-effort cleanup - } - } + compensateSuspension( + createdChallengeId, + createdContinuationVersion, + approvalContinuationStore, + approvalGateCoordinator, + ) throw failure } } + + private fun requireApprovalGateCoordinator(): ApprovalGateCoordinator = + approvalGateCoordinator + ?: throw ConfigurationException("ApprovalGateCoordinator is required for tool execution suspension") + + private fun requireApprovalContinuationStore(): ApprovalContinuationStore = + approvalContinuationStore + ?: throw ConfigurationException("ApprovalContinuationStore is required for tool execution suspension") + + private suspend fun resolveGovernedSuspension(): GovernedSuspension { + val identity = + GovernedRunScope.resolve(currentCoroutineContext()) + ?: return GovernedSuspension(null, null) + val store = + suspendedInvocationStore as? GovernedSuspendedInvocationStore + ?: throw ConfigurationException( + "Governed approval suspension requires a SuspendedInvocationStore implementing " + + "GovernedSuspendedInvocationStore; the configured store " + + "'${suspendedInvocationStore::class.simpleName}' does not", + ) + return GovernedSuspension(identity, store) + } + + private suspend fun persistSuspendedInvocation( + governedSuspension: GovernedSuspension, + metadata: SuspendedInvocationMetadata, + envelope: SensitiveReplayEnvelope, + ) { + val identity = governedSuspension.identity + val store = governedSuspension.store + if (identity != null && store != null) { + // ONE durable record: metadata + replay envelope + canonical identity. + store.createGoverned(GovernedSuspendedInvocation(metadata, identity), envelope) + } else { + suspendedInvocationStore.create(metadata, envelope) + } + } + + private suspend fun compensateSuspension( + approvalId: String?, + continuationVersion: Long, + continuationStore: ApprovalContinuationStore, + gateCoordinator: ApprovalGateCoordinator, + ) { + approvalId?.let { id -> + compensateStep { suspendedInvocationStore.remove(id) } + compensateStep { continuationStore.cancel(id, continuationVersion) } + compensateStep { gateCoordinator.cancelApproval(id, 0L, "suspension-compensation") } + } + } + + private suspend fun compensateStep(action: suspend () -> Unit) { + try { + action() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + e.rethrowIfCancellation() + } + } + + private data class GovernedSuspension( + val identity: dev.tramai.core.identity.GovernedRunIdentity?, + val store: GovernedSuspendedInvocationStore?, + ) } diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/DefaultApprovalGateway.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/DefaultApprovalGateway.kt index 8ef3c1fd2..ac3d081ba 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/DefaultApprovalGateway.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/DefaultApprovalGateway.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + package dev.tramai.engine.approval import dev.tramai.core.approval.ApprovalContinuationStore @@ -13,9 +15,17 @@ import dev.tramai.core.approval.gateway.AuditStreamId import dev.tramai.core.approval.gateway.HumanApprovalDecision import dev.tramai.core.approval.gateway.ResumeToken import dev.tramai.core.approval.gateway.WorkflowRunId +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore import dev.tramai.engine.SuspendedInvocationStore -import java.time.Clock import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import java.time.Clock /** * Minimal Preview adapter for [ApprovalGateway]. @@ -23,12 +33,21 @@ import kotlinx.coroutines.CancellationException * Writes the existing approval, suspended invocation, and continuation stores * in order. Intended for ergonomic integration tests and Preview usage. * + * **Governed runs:** an execution inside an active [GovernedRunScope] persists its canonical + * identity rather than an un-attributed suspension. The gateway resolves the scope once, requires + * both governed store capabilities before invoking the request factory, and then derives the + * approval attribution at the governed store boundary. A governed request never falls back to the + * legacy `create` path, and a deployment whose stores lack the governed capability fails closed + * with [ConfigurationException] instead of durably recording a downgraded suspension. + * * **Limitations:** * - Does not provide a single transactional boundary across all three stores. * If step 2 or 3 fails after step 1 succeeds, the stores are left in an * inconsistent state. A future PR should harden the transaction boundary. * - Does not emit audit-requested outbox intent. * - Does not implement workflow resume. + * - Does not reconcile an existing approval's attribution against its existing suspended-invocation + * identity; that cross-record reconstruction continuity is tracked separately. * - Existing pending requests cannot recover the original suspended invocation * correlation ID from [ApprovalStore] alone, so the adapter currently uses * the workflow run ID as a temporary audit stream identifier until the @@ -38,8 +57,10 @@ import kotlinx.coroutines.CancellationException * @param continuationStore The store for approval continuation records. * @param suspendedInvocationStore The store for suspended invocation metadata and replay envelope. * @param requestFactory Internal seam that translates high-level SPI input into low-level - * persistence records. Must provide a stable [ResumeToken] — the - * gateway never derives it from [dev.tramai.core.approval.ApprovalBinding.approvalTokenDigest]. + * persistence records. It is identity-blind by contract: it never + * receives and never nominates governed attribution. Must provide a + * stable [ResumeToken] — the gateway never derives it from + * [dev.tramai.core.approval.ApprovalBinding.approvalTokenDigest]. * @param clock Clock for time-based checks. Defaults to system UTC. */ class DefaultApprovalGateway( @@ -49,37 +70,49 @@ class DefaultApprovalGateway( private val requestFactory: ApprovalGatewayRequestFactory, private val clock: Clock = Clock.systemUTC(), ) : ApprovalGateway { - override suspend fun requestApproval( subject: ApprovalSubject, recommendation: ApprovalRecommendation, requiredRole: ApproverRole, workflowRunId: WorkflowRunId?, ): ApprovalRequestResult { - val request = requestFactory.createRequest( - subject = subject, - recommendation = recommendation, - requiredRole = requiredRole, - workflowRunId = workflowRunId, - ) + // The canonical value for this request: a governed execution carries its identity, an + // ungoverned one carries nothing. Every persistence path below derives from this one value + // instead of reconstructing or reinterpreting identity for itself. + val governedIdentity = resolveGovernedIdentity(workflowRunId) + + // Capability discovery is a single precondition, not a per-store surprise: a governed request + // against a deployment that cannot persist governed records fails before the factory runs, so + // a partially governed wiring can never write some records and then fail on the rest. + if (governedIdentity != null) { + requireGovernedApprovalStore() + requireGovernedSuspensionStore() + } + + val request = + requestFactory.createRequest( + subject = subject, + recommendation = recommendation, + requiredRole = requiredRole, + workflowRunId = effectiveWorkflowRunId(governedIdentity, workflowRunId), + ) + + // The factory is untrusted for identity: it is handed the canonical run id above, and a + // payload that re-points the binding must abort before anything durable exists. + requireFactoryBindingMatches(request, governedIdentity) return try { val existing = approvalStore.get(request.approvalRequest.approvalId) if (existing != null) { + requireExistingAttributionMatches(existing, governedIdentity) return existing.toGatewayResult(request, clock) } - approvalStore.create(request.approvalRequest) - - suspendedInvocationStore.create( - metadata = request.suspendedInvocationMetadata, - replayEnvelope = request.replayEnvelope, - ) - - continuationStore.create( - continuation = request.continuation, - arguments = request.sensitiveArguments, - ) + if (governedIdentity == null) { + persistUngoverned(request) + } else { + persistGoverned(request, governedIdentity) + } ApprovalRequestResult.Suspended( approvalId = ApprovalId(request.approvalRequest.approvalId), @@ -92,6 +125,145 @@ class DefaultApprovalGateway( } } + /** + * Resolves the canonical governed identity for this request, or null when the execution is + * ungoverned. + * + * A caller may name the run it believes it is suspending, but it may never nominate one run's id + * together with another run's identity. When an active scope exists the scope is authoritative, + * and a disagreement aborts here — before the factory and before any store. + */ + private suspend fun resolveGovernedIdentity(workflowRunId: WorkflowRunId?): GovernedRunIdentity? { + val identity = GovernedRunScope.resolve(currentCoroutineContext()) ?: return null + if (workflowRunId != null && workflowRunId.value != identity.runId.value) { + throw GovernedRunContinuityException( + "Caller supplied workflow run id '${workflowRunId.value}' does not match the active " + + "governed run '${identity.runId.value}': a governed suspension cannot pair one " + + "run's id with another run's identity", + ) + } + return identity + } + + /** + * Requires both governed capabilities up front. Existing callers resolve each capability lazily + * at its own use site; here the whole wired store set is checked as one precondition so a partial + * wiring cannot commit the approval and then refuse the suspension. + */ + private fun requireGovernedApprovalStore(): GovernedApprovalStore = + approvalStore as? GovernedApprovalStore + ?: throw ConfigurationException( + "Governed approval requests require an ApprovalStore implementing " + + "GovernedApprovalStore; the configured store " + + "'${approvalStore::class.simpleName}' does not", + ) + + private fun requireGovernedSuspensionStore(): GovernedSuspendedInvocationStore = + suspendedInvocationStore as? GovernedSuspendedInvocationStore + ?: throw ConfigurationException( + "Governed approval suspension requires a SuspendedInvocationStore implementing " + + "GovernedSuspendedInvocationStore; the configured store " + + "'${suspendedInvocationStore::class.simpleName}' does not", + ) + + /** + * The effective run id for the factory call: for a governed request the canonical identity + * supplies it, so an omitted argument is derived rather than left absent. + */ + private fun effectiveWorkflowRunId( + governedIdentity: GovernedRunIdentity?, + callerRunId: WorkflowRunId?, + ): WorkflowRunId? = governedIdentity?.let { WorkflowRunId(it.runId.value) } ?: callerRunId + + private fun requireFactoryBindingMatches( + request: ApprovalGatewayPersistenceRequest, + governedIdentity: GovernedRunIdentity?, + ) { + if (governedIdentity == null) return + val bindingRunId = request.approvalRequest.binding.workflowRunId + if (bindingRunId != governedIdentity.runId.value) { + throw GovernedRunContinuityException( + "Approval request factory produced a binding for run '$bindingRunId' while the active " + + "governed run is '${governedIdentity.runId.value}': a governed request must keep " + + "the canonical run id", + ) + } + } + + /** + * An idempotent call must not accept a durable record whose attribution contradicts the active + * run: neither an un-attributed (legacy) approval nor one bound to a different identity may be + * silently returned as if it were this run's suspension. + * + * A malformed persisted attribution is NOT re-interpreted here — the store's + * `ApprovalAttributionCorruptionException` propagates unchanged. + */ + private suspend fun requireExistingAttributionMatches( + existing: dev.tramai.core.approval.ApprovalRequest, + governedIdentity: GovernedRunIdentity?, + ) { + if (governedIdentity == null) return + val store = requireGovernedApprovalStore() + // A missing attribution record is not reinterpreted here: the store's own not-found + // failure propagates, because an approval with an unreadable identity must never be + // adopted by a governed run. + val persisted = store.attributionOf(existing.approvalId) + val expected = ApprovalRunAttribution.Governed(governedIdentity) + if (persisted != expected) { + throw GovernedRunContinuityException( + "Existing approval '${existing.approvalId}' does not carry the canonical identity of " + + "governed run '${governedIdentity.runId.value}': a governed run must not resume " + + "another run's approval", + ) + } + } + + /** The legacy path: unchanged, and never reached by a governed request. */ + private suspend fun persistUngoverned(request: ApprovalGatewayPersistenceRequest) { + approvalStore.create(request.approvalRequest) + + suspendedInvocationStore.create( + metadata = request.suspendedInvocationMetadata, + replayEnvelope = request.replayEnvelope, + ) + + continuationStore.create( + continuation = request.continuation, + arguments = request.sensitiveArguments, + ) + } + + /** + * The governed path. The attribution is derived here, at the governed store boundary, which is + * what keeps the factory payload identity-blind: the factory never manufactures attribution and + * never nominates a run. + * + * The suspension follows [ApprovalSuspensionCoordinator]'s shape — ONE durable record holding the + * metadata, the replay envelope and the same canonical identity. + */ + private suspend fun persistGoverned( + request: ApprovalGatewayPersistenceRequest, + governedIdentity: GovernedRunIdentity, + ) { + requireGovernedApprovalStore().createGovernedApproval( + request.approvalRequest, + ApprovalRunAttribution.Governed(governedIdentity), + ) + + requireGovernedSuspensionStore().createGoverned( + GovernedSuspendedInvocation( + metadata = request.suspendedInvocationMetadata, + runIdentity = governedIdentity, + ), + request.replayEnvelope, + ) + + continuationStore.create( + continuation = request.continuation, + arguments = request.sensitiveArguments, + ) + } + /** * Maps an existing [dev.tramai.core.approval.ApprovalRequest] to a gateway result. * @@ -108,48 +280,61 @@ class DefaultApprovalGateway( val now = clock.instant() return when { - status == ApprovalStatus.APPROVED -> ApprovalRequestResult.AlreadyApproved( - decision = HumanApprovalDecision.Approved( - approvalId = approvalId, - decidedBy = requireNotNull(decidedBy) { - "approved request must have a decider" - }, - decidedAt = requireNotNull(decidedAt) { - "approved request must have a decision timestamp" - }, - comment = decisionComment, - ), - ) + status == ApprovalStatus.APPROVED -> { + ApprovalRequestResult.AlreadyApproved( + decision = + HumanApprovalDecision.Approved( + approvalId = approvalId, + decidedBy = + requireNotNull(decidedBy) { + "approved request must have a decider" + }, + decidedAt = + requireNotNull(decidedAt) { + "approved request must have a decision timestamp" + }, + comment = decisionComment, + ), + ) + } - status == ApprovalStatus.DENIED -> ApprovalRequestResult.AlreadyDenied( - decision = HumanApprovalDecision.Denied( - approvalId = approvalId, - decidedBy = requireNotNull(decidedBy) { - "denied request must have a decider" - }, - decidedAt = requireNotNull(decidedAt) { - "denied request must have a decision timestamp" - }, - reason = decisionComment ?: "approval-denied", - ), - ) + status == ApprovalStatus.DENIED -> { + ApprovalRequestResult.AlreadyDenied( + decision = + HumanApprovalDecision.Denied( + approvalId = approvalId, + decidedBy = + requireNotNull(decidedBy) { + "denied request must have a decider" + }, + decidedAt = + requireNotNull(decidedAt) { + "denied request must have a decision timestamp" + }, + reason = decisionComment ?: "approval-denied", + ), + ) + } - status == ApprovalStatus.TIMED_OUT || !expiresAt.isAfter(now) -> + status == ApprovalStatus.TIMED_OUT || !expiresAt.isAfter(now) -> { ApprovalRequestResult.Expired( approvalId = approvalId, expiredAt = expiresAt, reason = "approval-expired", ) + } - else -> ApprovalRequestResult.Suspended( - approvalId = approvalId, - workflowRunId = WorkflowRunId(binding.workflowRunId), - // Existing pending cannot recover the original correlation ID from - // ApprovalStore alone, so workflowRunId serves as a temporary audit - // stream identifier until the resume/gateway state model is hardened. - auditStreamId = AuditStreamId(binding.workflowRunId), - resumeToken = request.resumeToken, - ) + else -> { + ApprovalRequestResult.Suspended( + approvalId = approvalId, + workflowRunId = WorkflowRunId(binding.workflowRunId), + // Existing pending cannot recover the original correlation ID from + // ApprovalStore alone, so workflowRunId serves as a temporary audit + // stream identifier until the resume/gateway state model is hardened. + auditStreamId = AuditStreamId(binding.workflowRunId), + resumeToken = request.resumeToken, + ) + } } } } diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedApprovalStore.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedApprovalStore.kt new file mode 100644 index 000000000..21c4bff80 --- /dev/null +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedApprovalStore.kt @@ -0,0 +1,61 @@ +package dev.tramai.engine.approval + +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStore + +/** + * Additive durable capability: governed approvals (0.7.1d1). + * + * Deliberately a separate interface rather than new methods on [ApprovalStore]: third parties + * implement that SPI, and adding a required method would break them. A governed approval therefore + * REQUIRES this capability and fails closed when the configured store does not provide it — before + * any approval or continuation state is durably created. The degradation this prevents is "looks + * governed until the first approval boundary, then silently continues with run-id-only semantics". + * + * Implementations must persist the approval and its attribution snapshot as ONE durable record (one + * transaction, one file write): two independently written records could leave a governed approval + * carrying only partial attribution after a crash. + * + * The attribution is a framework-owned immutable snapshot, never a second identity authority: the + * canonical full governed identity of an active suspension is the governed suspension record, and + * wherever both exist they must agree exactly. + */ +public interface GovernedApprovalStore : ApprovalStore { + /** + * Create a governed approval carrying the canonical attribution snapshot. + * + * @throws IllegalArgumentException when the attribution's run id does not match the request + * binding's `workflowRunId` — a caller must not be able to nominate run A with identity B. + */ + public suspend fun createGovernedApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution.Governed, + ): ApprovalRequest + + /** + * Attribution snapshot of an existing approval, decoded from durable state. + * + * Returns [ApprovalRunAttribution.Ungoverned] for a legacy approval with no reserved keys. + * + * @throws ApprovalAttributionCorruptionException when the persisted set is partial or unusable. + * @throws dev.tramai.core.exception.ApprovalStoreNotFoundException when no such approval exists. + */ + public suspend fun attributionOf(approvalId: String): ApprovalRunAttribution +} + +/** + * Rejects a governed creation whose attribution describes a different run than the request binding. + * + * The run id has exactly one durable source, `ApprovalBinding.workflowRunId`; the attribution must + * describe the same run, so "run A + workload/configuration/deployment B" is unrepresentable. + */ +public fun requireAttributionMatchesBinding( + request: ApprovalRequest, + attribution: ApprovalRunAttribution, +) { + val identity = (attribution as? ApprovalRunAttribution.Governed)?.identity ?: return + require(identity.runId.value == request.binding.workflowRunId) { + "Governed approval attribution is inconsistent with its binding: identity runId " + + "'${identity.runId.value}' != binding workflowRunId '${request.binding.workflowRunId}'" + } +} diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedContinuationContinuity.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedContinuationContinuity.kt new file mode 100644 index 000000000..67701ada8 --- /dev/null +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/approval/GovernedContinuationContinuity.kt @@ -0,0 +1,47 @@ +package dev.tramai.engine.approval + +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.GovernedRunIdentity + +/** + * Continuity precondition for an approval continuation (0.7.1d). + * + * The persisted suspension record is the AUTHORITY for a standalone continuation. A + * caller-supplied [GovernedRunIdentity] (the governed scope the resume runs inside, when + * there is one) is only a consistency precondition — never replacement authority, and + * never a second source of truth to reconcile against the record. + * + * | persisted | requested | outcome | + * |---|---|---| + * | none | none | legacy suspension, legacy behaviour | + * | none | governed | reject: cannot prove a legacy suspension belongs to a governed run | + * | governed | none | recover the persisted identity (standalone resume) | + * | governed | same | proceed | + * | governed | different | reject before claim/execution: attribution was substituted | + * + * The whole identity is compared, not the run id: a run id can be replayed against + * another workload, configuration, environment or deployment. + */ +internal fun requireGovernedContinuity( + approvalId: String, + persisted: GovernedRunIdentity?, + requested: GovernedRunIdentity?, +) { + when { + persisted == null && requested == null -> Unit + + persisted == null -> throw GovernedRunContinuityException( + "Approval '$approvalId' was suspended by an ungoverned execution and cannot be " + + "resumed from governed run '${requested?.runId}'", + ) + + requested == null -> Unit + + persisted != requested -> throw GovernedRunContinuityException( + "Approval '$approvalId' governed run attribution was substituted: " + + "persisted=$persisted requested=$requested", + ) + + else -> Unit + } +} diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/InvocationExecutionCoordinator.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/InvocationExecutionCoordinator.kt index 053eaa49f..6d528d417 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/InvocationExecutionCoordinator.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/InvocationExecutionCoordinator.kt @@ -1,34 +1,41 @@ -package dev.tramai.engine.invocation +@file:OptIn(ExperimentalTramaiInternalApi::class) +package dev.tramai.engine.invocation +import dev.tramai.core.approval.ApprovalContinuationStore import dev.tramai.core.approval.ApprovalGateCoordinator -import dev.tramai.core.approval.Sha256Digest import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter -import dev.tramai.core.approval.ApprovalContinuationStore import dev.tramai.core.approval.IdempotencyKeyUtil +import dev.tramai.core.approval.Sha256Digest import dev.tramai.core.approval.ToolArgumentsDigester import dev.tramai.core.coroutines.rethrowIfCancellation import dev.tramai.core.exception.ApprovalSuspendedException import dev.tramai.core.exception.ConfigurationException import dev.tramai.core.exception.ToolInvalidInputException +import dev.tramai.core.identity.GovernedRunScope import dev.tramai.core.model.Message +import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ModelResponse import dev.tramai.core.model.ToolCall -import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolResult import dev.tramai.core.observation.OperationObservation +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.core.provider.ResolvedProviderRoute -import dev.tramai.core.security.DlpContentType import dev.tramai.core.security.DlpContentLocation +import dev.tramai.core.security.DlpContentType import dev.tramai.core.security.DlpContext import dev.tramai.core.security.DlpInspectionException import dev.tramai.core.security.NoOpDlpInterceptor import dev.tramai.core.security.NoOpDlpRedactionAuditEmitter import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.ModelRegistryEnforcer import dev.tramai.engine.OperationDefinition import dev.tramai.engine.PolicyContextBuilder import dev.tramai.engine.PolicyEnforcementHelper +import dev.tramai.engine.ProviderCircuitBreaker +import dev.tramai.engine.ProviderRetryDelayPolicy import dev.tramai.engine.ReturnKind +import dev.tramai.engine.ToolRegistry import dev.tramai.engine.WorkflowDigestHelper import dev.tramai.engine.approval.ApprovalSuspensionCoordinator import dev.tramai.engine.approval.ClaimedResumeExecutionRequest @@ -41,7 +48,6 @@ import dev.tramai.engine.components.ApprovalCapability import dev.tramai.engine.components.EngineComponents import dev.tramai.engine.memory.ConversationMemoryCoordinator import dev.tramai.engine.memory.PersistConversationTurnRequest -import dev.tramai.engine.ModelRegistryEnforcer import dev.tramai.engine.planning.ServiceDefinition import dev.tramai.engine.provider.ProviderAttemptExecutor import dev.tramai.engine.provider.ProviderAuthorizationService @@ -54,8 +60,6 @@ import dev.tramai.engine.provider.ProviderInvocationGate import dev.tramai.engine.provider.ProviderResolutionGate import dev.tramai.engine.provider.ProviderResponseSanitizer import dev.tramai.engine.provider.ProviderRetryPolicy -import dev.tramai.engine.ProviderCircuitBreaker -import dev.tramai.engine.ProviderRetryDelayPolicy import dev.tramai.engine.streaming.StreamingBeforeResponseReturnGate import dev.tramai.engine.streaming.StreamingExecutionCoordinator import dev.tramai.engine.streaming.StreamingExecutionRequest @@ -71,9 +75,9 @@ import dev.tramai.engine.tool.ToolInvocationExecutor import dev.tramai.engine.tool.ToolReinjectionCoordinator import dev.tramai.engine.tool.ToolResultSanitizer import dev.tramai.engine.tool.ToolRetryPolicy -import dev.tramai.engine.ToolRegistry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext import java.time.Clock import java.util.concurrent.atomic.AtomicBoolean @@ -113,170 +117,254 @@ internal class InvocationExecutionCoordinator( private val toolResultFilteringSettings = components.tools.toolResultFilteringSettings private val engineEventObserver = components.observation.engineEventObserver private val toolFailureDiagnosticObserver = components.observation.toolFailureDiagnosticObserver - private val structuredOutputFailureDiagnosticObserver = components.observation.structuredOutputFailureDiagnosticObserver + private val structuredOutputFailureDiagnosticObserver = + components.observation.structuredOutputFailureDiagnosticObserver private val policyDecisionAuditEmitter = components.security.policyDecisionAuditEmitter private val suspendedInvocationStore = components.approvals.suspendedInvocationStore private val approvalLifecycleAuditEmitter = components.approvals.approvalLifecycleAuditEmitter private val clock = components.execution.clock - private val approvalContinuationStore = (components.approvals.capability as? ApprovalCapability.Enabled)?.continuationStore - private val toolArgumentsDigester = (components.approvals.capability as? ApprovalCapability.Enabled)?.argumentsDigester - private val approvalGateCoordinator = (components.approvals.capability as? ApprovalCapability.Enabled)?.gateCoordinator + private val approvalContinuationStore = + (components.approvals.capability as? ApprovalCapability.Enabled)?.continuationStore + private val toolArgumentsDigester = + (components.approvals.capability as? ApprovalCapability.Enabled)?.argumentsDigester + private val approvalGateCoordinator = + (components.approvals.capability as? ApprovalCapability.Enabled)?.gateCoordinator - private val policyHelper = PolicyEnforcementHelper(components.security.resolvedPolicyEngine, migrationWarningGuard, isLegacyFallback = components.security.isLegacyFallback, auditEmitter = policyDecisionAuditEmitter) + private val policyHelper = + PolicyEnforcementHelper( + components.security.resolvedPolicyEngine, + migrationWarningGuard, + isLegacyFallback = components.security.isLegacyFallback, + auditEmitter = policyDecisionAuditEmitter, + ) private val modelRegistryEnforcer = ModelRegistryEnforcer(modelRegistry, modelRegistrySettings) - private val providerResponseDlpSanitizer = ProviderResponseDlpSanitizer( - dlpInterceptor = dlpInterceptor, - dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, - serviceDefinition = serviceDefinition, - ) - private val beforeProviderInvocationGate = ProviderInvocationGate { providerId, modelName, correlationId, securityContext -> enforceBeforeProviderInvocation(providerId, modelName, correlationId, securityContext) } - private val beforeResolutionGate = ProviderResolutionGate { operation, correlationId, securityContext -> enforceBeforeProviderResolution(operation, correlationId, securityContext) } - private val fallbackGate = ProviderFallbackGate { correlationId, previousProviderId, previousModelName, nextProviderId, reason, securityContext -> enforceFallbackTransition(correlationId, previousProviderId, previousModelName, nextProviderId, reason, securityContext) } - private val providerExecutionCoordinator = ProviderExecutionCoordinator( - routingPlan = routingPlan, - circuitBreaker = circuitBreaker, - attemptExecutor = ProviderAttemptExecutor( - serviceInterface = serviceDefinition.serviceType.qualifiedName ?: serviceDefinition.serviceType.simpleName.orEmpty(), - operationObserver = operationObserver, - operationInterceptor = operationInterceptor, + private val providerResponseDlpSanitizer = + ProviderResponseDlpSanitizer( + dlpInterceptor = dlpInterceptor, + dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, + serviceDefinition = serviceDefinition, + ) + private val beforeProviderInvocationGate = + ProviderInvocationGate { + providerId, + modelName, + correlationId, + securityContext, + -> + enforceBeforeProviderInvocation(providerId, modelName, correlationId, securityContext) + } + private val beforeResolutionGate = + ProviderResolutionGate { + operation, + correlationId, + securityContext, + -> + enforceBeforeProviderResolution(operation, correlationId, securityContext) + } + private val fallbackGate = + ProviderFallbackGate { + correlationId, + previousProviderId, + previousModelName, + nextProviderId, + reason, + securityContext, + -> + enforceFallbackTransition( + correlationId, + previousProviderId, + previousModelName, + nextProviderId, + reason, + securityContext, + ) + } + private val providerExecutionCoordinator = + ProviderExecutionCoordinator( + routingPlan = routingPlan, circuitBreaker = circuitBreaker, - retryPolicy = ProviderRetryPolicy(retryDelayPolicy), - authorizationService = ProviderAuthorizationService(modelRegistryEnforcer), - beforeProviderInvocation = beforeProviderInvocationGate, - responseSanitizer = ProviderResponseSanitizer { response, operation, providerId, modelName, correlationId, securityContext, observation -> providerResponseDlpSanitizer.sanitizeProviderResponse(response, operation, providerId, modelName, correlationId, securityContext, observation) }, - ), - fallbackPolicy = ProviderFallbackPolicy(), - beforeResolution = beforeResolutionGate, - fallbackGate = fallbackGate, - ) + attemptExecutor = + ProviderAttemptExecutor( + serviceInterface = + serviceDefinition.serviceType.qualifiedName + ?: serviceDefinition.serviceType.simpleName.orEmpty(), + operationObserver = operationObserver, + operationInterceptor = operationInterceptor, + circuitBreaker = circuitBreaker, + retryPolicy = ProviderRetryPolicy(retryDelayPolicy), + authorizationService = ProviderAuthorizationService(modelRegistryEnforcer), + beforeProviderInvocation = beforeProviderInvocationGate, + responseSanitizer = + ProviderResponseSanitizer { + response, + operation, + providerId, + modelName, + correlationId, + securityContext, + observation, + -> + providerResponseDlpSanitizer.sanitizeProviderResponse( + response, + operation, + providerId, + modelName, + correlationId, + securityContext, + observation, + ) + }, + ), + fallbackPolicy = ProviderFallbackPolicy(), + beforeResolution = beforeResolutionGate, + fallbackGate = fallbackGate, + ) private val toolExposureCoordinator = ToolExposureCoordinator(toolRegistry, policyHelper) - private val conversationMemoryCoordinator = ConversationMemoryCoordinator( - chatMemory = chatMemory, - conversationIdProvider = conversationIdProvider, - ) + private val conversationMemoryCoordinator = + ConversationMemoryCoordinator( + chatMemory = chatMemory, + conversationIdProvider = conversationIdProvider, + ) internal val contextFactory = InvocationContextFactory(chatMemory, conversationMemoryCoordinator) - private val operationCacheCoordinator = OperationCacheCoordinator( - responseCache = responseCache, - operationInterceptor = operationInterceptor, - dlpInterceptor = dlpInterceptor, - modelRegistrySettings = modelRegistrySettings, - modelRegistryEnforcer = modelRegistryEnforcer, - policyHelper = policyHelper, - ) + private val operationCacheCoordinator = + OperationCacheCoordinator( + responseCache = responseCache, + operationInterceptor = operationInterceptor, + dlpInterceptor = dlpInterceptor, + modelRegistrySettings = modelRegistrySettings, + modelRegistryEnforcer = modelRegistryEnforcer, + policyHelper = policyHelper, + ) private val tokenBudgetCoordinator = TokenBudgetCoordinator(tokenBudgetSettings) - private val streamingExecutionCoordinator = StreamingExecutionCoordinator( - identitySource = components.execution.identitySource, - routingPlan = routingPlan, - circuitBreaker = circuitBreaker, - lifecycleScope = lifecycleScope, - isClosed = isClosed, - serviceTypeName = serviceDefinition.serviceType.qualifiedName ?: serviceDefinition.serviceType.simpleName.orEmpty(), - qualifiedServiceName = serviceDefinition.serviceType.qualifiedName, - operationObserver = operationObserver, - operationInterceptor = operationInterceptor, - toolExposureCoordinator = toolExposureCoordinator, - conversationMemoryCoordinator = conversationMemoryCoordinator, - tokenBudgetCoordinator = tokenBudgetCoordinator, - modelRegistryEnforcer = modelRegistryEnforcer, - retryPolicy = ProviderRetryPolicy(retryDelayPolicy), - beforeResolution = beforeResolutionGate, - beforeInvocation = beforeProviderInvocationGate, - fallbackGate = fallbackGate, - beforeResponseReturn = StreamingBeforeResponseReturnGate { route, correlationId, securityContext -> - enforceBeforeResponseReturn(route, correlationId, securityContext) - }, - ) - private val toolResultSanitizer = ToolResultSanitizer( - toolRegistry = toolRegistry, - dlpInterceptor = dlpInterceptor, - dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, - toolResultFilteringSettings = toolResultFilteringSettings, - engineEventObserver = engineEventObserver, - ) + private val streamingExecutionCoordinator = + StreamingExecutionCoordinator( + identitySource = components.execution.identitySource, + routingPlan = routingPlan, + circuitBreaker = circuitBreaker, + lifecycleScope = lifecycleScope, + isClosed = isClosed, + serviceTypeName = + serviceDefinition.serviceType.qualifiedName + ?: serviceDefinition.serviceType.simpleName.orEmpty(), + qualifiedServiceName = serviceDefinition.serviceType.qualifiedName, + operationObserver = operationObserver, + operationInterceptor = operationInterceptor, + toolExposureCoordinator = toolExposureCoordinator, + conversationMemoryCoordinator = conversationMemoryCoordinator, + tokenBudgetCoordinator = tokenBudgetCoordinator, + modelRegistryEnforcer = modelRegistryEnforcer, + retryPolicy = ProviderRetryPolicy(retryDelayPolicy), + beforeResolution = beforeResolutionGate, + beforeInvocation = beforeProviderInvocationGate, + fallbackGate = fallbackGate, + beforeResponseReturn = + StreamingBeforeResponseReturnGate { route, correlationId, securityContext -> + enforceBeforeResponseReturn(route, correlationId, securityContext) + }, + ) + private val toolResultSanitizer = + ToolResultSanitizer( + toolRegistry = toolRegistry, + dlpInterceptor = dlpInterceptor, + dlpRedactionAuditEmitter = dlpRedactionAuditEmitter, + toolResultFilteringSettings = toolResultFilteringSettings, + engineEventObserver = engineEventObserver, + ) private val toolAuthorizationCoordinator = ToolAuthorizationCoordinator(policyHelper) private val toolRetryPolicy = ToolRetryPolicy() - private val approvalSuspensionCoordinator = ApprovalSuspensionCoordinator( - approvalGateCoordinator = approvalGateCoordinator, - approvalContinuationStore = approvalContinuationStore, - suspendedInvocationStore = suspendedInvocationStore, - resumeOperationRegistry = resumeOperationRegistry, - serviceDefinition = serviceDefinition, - resumeExecutor = this, - toolArgumentsDigester = toolArgumentsDigester, - clock = clock, - approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, - ) - private val toolInvocationExecutor = ToolInvocationExecutor( - authorizationCoordinator = toolAuthorizationCoordinator, - retryPolicy = toolRetryPolicy, - toolFailureDiagnosticObserver = toolFailureDiagnosticObserver, - approvalGate = approvalSuspensionCoordinator, - ) - private val toolReinjectionCoordinator = ToolReinjectionCoordinator( - toolRegistry = toolRegistry, - policyHelper = policyHelper, - invocationExecutor = toolInvocationExecutor, - resultSanitizer = toolResultSanitizer, - ) - private val toolLoopCoordinator = ToolLoopCoordinator( - providerExecutionCoordinator = providerExecutionCoordinator, - toolExposureCoordinator = toolExposureCoordinator, - tokenBudgetCoordinator = tokenBudgetCoordinator, - toolRegistry = toolRegistry, - toolReinjectionCoordinator = toolReinjectionCoordinator, - ) - private val rawResponseCoordinator = RawResponseCoordinator( - conversationMemoryCoordinator = conversationMemoryCoordinator, - operationCacheCoordinator = operationCacheCoordinator, - policyHelper = policyHelper, - toolLoopCoordinator = toolLoopCoordinator, - ) - private val structuredResponseCoordinator = StructuredResponseCoordinator( - structuredOutputHandler = structuredOutputHandler, - structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver, - conversationMemoryCoordinator = conversationMemoryCoordinator, - operationCacheCoordinator = operationCacheCoordinator, - policyHelper = policyHelper, - attemptExecutor = StructuredAttemptExecutor { request -> - toolLoopCoordinator.execute( - ToolLoopContext( - operation = request.operation, - messages = request.messages, - tokenBudgetTracker = request.tokenBudgetTracker, - correlationId = request.correlationId, - securityContext = request.securityContext, - identity = request.identity, - conversationId = request.conversationId, - historySize = request.historySize, - ), - ) - }, - serviceTypeName = serviceDefinition.serviceType.qualifiedName - ?: serviceDefinition.serviceType.simpleName - ?: "", - ) - private val claimedResumeCoordinator = ClaimedResumeExecutionCoordinator( - tokenBudgetCoordinator = tokenBudgetCoordinator, - toolInvocationExecutor = toolInvocationExecutor, - toolReinjectionCoordinator = toolReinjectionCoordinator, - toolLoopCoordinator = toolLoopCoordinator, - structuredResponseCoordinator = structuredResponseCoordinator, - conversationMemoryCoordinator = conversationMemoryCoordinator, - policyHelper = policyHelper, - approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, - ) + private val approvalSuspensionCoordinator = + ApprovalSuspensionCoordinator( + approvalGateCoordinator = approvalGateCoordinator, + approvalContinuationStore = approvalContinuationStore, + suspendedInvocationStore = suspendedInvocationStore, + resumeOperationRegistry = resumeOperationRegistry, + serviceDefinition = serviceDefinition, + resumeExecutor = this, + toolArgumentsDigester = toolArgumentsDigester, + clock = clock, + approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, + ) + private val toolInvocationExecutor = + ToolInvocationExecutor( + authorizationCoordinator = toolAuthorizationCoordinator, + retryPolicy = toolRetryPolicy, + toolFailureDiagnosticObserver = toolFailureDiagnosticObserver, + approvalGate = approvalSuspensionCoordinator, + ) + private val toolReinjectionCoordinator = + ToolReinjectionCoordinator( + toolRegistry = toolRegistry, + policyHelper = policyHelper, + invocationExecutor = toolInvocationExecutor, + resultSanitizer = toolResultSanitizer, + ) + private val toolLoopCoordinator = + ToolLoopCoordinator( + providerExecutionCoordinator = providerExecutionCoordinator, + toolExposureCoordinator = toolExposureCoordinator, + tokenBudgetCoordinator = tokenBudgetCoordinator, + toolRegistry = toolRegistry, + toolReinjectionCoordinator = toolReinjectionCoordinator, + ) + private val rawResponseCoordinator = + RawResponseCoordinator( + conversationMemoryCoordinator = conversationMemoryCoordinator, + operationCacheCoordinator = operationCacheCoordinator, + policyHelper = policyHelper, + toolLoopCoordinator = toolLoopCoordinator, + ) + private val structuredResponseCoordinator = + StructuredResponseCoordinator( + structuredOutputHandler = structuredOutputHandler, + structuredOutputFailureDiagnosticObserver = structuredOutputFailureDiagnosticObserver, + conversationMemoryCoordinator = conversationMemoryCoordinator, + operationCacheCoordinator = operationCacheCoordinator, + policyHelper = policyHelper, + attemptExecutor = + StructuredAttemptExecutor { request -> + toolLoopCoordinator.execute( + ToolLoopContext( + operation = request.operation, + messages = request.messages, + tokenBudgetTracker = request.tokenBudgetTracker, + correlationId = request.correlationId, + securityContext = request.securityContext, + identity = request.identity, + conversationId = request.conversationId, + historySize = request.historySize, + ), + ) + }, + serviceTypeName = + serviceDefinition.serviceType.qualifiedName + ?: serviceDefinition.serviceType.simpleName + ?: "", + ) + private val claimedResumeCoordinator = + ClaimedResumeExecutionCoordinator( + tokenBudgetCoordinator = tokenBudgetCoordinator, + toolInvocationExecutor = toolInvocationExecutor, + toolReinjectionCoordinator = toolReinjectionCoordinator, + toolLoopCoordinator = toolLoopCoordinator, + structuredResponseCoordinator = structuredResponseCoordinator, + conversationMemoryCoordinator = conversationMemoryCoordinator, + policyHelper = policyHelper, + approvalLifecycleAuditEmitter = approvalLifecycleAuditEmitter, + ) + private suspend fun enforceBeforeProviderResolution( operation: OperationDefinition, correlationId: String, securityContext: ExecutionSecurityContext, ) { policyHelper.enforce( - policyHelper.buildContext( - enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_PROVIDER_RESOLUTION, - correlationId = correlationId, - ).modelName(operation.operation.model) + policyHelper + .buildContext( + enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_PROVIDER_RESOLUTION, + correlationId = correlationId, + ).modelName(operation.operation.model) .applySecurityContext(securityContext) - .build() + .build(), ) } @@ -286,13 +374,14 @@ internal class InvocationExecutionCoordinator( securityContext: ExecutionSecurityContext, ) { policyHelper.enforce( - policyHelper.buildContext( - enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_RESPONSE_RETURN, - correlationId = correlationId, - ).providerId(route.providerName) + policyHelper + .buildContext( + enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_RESPONSE_RETURN, + correlationId = correlationId, + ).providerId(route.providerName) .modelName(route.effectiveModelName) .applySecurityContext(securityContext) - .build() + .build(), ) } @@ -303,13 +392,14 @@ internal class InvocationExecutionCoordinator( securityContext: ExecutionSecurityContext, ) { policyHelper.enforce( - policyHelper.buildContext( - enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_PROVIDER_INVOCATION, - correlationId = correlationId, - ).providerId(providerId) + policyHelper + .buildContext( + enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_PROVIDER_INVOCATION, + correlationId = correlationId, + ).providerId(providerId) .modelName(modelName) .applySecurityContext(securityContext) - .build() + .build(), ) } @@ -326,16 +416,19 @@ internal class InvocationExecutionCoordinator( reason: String, securityContext: ExecutionSecurityContext, ) { - val ctx = policyHelper.buildContext( - enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_FALLBACK, - correlationId = correlationId, - ).applySecurityContext(securityContext) + val ctx = + policyHelper + .buildContext( + enforcementPoint = dev.tramai.core.policy.EnforcementPoint.BEFORE_FALLBACK, + correlationId = correlationId, + ).applySecurityContext(securityContext) if (previousProviderId != null) ctx.providerId(previousProviderId) if (previousModelName != null) ctx.modelName(previousModelName) ctx.fallbackProviderId(nextProviderId) ctx.attribute("fallbackReason", reason) policyHelper.enforce(ctx.build()) } + suspend fun execute(context: InvocationExecutionContext): Any? { val operation = context.plan.definition val arguments = context.arguments @@ -343,15 +436,18 @@ internal class InvocationExecutionCoordinator( val tokenBudgetTracker = tokenBudgetCoordinator.createTracker() val workflowDigest = WorkflowDigestHelper.compute(operation, serviceDefinition) return when (operation.returnKind) { - ReturnKind.STRING -> rawResponseCoordinator.execute( - RawResponseRequest( - plan = context.plan, - arguments = arguments, - tokenBudgetTracker = tokenBudgetTracker, - conversationId = conversationId, - identity = invocationIdentity(workflowDigest), - ), - ) + ReturnKind.STRING -> { + rawResponseCoordinator.execute( + RawResponseRequest( + plan = context.plan, + arguments = arguments, + tokenBudgetTracker = tokenBudgetTracker, + conversationId = conversationId, + identity = invocationIdentity(workflowDigest), + ), + ) + } + ReturnKind.UNIT -> { rawResponseCoordinator.execute( RawResponseRequest( @@ -364,24 +460,30 @@ internal class InvocationExecutionCoordinator( ) Unit } - ReturnKind.STRUCTURED -> structuredResponseCoordinator.execute( - StructuredResponseRequest( - operation = operation, - arguments = arguments, - tokenBudgetTracker = tokenBudgetTracker, - conversationId = conversationId, - identity = invocationIdentity(workflowDigest), - operationFingerprint = context.plan.fingerprint, - ), - ) - ReturnKind.STREAMING -> streamingExecutionCoordinator.execute( - StreamingExecutionRequest( - operation = operation, - arguments = arguments, - tokenBudgetTracker = tokenBudgetTracker, - conversationId = conversationId, - ), - ) + + ReturnKind.STRUCTURED -> { + structuredResponseCoordinator.execute( + StructuredResponseRequest( + operation = operation, + arguments = arguments, + tokenBudgetTracker = tokenBudgetTracker, + conversationId = conversationId, + identity = invocationIdentity(workflowDigest), + operationFingerprint = context.plan.fingerprint, + ), + ) + } + + ReturnKind.STREAMING -> { + streamingExecutionCoordinator.execute( + StreamingExecutionRequest( + operation = operation, + arguments = arguments, + tokenBudgetTracker = tokenBudgetTracker, + conversationId = conversationId, + ), + ) + } } } @@ -391,9 +493,15 @@ internal class InvocationExecutionCoordinator( * a never-collected flow consumes zero identities, so its correlation is * sampled inside the flow body (StreamingExecutionCoordinator). */ - private fun invocationIdentity(workflowDigest: Sha256Digest): EngineExecutionIdentity { + private suspend fun invocationIdentity(workflowDigest: Sha256Digest): EngineExecutionIdentity { val identitySource = components.execution.identitySource - val workflowRunId = identitySource.newWorkflowRunId() + // 0.7.1d: inside a governed execution the canonical RunId is authoritative and + // the engine must NOT mint a second workflow run id for the same execution: + // the coroutine context is authoritative, the thread bridge covers the blocking + // proxy path (see TramaiInvocationHandler). Outside a governed execution the + // generated identity is unchanged. + val governedRunId = GovernedRunScope.resolve(currentCoroutineContext())?.runId?.value + val workflowRunId = governedRunId ?: identitySource.newWorkflowRunId() val correlationId = identitySource.newCorrelationId() require(workflowRunId.isNotBlank()) { "Engine workflowRunId must not be blank" } require(correlationId.isNotBlank()) { "Engine correlationId must not be blank" } @@ -405,11 +513,14 @@ internal class InvocationExecutionCoordinator( actorId = PolicyEnforcementHelper.ACTOR_ANONYMOUS, ) } + override suspend fun execute(request: ClaimedResumeExecutionRequest): Any? = - claimedResumeCoordinator.execute(request) + run { + val coordinator = claimedResumeCoordinator + coordinator.execute(request) + } } -private fun PolicyContextBuilder.applySecurityContext( - securityContext: ExecutionSecurityContext, -): PolicyContextBuilder = dataClassification(securityContext.dataClassification) - .classificationSource(securityContext.classificationSource) +private fun PolicyContextBuilder.applySecurityContext(securityContext: ExecutionSecurityContext): PolicyContextBuilder = + dataClassification(securityContext.dataClassification) + .classificationSource(securityContext.classificationSource) diff --git a/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/TramaiInvocationHandler.kt b/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/TramaiInvocationHandler.kt index 3bd2e4fef..ed8ccddb8 100644 --- a/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/TramaiInvocationHandler.kt +++ b/tramai-engine/src/main/kotlin/dev/tramai/engine/invocation/TramaiInvocationHandler.kt @@ -1,6 +1,9 @@ -package dev.tramai.engine.invocation +@file:OptIn(ExperimentalTramaiInternalApi::class) +package dev.tramai.engine.invocation import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.engine.planning.ServiceDefinition import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -38,15 +41,20 @@ internal class TramaiInvocationHandler( private val contextFactory: InvocationContextFactory, private val executionCoordinator: InvocationExecutionCoordinator, ) : InvocationHandler { - override fun invoke(proxy: Any, method: Method, args: Array?): Any? { + override fun invoke( + proxy: Any, + method: Method, + args: Array?, + ): Any? { if (method.declaringClass == Any::class.java) { return handleObjectMethod(proxy, method, args.orEmpty()) } check(!isClosed.get()) { "Tramai runtime is closed" } - val plan = serviceDefinition.operations[method] - ?: throw ConfigurationException("No operation metadata registered for ${method.name}") + val plan = + serviceDefinition.operations[method] + ?: throw ConfigurationException("No operation metadata registered for ${method.name}") // Conversation-ID resolution happens here — before the suspend/blocking // dispatch — exactly as in the monolithic handler, so timing/error @@ -68,23 +76,45 @@ internal class TramaiInvocationHandler( // marker marks this coroutine as engine-owned so a blocking call // that itself invokes close() skips the join (avoiding a // self-deadlock on lifecycleJob). - val result = runBlocking(lifecycleJob + engineThreadMarker.asContextElement(true)) { - executionCoordinator.execute(context) - } + // + // 0.7.1d: a blocking invocation starts its OWN coroutine context, so the governed + // attribution in force on the calling thread is captured BEFORE runBlocking and + // re-installed inside it. Without this bridge a blocking proxy call made from a + // governed run would silently lose its identity and the engine would mint a + // second run id for the same execution. The bridge is transport only: nothing is + // inferred or regenerated when no governed execution is in force. + val governedRun = GovernedRunScope.currentThreadIdentity() + val engineContext = + if (governedRun == null) { + lifecycleJob + engineThreadMarker.asContextElement(true) + } else { + lifecycleJob + + engineThreadMarker.asContextElement(true) + + GovernedRunScope(governedRun) + } + val result = + runBlocking(engineContext) { + executionCoordinator.execute(context) + } // The engine may have closed while this blocking call was in // flight. Never deliver a result computed against a closed engine: // the caller sees the fixed lifecycle error instead. check(!isClosed.get()) { "Tramai runtime is closed" } return result } + private fun invokeSuspend( context: InvocationExecutionContext, args: Array, ): Any { // Kotlin suspend proxies receive the continuation as the last JVM argument. @Suppress("UNCHECKED_CAST") - val continuation = args.lastOrNull() as? Continuation - ?: throw ConfigurationException("Suspend invocation for ${context.plan.definition.method.name} is missing its continuation") + val continuation = + args.lastOrNull() as? Continuation + ?: throw ConfigurationException( + "Suspend invocation for ${context.plan.definition.method.name} is missing " + + "its continuation", + ) val callArguments = args.dropLast(1) // Launch as a child of the CALLER's job (continuation.context, with the @@ -102,27 +132,34 @@ internal class TramaiInvocationHandler( // invokeOnCompletion resumes with a cancellation when the block never // ran (job cancelled pre-start by close()) — otherwise the caller's // suspension would freeze forever. - val resumed = java.util.concurrent.atomic.AtomicReference?>(null) - val launched = synchronized(activeInvocationJobs) { - check(!isClosed.get()) { "Tramai runtime is closed" } - val job = lifecycleScope.launch( - continuation.context.minusKey(kotlin.coroutines.ContinuationInterceptor) + - engineThreadMarker.asContextElement(true), - ) { - var result = runCatching { executionCoordinator.execute(context.copy(arguments = callArguments)) } - // Never deliver a success computed against a closed engine: the - // engine may have closed while the invocation was in flight. - // The caller sees the fixed lifecycle error instead (mirrors - // the blocking path). - if (isClosed.get() && result.isSuccess) { - result = Result.failure(IllegalStateException("Tramai runtime is closed")) - } - resumed.set(result) - continuation.resumeWith(result) + val resumed = + java.util.concurrent.atomic + .AtomicReference?>(null) + val launched = + synchronized(activeInvocationJobs) { + check(!isClosed.get()) { "Tramai runtime is closed" } + val job = + lifecycleScope.launch( + continuation.context.minusKey(kotlin.coroutines.ContinuationInterceptor) + + engineThreadMarker.asContextElement(true), + ) { + var result = + runCatching { + executionCoordinator.execute(context.copy(arguments = callArguments)) + } + // Never deliver a success computed against a closed engine: the + // engine may have closed while the invocation was in flight. + // The caller sees the fixed lifecycle error instead (mirrors + // the blocking path). + if (isClosed.get() && result.isSuccess) { + result = Result.failure(IllegalStateException("Tramai runtime is closed")) + } + resumed.set(result) + continuation.resumeWith(result) + } + activeInvocationJobs += job + job } - activeInvocationJobs += job - job - } launched.invokeOnCompletion { cause -> // Registry mutations obey the same monitor: launch+add (above) and // close()'s snapshot (in close()) are synchronized on @@ -146,15 +183,16 @@ internal class TramaiInvocationHandler( } return COROUTINE_SUSPENDED } + private fun handleObjectMethod( proxy: Any, method: Method, args: Array, - ): Any? = when (method.name) { - "toString" -> "TramaiProxy(${serviceDefinition.serviceType.qualifiedName})" - "hashCode" -> System.identityHashCode(proxy) - "equals" -> proxy === args.firstOrNull() - else -> throw UnsupportedOperationException("Unsupported Object method: ${method.name}") - } + ): Any? = + when (method.name) { + "toString" -> "TramaiProxy(${serviceDefinition.serviceType.qualifiedName})" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args.firstOrNull() + else -> throw UnsupportedOperationException("Unsupported Object method: ${method.name}") + } } - diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/GovernedApprovalAttributionTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/GovernedApprovalAttributionTest.kt new file mode 100644 index 000000000..d39fb7b5f --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/GovernedApprovalAttributionTest.kt @@ -0,0 +1,472 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.engine + +import dev.tramai.core.annotations.AiService +import dev.tramai.core.annotations.Operation +import dev.tramai.core.approval.ApprovalAuthorization +import dev.tramai.core.approval.ApprovalChallenge +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ApprovalToken +import dev.tramai.core.approval.ApprovalValidation +import dev.tramai.core.approval.AuthorizeResumeCommand +import dev.tramai.core.approval.CreateApprovalCommand +import dev.tramai.core.approval.NoOpApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.ValidateResumeCommand +import dev.tramai.core.exception.ApprovalSuspendedException +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.memory.UuidConversationIdProvider +import dev.tramai.core.model.ModelRequest +import dev.tramai.core.model.ModelResponse +import dev.tramai.core.model.ResolvedTool +import dev.tramai.core.model.SideEffectLevel +import dev.tramai.core.model.ToolCall +import dev.tramai.core.model.ToolExecutionContext +import dev.tramai.core.model.ToolResult +import dev.tramai.core.observation.NoOpOperationObserver +import dev.tramai.core.observation.NoOpToolFailureDiagnosticObserver +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.core.policy.ApprovalRequirement +import dev.tramai.core.policy.EnforcementPoint +import dev.tramai.core.policy.NoOpPolicyDecisionAuditEmitter +import dev.tramai.core.policy.PolicyContext +import dev.tramai.core.policy.PolicyDecision +import dev.tramai.core.policy.PolicyEngine +import dev.tramai.core.provider.ModelProvider +import dev.tramai.core.security.NoOpDlpInterceptor +import dev.tramai.core.security.NoOpDlpRedactionAuditEmitter +import dev.tramai.engine.components.EngineComponentFactory +import dev.tramai.security.approval.InMemoryApprovalContinuationStore +import dev.tramai.security.approval.Sha256ToolArgumentsDigester +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import java.time.Clock +import java.util.concurrent.CopyOnWriteArrayList + +/** + * 0.7.1d governed approval attribution. + * + * The durable suspension record is the authority for a continuation: it carries the whole + * [GovernedRunIdentity], and the engine identity inside it must agree with the canonical + * run id. A standalone resume recovers that identity and installs it; a caller-supplied + * identity is only a consistency precondition, so a substitution of ANY component — not + * just the run id — must be rejected before the continuation is claimed or executed. + */ +class GovernedApprovalAttributionTest { + private companion object { + const val TOOL_NAME = "test_calculator" + const val ARGUMENTS_DIGEST = "sha256:12e49c0f5b1f1c5a753a1e98fb8e94a06c58b35c8432b77270d412d5d295e3b9" + } + + @AiService + interface ApprovalIdentityService { + @Operation(prompt = "Answer", model = "logical-model") + suspend fun answer(input: String): String + } + + private class ForbiddenRunIdSource : EngineIdentitySource { + var runIdSamples = 0 + private set + + override fun newWorkflowRunId(): String { + runIdSamples++ + error("engine must not generate a workflow run id inside a governed execution") + } + + override fun newCorrelationId(): String = "corr-governed" + } + + private class RunIdSource( + private val runId: String, + ) : EngineIdentitySource { + var runIdSamples = 0 + private set + + override fun newWorkflowRunId(): String { + runIdSamples++ + return runId + } + + override fun newCorrelationId(): String = "corr-legacy" + } + + /** Requires approval at tool execution for the calculator tool. */ + private class ApprovalPolicyEngine : PolicyEngine { + override suspend fun evaluate(context: PolicyContext): PolicyDecision = + if (context.enforcementPoint == EnforcementPoint.BEFORE_TOOL_EXECUTION) { + PolicyDecision.RequireApproval( + ApprovalRequirement( + toolName = TOOL_NAME, + argumentsDigest = ARGUMENTS_DIGEST, + reason = "testing", + timeoutMillis = 60_000, + ), + ) + } else { + PolicyDecision.Allow + } + } + + private class RecordingProvider : ModelProvider { + var calls = 0 + private set + + override suspend fun complete(request: ModelRequest): ModelResponse { + calls++ + return if (calls == 1) { + ModelResponse( + content = "", + toolCalls = listOf(ToolCall("call-1", TOOL_NAME, """{"x":2,"y":3}""")), + ) + } else { + ModelResponse(content = "Final result: success") + } + } + + override fun providerId(): String = "primary" + } + + /** Records the governed identity in force when the tool finally executes. */ + private class ScopeObservingTool : ResolvedTool { + override val name: String = TOOL_NAME + override val description: String = "Calculator tool" + override val inputSchemaJson: String = "" + override val idempotent: Boolean = false + override val sideEffectLevel: SideEffectLevel = SideEffectLevel.READ_ONLY + val observedIdentities = CopyOnWriteArrayList() + var invocations = 0 + private set + + override suspend fun execute( + input: Any, + context: ToolExecutionContext, + ): ToolResult { + invocations++ + observedIdentities += GovernedRunScope.currentThreadIdentity() + return ToolResult.Success("""{"result":5}""") + } + } + + private class PermitApprovalGateCoordinator : ApprovalGateCoordinator { + var createdApprovals = 0 + private set + private var nextChallengeId = "challenge-1" + + override suspend fun createApproval(command: CreateApprovalCommand): ApprovalChallenge { + createdApprovals++ + val id = nextChallengeId + nextChallengeId = "challenge-2" + return ApprovalChallenge( + approvalId = id, + token = ApprovalToken.parsePresented("token-$id"), + expiresAt = command.expiresAt, + ) + } + + override suspend fun validateResume(command: ValidateResumeCommand): ApprovalValidation = + ApprovalValidation( + approvalId = command.approvalId, + validatedBy = command.consumedBy, + validatedAt = Clock.systemUTC().instant(), + version = command.expectedVersion, + ) + + override suspend fun authorizeResume(command: AuthorizeResumeCommand): ApprovalAuthorization = + ApprovalAuthorization( + approvalId = command.approvalId, + consumedBy = command.consumedBy, + consumedAt = Clock.systemUTC().instant(), + version = command.expectedVersion, + ) + + override suspend fun cancelApproval( + approvalId: String, + expectedVersion: Long, + reason: String, + ) = Unit + } + + /** + * A legacy-only store: implements [SuspendedInvocationStore] but NOT the governed + * capability, exactly like a third-party implementation written before 0.7.1d. + */ + private class LegacyOnlySuspendedInvocationStore : SuspendedInvocationStore { + private val delegate = InMemorySuspendedInvocationStore() + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) = delegate.create(metadata, replayEnvelope) + + override suspend fun get(approvalId: String): SuspendedInvocationMetadata? = delegate.get(approvalId) + + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? = + run { + val store = delegate + store.revealReplayEnvelope(approvalId) + } + + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? = delegate.remove(approvalId) + } + + private class Fixture( + val suspendedStore: SuspendedInvocationStore = InMemorySuspendedInvocationStore(), + val source: EngineIdentitySource = ForbiddenRunIdSource(), + ) { + val provider = RecordingProvider() + val tool = ScopeObservingTool() + val gate = PermitApprovalGateCoordinator() + val continuationStore = InMemoryApprovalContinuationStore(clock = Clock.systemUTC()) + val engine = + TramaiEngine( + EngineComponentFactory.create( + providerRegistry = + dev.tramai.core.provider.ProviderRegistry + .singleProvider(provider), + structuredOutputHandler = null, + toolRegistry = ToolRegistry(mapOf(tool.name to tool)), + operationObserver = NoOpOperationObserver, + operationInterceptor = object : dev.tramai.core.observation.OperationInterceptor {}, + responseCache = NoOpOperationResponseCache, + modelRegistry = dev.tramai.core.model.NoOpModelRegistry, + modelRegistrySettings = + dev.tramai.core.model + .ModelRegistrySettings(), + circuitBreakerSettings = CircuitBreakerSettings(), + retryPolicySettings = RetryPolicySettings(), + tokenBudgetSettings = TokenBudgetSettings(), + promptSanitizer = null, + chatMemory = null, + conversationIdProvider = UuidConversationIdProvider(), + policyEngine = ApprovalPolicyEngine(), + dlpInterceptor = NoOpDlpInterceptor, + dlpRedactionAuditEmitter = NoOpDlpRedactionAuditEmitter, + toolResultFilteringSettings = ToolResultFilteringSettings(), + engineEventObserver = NoOpEngineEventObserver, + toolFailureDiagnosticObserver = NoOpToolFailureDiagnosticObserver, + policyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter, + suspendedInvocationStore = suspendedStore, + approvalContinuationStore = continuationStore, + toolArgumentsDigester = Sha256ToolArgumentsDigester(), + approvalGateCoordinator = gate, + approvalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, + clock = Clock.systemUTC(), + identitySource = source, + ), + ) + + val service = engine.create(ApprovalIdentityService::class) + + suspend fun suspendGoverned(identity: GovernedRunIdentity): ApprovalSuspendedException { + val failure = + runCatching { + withContext(GovernedRunScope(identity)) { service.answer("suspend") } + }.exceptionOrNull() + require(failure is ApprovalSuspendedException) { + "expected ApprovalSuspendedException, got ${failure?.javaClass?.name}: ${failure?.message}" + } + return failure + } + + suspend fun suspendLegacy(): ApprovalSuspendedException { + val failure = runCatching { service.answer("suspend") }.exceptionOrNull() + require(failure is ApprovalSuspendedException) { + "expected ApprovalSuspendedException, got ${failure?.javaClass?.name}: ${failure?.message}" + } + return failure + } + + fun resumeCommand(exception: ApprovalSuspendedException): ResumeApprovalCommand = + ResumeApprovalCommand( + approvalId = exception.approvalId, + approvalExpectedVersion = 0L, + continuationExpectedVersion = 0L, + presentedToken = exception.challenge.token, + resumedBy = "operator", + ) + } + + private data class IdentityOverrides( + val runId: String = "governed-approval-run", + val workload: String = "claims", + val configurationId: String = "claims-prod", + val configurationVersion: String = "17", + val environment: String = "production", + val deploymentId: String = "eu-west-amsterdam-01", + ) + + private fun identity(overrides: IdentityOverrides = IdentityOverrides()): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(overrides.workload), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(overrides.configurationId), + version = ConfigurationVersion(overrides.configurationVersion), + ), + environmentId = EnvironmentId(overrides.environment), + deploymentId = DeploymentId(overrides.deploymentId), + ), + runId = RunId(overrides.runId), + ) + + // ── Suspension ────────────────────────────────────────────────── + + @Test + fun `a governed suspension persists the whole identity and its engine identity agrees`() { + runBlocking { + val fixture = Fixture() + val expected = identity(IdentityOverrides(runId = "governed-approval-run")) + + val exception = fixture.suspendGoverned(expected) + + val metadata = fixture.suspendedStore.get(exception.approvalId) + assertThat(metadata).isNotNull + // The bidirectional invariant: the engine identity the suspension recorded and + // the canonical run identity must be the same run. + assertThat(metadata!!.identity.workflowRunId) + .withFailMessage("engine identity and canonical run identity must agree") + .isEqualTo(expected.runId.value) + val persisted = + (fixture.suspendedStore as GovernedSuspendedInvocationStore) + .governedRunIdentity(exception.approvalId) + assertThat(persisted).isEqualTo(expected) + // Nothing was generated: the canonical identity came from the governed scope. + assertThat((fixture.source as ForbiddenRunIdSource).runIdSamples).isEqualTo(0) + } + } + + @Test + fun `a governed suspension fails closed when the store lacks the governed capability`() { + runBlocking { + val fixture = Fixture(suspendedStore = LegacyOnlySuspendedInvocationStore()) + + assertThatThrownBy { + runBlocking { + withContext(GovernedRunScope(identity())) { fixture.service.answer("suspend") } + } + }.isInstanceOf(ConfigurationException::class.java) + .hasMessageContaining("GovernedSuspendedInvocationStore") + // Failed BEFORE any durable approval/continuation state was created: the + // degradation this prevents is "governed until the first approval boundary". + assertThat(fixture.gate.createdApprovals).isEqualTo(0) + } + } + + // ── Continuation ──────────────────────────────────────────────── + + @Test + fun `a standalone resume recovers and installs the persisted identity`() { + runBlocking { + val fixture = Fixture() + val expected = identity(IdentityOverrides(runId = "governed-standalone-run")) + val exception = fixture.suspendGoverned(expected) + + // No governed scope in force: the durable witness is the authority. + val result = fixture.engine.resumeApproval(fixture.resumeCommand(exception)) + + assertThat(result).isNotNull() + assertThat(fixture.tool.invocations).isEqualTo(1) + assertThat(fixture.tool.observedIdentities) + .withFailMessage("the resumed execution must carry the persisted identity") + .containsExactly(expected) + } + } + + @Test + fun `resume inside the identical governed scope proceeds`() { + runBlocking { + val fixture = Fixture() + val expected = identity(IdentityOverrides(runId = "governed-identical-run")) + val exception = fixture.suspendGoverned(expected) + + val result = + withContext(GovernedRunScope(expected)) { + fixture.engine.resumeApproval(fixture.resumeCommand(exception)) + } + + assertThat(result).isNotNull() + assertThat(fixture.tool.observedIdentities).containsExactly(expected) + } + } + + @Test + fun `every substituted identity component is rejected before the continuation is claimed`() { + runBlocking { + val substitutions = + mapOf( + "workloadId" to identity(IdentityOverrides(workload = "payments")), + "configurationId" to identity(IdentityOverrides(configurationId = "claims-staging")), + "configurationVersion" to identity(IdentityOverrides(configurationVersion = "18")), + "environmentId" to identity(IdentityOverrides(environment = "staging")), + "deploymentId" to identity(IdentityOverrides(deploymentId = "eu-central-frankfurt-01")), + "runId" to identity(IdentityOverrides(runId = "another-run")), + ) + + substitutions.forEach { (component, substituted) -> + val fixture = Fixture() + val persisted = identity() + val exception = fixture.suspendGoverned(persisted) + + assertThatThrownBy { + runBlocking { + withContext(GovernedRunScope(substituted)) { + fixture.engine.resumeApproval(fixture.resumeCommand(exception)) + } + } + }.isInstanceOf(GovernedRunContinuityException::class.java) + .withFailMessage("substituting $component must be rejected") + .hasMessageContaining(exception.approvalId) + assertThat(fixture.tool.invocations) + .withFailMessage("a rejected resume must not execute the tool ($component)") + .isEqualTo(0) + } + } + } + + @Test + fun `a legacy suspension cannot be resumed from a governed execution`() { + runBlocking { + val fixture = Fixture(source = RunIdSource("legacy-run")) + val exception = fixture.suspendLegacy() + + assertThatThrownBy { + runBlocking { + withContext(GovernedRunScope(identity(IdentityOverrides(runId = "legacy-run")))) { + fixture.engine.resumeApproval(fixture.resumeCommand(exception)) + } + } + }.isInstanceOf(GovernedRunContinuityException::class.java) + assertThat(fixture.tool.invocations).isEqualTo(0) + } + } + + @Test + fun `a legacy suspension still resumes as before`() { + runBlocking { + val fixture = Fixture(source = RunIdSource("legacy-run")) + val exception = fixture.suspendLegacy() + + val result = fixture.engine.resumeApproval(fixture.resumeCommand(exception)) + + assertThat(result).isNotNull() + assertThat(fixture.tool.observedIdentities).containsExactly(null) + } + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/GovernedRunScopeIdentityTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/GovernedRunScopeIdentityTest.kt new file mode 100644 index 000000000..5aafeae11 --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/GovernedRunScopeIdentityTest.kt @@ -0,0 +1,356 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.engine + +import dev.tramai.core.annotations.AiService +import dev.tramai.core.annotations.Operation +import dev.tramai.core.approval.NoOpApprovalLifecycleAuditEmitter +import dev.tramai.core.exception.ProviderException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.memory.UuidConversationIdProvider +import dev.tramai.core.model.ModelRequest +import dev.tramai.core.model.ModelResponse +import dev.tramai.core.observation.NoOpOperationObserver +import dev.tramai.core.observation.NoOpToolFailureDiagnosticObserver +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.core.policy.NoOpPolicyDecisionAuditEmitter +import dev.tramai.core.policy.PolicyContext +import dev.tramai.core.policy.PolicyDecision +import dev.tramai.core.policy.PolicyEngine +import dev.tramai.core.provider.ModelProvider +import dev.tramai.core.security.NoOpDlpInterceptor +import dev.tramai.core.security.NoOpDlpRedactionAuditEmitter +import dev.tramai.engine.components.EngineComponentFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import java.time.Clock +import java.util.concurrent.CopyOnWriteArrayList + +/** + * 0.7.1d — governed run identity reaches the engine on BOTH supported proxy paths. + * + * The engine must never mint a second run id inside a governed execution. Suspend + * invocations inherit the caller's coroutine context; blocking invocations start their + * own runBlocking context, so their attribution is bridged across the thread boundary. + * + * The observing provider runs INSIDE the engine's execution, so what it sees is the + * identity the engine itself is about to use. Together with an identity source that + * throws when sampled, each test establishes: + * `EngineExecutionIdentity.workflowRunId == the canonical RunId` on the governed path. + */ +class GovernedRunScopeIdentityTest { + @AiService + interface ScopeIdentityService { + @Operation(prompt = "Answer", model = "logical-model") + suspend fun answerSuspend(input: String): String + + @Operation(prompt = "Answer", model = "logical-model") + fun answerBlocking(input: String): String + } + + /** Fails loudly if the engine tries to mint a run id inside a governed execution. */ + private class ForbiddenRunIdSource : EngineIdentitySource { + var runIdSamples = 0 + private set + + override fun newWorkflowRunId(): String { + runIdSamples++ + error("engine must not generate a workflow run id inside a governed execution") + } + + override fun newCorrelationId(): String = "corr-governed" + } + + private class GeneratedRunIdSource( + private val runId: String, + ) : EngineIdentitySource { + var runIdSamples = 0 + private set + + override fun newWorkflowRunId(): String { + runIdSamples++ + return runId + } + + override fun newCorrelationId(): String = "corr-legacy" + } + + private class CorrelationRecordingPolicyEngine : PolicyEngine { + val seenCorrelationIds = mutableListOf() + + override suspend fun evaluate(context: PolicyContext): PolicyDecision { + seenCorrelationIds += context.correlationId + return PolicyDecision.Allow + } + } + + /** Records the governed identity in force while the engine invokes the provider. */ + private class ObservingProvider( + private val failWith: Throwable? = null, + ) : ModelProvider { + val observedIdentities = CopyOnWriteArrayList() + + override suspend fun complete(request: ModelRequest): ModelResponse { + observedIdentities += GovernedRunScope.currentThreadIdentity() + failWith?.let { throw it } + return ModelResponse(content = "answer") + } + + override fun providerId(): String = "primary" + } + + private fun identity(runId: String): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId(runId), + ) + + private fun buildEngine( + source: EngineIdentitySource, + provider: ModelProvider, + policyEngine: PolicyEngine = CorrelationRecordingPolicyEngine(), + ): TramaiEngine { + val components = + EngineComponentFactory.create( + providerRegistry = + dev.tramai.core.provider.ProviderRegistry + .singleProvider(provider), + structuredOutputHandler = null, + toolRegistry = ToolRegistry(), + operationObserver = NoOpOperationObserver, + operationInterceptor = object : dev.tramai.core.observation.OperationInterceptor {}, + responseCache = NoOpOperationResponseCache, + modelRegistry = dev.tramai.core.model.NoOpModelRegistry, + modelRegistrySettings = + dev.tramai.core.model + .ModelRegistrySettings(), + circuitBreakerSettings = CircuitBreakerSettings(), + retryPolicySettings = RetryPolicySettings(), + tokenBudgetSettings = TokenBudgetSettings(), + promptSanitizer = null, + chatMemory = null, + conversationIdProvider = UuidConversationIdProvider(), + policyEngine = policyEngine, + dlpInterceptor = NoOpDlpInterceptor, + dlpRedactionAuditEmitter = NoOpDlpRedactionAuditEmitter, + toolResultFilteringSettings = ToolResultFilteringSettings(), + engineEventObserver = NoOpEngineEventObserver, + toolFailureDiagnosticObserver = NoOpToolFailureDiagnosticObserver, + policyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter, + suspendedInvocationStore = InMemorySuspendedInvocationStore(), + approvalContinuationStore = null, + toolArgumentsDigester = null, + approvalGateCoordinator = null, + approvalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, + clock = Clock.systemUTC(), + identitySource = source, + ) + return TramaiEngine(components) + } + + // ── Propagation on both proxy paths ───────────────────────────── + + @Test + fun `suspend proxy invocation sees the exact governed identity`() { + runBlocking { + val source = ForbiddenRunIdSource() + val provider = ObservingProvider() + val service = buildEngine(source, provider).create(ScopeIdentityService::class) + val expected = identity("governed-run-suspend") + + val answer = withContext(GovernedRunScope(expected)) { service.answerSuspend("hello") } + + assertThat(answer).isEqualTo("answer") + assertThat(provider.observedIdentities).containsExactly(expected) + // Nothing was generated: the canonical RunId is the only possible source. + assertThat(source.runIdSamples).isEqualTo(0) + } + } + + @Test + fun `blocking proxy invocation sees the exact governed identity`() { + runBlocking { + val source = ForbiddenRunIdSource() + val provider = ObservingProvider() + val service = buildEngine(source, provider).create(ScopeIdentityService::class) + val expected = identity("governed-run-blocking") + + val answer = withContext(GovernedRunScope(expected)) { service.answerBlocking("hello") } + + assertThat(answer).isEqualTo("answer") + assertThat(provider.observedIdentities).containsExactly(expected) + assertThat(source.runIdSamples).isEqualTo(0) + } + } + + @Test + fun `legacy blocking invocation has no governed attribution and still generates its run id`() { + runBlocking { + val source = GeneratedRunIdSource("legacy-run-1") + val provider = ObservingProvider() + val service = buildEngine(source, provider).create(ScopeIdentityService::class) + + service.answerBlocking("hello") + + assertThat(provider.observedIdentities).containsExactly(null) + assertThat(source.runIdSamples).isEqualTo(1) + } + } + + @Test + fun `dispatcher switch preserves the governed identity on the blocking path`() { + runBlocking { + val source = ForbiddenRunIdSource() + val provider = ObservingProvider() + val service = buildEngine(source, provider).create(ScopeIdentityService::class) + val expected = identity("governed-run-dispatcher") + + service.let { + withContext(Dispatchers.IO + GovernedRunScope(expected)) { + it.answerBlocking("hello") + } + } + + assertThat(provider.observedIdentities).containsExactly(expected) + assertThat(source.runIdSamples).isEqualTo(0) + } + } + + // ── No leakage between executions ─────────────────────────────── + + @Test + fun `consecutive governed runs on the same thread cannot leak identity`() { + runBlocking { + val source = ForbiddenRunIdSource() + val provider = ObservingProvider() + val service = buildEngine(source, provider).create(ScopeIdentityService::class) + val first = identity("governed-run-first") + val second = identity("governed-run-second") + + withContext(GovernedRunScope(first)) { service.answerBlocking("one") } + assertThat(GovernedRunScope.currentThreadIdentity()) + .withFailMessage("the bridge must be clear once the governed execution ended") + .isNull() + + withContext(GovernedRunScope(second)) { service.answerBlocking("two") } + assertThat(GovernedRunScope.currentThreadIdentity()).isNull() + + assertThat(provider.observedIdentities).containsExactly(first, second) + } + } + + @Test + fun `concurrent governed runs cannot cross-contaminate`() { + runBlocking { + val leftSource = ForbiddenRunIdSource() + val leftProvider = ObservingProvider() + val leftService = buildEngine(leftSource, leftProvider).create(ScopeIdentityService::class) + val left = identity("governed-run-left") + + val rightSource = ForbiddenRunIdSource() + val rightProvider = ObservingProvider() + val rightService = buildEngine(rightSource, rightProvider).create(ScopeIdentityService::class) + val right = identity("governed-run-right") + + val leftJob = + launch(Dispatchers.Default) { + withContext(GovernedRunScope(left)) { leftService.answerSuspend("l") } + } + val rightJob = + launch(Dispatchers.Default) { + withContext(GovernedRunScope(right)) { rightService.answerSuspend("r") } + } + leftJob.join() + rightJob.join() + + assertThat(leftProvider.observedIdentities).containsExactly(left) + assertThat(rightProvider.observedIdentities).containsExactly(right) + } + } + + @Test + fun `a suspended governed run does not attribute an unrelated call on the same thread`() { + runBlocking { + val source = GeneratedRunIdSource("legacy-run-2") + val provider = ObservingProvider() + val service = buildEngine(source, provider).create(ScopeIdentityService::class) + + // A governed execution that is suspended mid-flight on this thread's event loop. + val suspended = + launch { + withContext(GovernedRunScope(identity("governed-run-suspended"))) { awaitCancellation() } + } + delay(20) + + // ... must not leak its identity into an unrelated, ungoverned blocking call. + service.answerBlocking("unrelated") + + assertThat(provider.observedIdentities).containsExactly(null) + assertThat(source.runIdSamples).isEqualTo(1) + suspended.cancelAndJoin() + } + } + + @Test + fun `failure restores the previous thread context`() { + runBlocking { + val source = ForbiddenRunIdSource() + val provider = ObservingProvider(failWith = IllegalStateException("provider exploded")) + val service = buildEngine(source, provider).create(ScopeIdentityService::class) + + assertThatThrownBy { + runBlocking { + withContext(GovernedRunScope(identity("governed-run-failing"))) { service.answerSuspend("boom") } + } + }.isInstanceOf(ProviderException::class.java) + .hasCauseInstanceOf(IllegalStateException::class.java) + + assertThat(GovernedRunScope.currentThreadIdentity()) + .withFailMessage("a failed governed execution must not leave its identity installed") + .isNull() + } + } + + @Test + fun `cancellation restores the previous thread context`() { + runBlocking { + val cancelled = + launch { + withContext(GovernedRunScope(identity("governed-run-cancelled"))) { awaitCancellation() } + } + delay(20) + cancelled.cancelAndJoin() + + assertThat(GovernedRunScope.currentThreadIdentity()) + .withFailMessage("a cancelled governed execution must not leave its identity installed") + .isNull() + } + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalAttributionPresenceCorruptionTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalAttributionPresenceCorruptionTest.kt new file mode 100644 index 000000000..ffa0ebca1 --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalAttributionPresenceCorruptionTest.kt @@ -0,0 +1,50 @@ +package dev.tramai.engine.approval + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * The attribution parser is an identity boundary: presence and validity are separate equivalence + * classes, and malformed governed state must never be downgraded into legacy state. + * + * The all-blank case is the one that hides: every key is present, so a "treat blanks as absent" + * reading classifies corrupt governed state as an intentionally un-attributed legacy approval — + * exactly the silent downgrade 0.7.1d1 exists to prevent. + */ +class ApprovalAttributionPresenceCorruptionTest { + private val keys = ApprovalAttributionKeys.ALL + + @Test + fun `no reserved keys is an intentionally unattributed legacy approval`() { + assertThat(decodeApprovalAttribution("run-1", emptyMap())) + .isEqualTo(ApprovalRunAttribution.Ungoverned) + } + + @Test + fun `all reserved keys present but blank is corruption never legacy`() { + val blank = keys.associateWith { " " } + + assertThrows { + decodeApprovalAttribution("run-1", blank) + } + } + + @Test + fun `a partial reserved set is corruption`() { + val partial = keys.drop(1).associateWith { "value" } + + assertThrows { + decodeApprovalAttribution("run-1", partial) + } + } + + @Test + fun `a single blank among valid values is corruption`() { + val mixed = keys.associateWith { "value" }.toMutableMap().apply { this[keys.first()] = "" } + + assertThrows { + decodeApprovalAttribution("run-1", mixed) + } + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalResumeSuspensionContractTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalResumeSuspensionContractTest.kt new file mode 100644 index 000000000..140b4183b --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalResumeSuspensionContractTest.kt @@ -0,0 +1,1254 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.engine.approval + +import dev.tramai.core.approval.ApprovalAuthorization +import dev.tramai.core.approval.ApprovalChallenge +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.ApprovalToken +import dev.tramai.core.approval.ApprovalValidation +import dev.tramai.core.approval.AuthorizeResumeCommand +import dev.tramai.core.approval.ClaimedApprovalContinuation +import dev.tramai.core.approval.CreateApprovalCommand +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.ValidateResumeCommand +import dev.tramai.core.exception.ApprovalContinuationConflictException +import dev.tramai.core.exception.ApprovalContinuationNotClaimableException +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.exception.NestedApprovalNotSupportedException +import dev.tramai.core.exception.PolicyViolationException +import dev.tramai.core.exception.StructuredOutputException +import dev.tramai.core.model.Message +import dev.tramai.core.model.MessageRole +import dev.tramai.core.model.ResolvedTool +import dev.tramai.core.model.ToolCall +import dev.tramai.core.model.ToolExecutionContext +import dev.tramai.core.model.ToolResult +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.core.policy.ApprovalRequirement +import dev.tramai.core.policy.EnforcementPoint +import dev.tramai.core.policy.NoOpPolicyDecisionAuditEmitter +import dev.tramai.core.policy.PolicyContext +import dev.tramai.core.policy.PolicyDecision +import dev.tramai.core.policy.PolicyDecisionAuditEmitter +import dev.tramai.core.policy.PolicyEngine +import dev.tramai.engine.EngineEventObserver +import dev.tramai.engine.EngineExecutionIdentity +import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.PolicyEnforcementHelper +import dev.tramai.engine.ReplayEnvelopeFactory +import dev.tramai.engine.ResumeApprovalCommand +import dev.tramai.engine.ResumeDefinitionDigestHelper +import dev.tramai.engine.ResumeOperationReference +import dev.tramai.engine.ResumeToolDeclarationDigestHelper +import dev.tramai.engine.ResumeToolReference +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.ToolRegistry +import dev.tramai.engine.withCapturedSecondaryDiagnostics +import dev.tramai.security.approval.Sha256ToolArgumentsDigester +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The collaborator contracts `ApprovalResumeCoordinator` must honour when a collaborator genuinely + * suspends, and when one fails on the resumed frame. + * + * Every store behind the released resume suites completes synchronously, so the suspension protocol + * itself is never exercised: a suspend call that returns normally leaves the sentinel return of the + * generated state machine unexecuted, and the rethrow that must unwrap a failure arriving on the + * resumed frame never runs. These tests state the contract where it is observable — a collaborator + * suspends for real (and resumes), and the coordinator must either complete the resume or propagate + * the collaborator's own failure to the caller. + * + * Scope note: this is the resume saga, which has no single transactional boundary. What is pinned + * here is that a failure after suspension is *propagated*, that no later step of the chain runs + * after it, and that a denial or a nested-approval requirement still cancels the continuation and + * reaches the caller when the cancellation itself suspends. + */ +class ApprovalResumeSuspensionContractTest { + private val digest = Sha256Digest.of("sha256:" + "1".repeat(64)) + private val input = """{"x":2}""" + private val inputDigest = Sha256ToolArgumentsDigester().digest(SensitiveToolArguments.of(input)) + private val token = ApprovalToken.parsePresented("token-1") + private val toolName = "test_tool" + private val toolCallId = "call-1" + private val approvalId = "approval-1" + private val resumedBy = "admin" + private val identity = EngineExecutionIdentity("wf-1", "corr-1", digest, "policy-v1", "actor-1") + private val command = + ResumeApprovalCommand( + approvalId, + approvalExpectedVersion = 1, + continuationExpectedVersion = 3, + token, + resumedBy, + ) + + private val tool = SuspensionFakeTool(toolName) + private val toolReference = ResumeToolReference(toolName, ResumeToolDeclarationDigestHelper.compute(tool)) + private val operation = approvalOperation(ApprovalRegistryService::class.java.getMethod("first")) + private val service = approvalService(operation) + private val opRef = + ResumeOperationReference( + serviceInterface = ApprovalRegistryService::class.qualifiedName!!, + methodName = "first", + jvmMethodDescriptor = "()Ljava/lang/String;", + resumeDefinitionDigest = ResumeDefinitionDigestHelper.compute(service, operation), + ) + + private val messages = + listOf( + Message(MessageRole.USER, "compute"), + Message(MessageRole.ASSISTANT, "", toolCalls = listOf(ToolCall(toolCallId, toolName, input))), + ) + private val prepared = ReplayEnvelopeFactory.prepareForSuspension(opRef, messages, toolCallId, toolName, 0) + + private fun metadata(replayEnvelopeDigest: Sha256Digest = prepared.digest) = + SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = toolCallId, + toolName = toolName, + toolCallIndex = 0, + correlationId = "corr-1", + identity = identity, + securityContext = ExecutionSecurityContext(), + operationReference = opRef, + replayEnvelopeDigest = replayEnvelopeDigest, + conversationId = null, + historySize = 0, + tokenBudgetSnapshot = null, + toolReference = toolReference, + toolSecurity = null, + ) + + private fun continuation(status: ApprovalContinuationStatus = ApprovalContinuationStatus.PENDING) = + ApprovalContinuation( + approvalId = approvalId, + workflowRunId = "wf-1", + correlationId = "corr-1", + toolCallId = toolCallId, + toolName = toolName, + argumentsDigest = inputDigest, + policyVersion = "policy-v1", + workflowDigest = digest, + status = status, + createdAt = Instant.EPOCH, + approvalExpiresAt = Instant.MAX, + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 3L, + ) + + // ------------------------------------------------------------------ + // Fixture assembly + // ------------------------------------------------------------------ + + private val baseContinuations = RecordingSuspensionContinuationStore(suspensionContinuation()) + private val baseSuspensions = RecordingSuspensionSuspendedStore(metadata(), prepared.envelope) + private val baseGate = RecordingSuspensionGate() + private val baseExecutor = RecordingSuspensionExecutor() + private val baseAudit = RecordingSuspensionAuditEmitter() + + /** The policy-decision audit emitter the fixture wires; set it where the policy seam must suspend. */ + private var policyAudit: PolicyDecisionAuditEmitter = NoOpPolicyDecisionAuditEmitter + + /** The policy decision the fixture's engine returns; set it by the tests that need a non-allow verdict. */ + private var policyDecision: PolicyDecision = PolicyDecision.Allow + + private fun suspensionContinuation() = continuation() + + private fun coordinator( + continuations: ApprovalContinuationStore = baseContinuations, + suspensions: SuspendedInvocationStore = baseSuspensions, + gate: ApprovalGateCoordinator = baseGate, + executor: ClaimedResumeExecutor = baseExecutor, + audit: ApprovalLifecycleAuditEmitter = baseAudit, + ): ApprovalResumeCoordinator { + val registry = ResumeOperationRegistry().also { it.register(service, operation, executor) } + return ApprovalResumeCoordinator( + approvalContinuationStore = continuations, + suspendedInvocationStore = suspensions, + resumeOperationRegistry = registry, + toolRegistry = ToolRegistry(mapOf(toolName to tool)), + toolArgumentsDigester = Sha256ToolArgumentsDigester(), + approvalLifecycleAuditEmitter = audit, + engineEventObserver = RecordingSuspensionObserver(), + claimService = ContinuationClaimService(continuations), + authorizationService = + ReplayAuthorizationService( + gate, + suspensions, + audit, + PolicyEnforcementHelper( + policyEngine = PolicyEngine { policyDecision }, + migrationWarningGuard = AtomicBoolean(false), + auditEmitter = policyAudit, + ), + RecordingSuspensionObserver(), + ), + ) + } + + private fun resume(coordinator: ApprovalResumeCoordinator): Any? = bounded { coordinator.resume(command) } + + /** A coroutine test bounded well below the mutation runner's own timeout. */ + private fun bounded(block: suspend () -> T): T = runBlocking { withTimeout(2_000) { block() } } + + /** + * The collaborator's own failure must reach the caller unchanged. kotlinx's stack-trace recovery + * hands the caller a copy of the original instance, so what identifies the failure is its type and + * message — every test uses a distinct message, which is what makes an earlier collaborator's + * failure distinguishable from the one under test. + */ + private fun assertReachesCaller( + thrown: Throwable, + expected: Throwable, + ) { + assertThat(thrown).isInstanceOf(expected.javaClass) + assertThat(thrown.message).isEqualTo(expected.message) + } + + private fun failureOf(coordinator: ApprovalResumeCoordinator): Throwable { + val caught = + try { + resume(coordinator) + null + } catch (e: Throwable) { + e + } + return caught ?: throw AssertionError("the resume was expected to fail and did not") + } + + // ------------------------------------------------------------------ + // A. The saga completes when every collaborator genuinely suspends + // ------------------------------------------------------------------ + + @Test + fun `a resume completes when every collaborator genuinely suspends`() { + val continuations = SuspendingReadStore(baseContinuations) + val suspensions = ResumeSuspendingSuspendedStore(baseSuspensions) + val gate = ResumeSuspendingGate(baseGate) + val executor = ResumeSuspendingExecutor(baseExecutor) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + policyAudit = ResumeSuspendingPolicyAuditEmitter() + + val result = + resume( + coordinator( + continuations = continuations, + suspensions = suspensions, + gate = gate, + executor = executor, + audit = audit, + ), + ) + + assertThat(result).isEqualTo("executed") + // The store's own state shows the transition, not a snapshot: PENDING v3 -> CLAIMED v4 -> COMPLETED v5. + assertThat(baseContinuations.current.status).isEqualTo(ApprovalContinuationStatus.COMPLETED) + assertThat(baseContinuations.current.version).isEqualTo(5) + // Every suspension must have been resumed for the saga to reach the executor: a synchronous + // fake cannot produce these counts. + assertThat(continuations.readResumes).isEqualTo(2) + assertThat(continuations.claimResumes).isEqualTo(1) + assertThat(continuations.completeResumes).isEqualTo(1) + assertThat(suspensions.metadataLoadResumes).isEqualTo(1) + assertThat(suspensions.revealResumes).isEqualTo(1) + assertThat(suspensions.removeResumes).isEqualTo(1) + assertThat(gate.validateResumes).isEqualTo(1) + assertThat(gate.authorizeResumes).isEqualTo(1) + assertThat(executor.executeResumes).isEqualTo(1) + assertThat(audit.completionResumes).isEqualTo(1) + assertThat((policyAudit as ResumeSuspendingPolicyAuditEmitter).emitResumes).isEqualTo(1) + assertThat(baseContinuations.completedStatus).isEqualTo(ApprovalContinuationStatus.COMPLETED) + } + + @Test + fun `a governed resume completes and executes inside the recovered run scope`() { + val governed = TestGovernedSuspendedInvocationStore(baseSuspensions).also { it.governed += governedRecord() } + val suspensions = ResumeSuspendingGovernedStore(governed) + val executor = ResumeSuspendingExecutor(baseExecutor) + + val result = + resume( + coordinator( + suspensions = suspensions, + executor = executor, + audit = ResumeSuspendingAuditEmitter(baseAudit), + gate = ResumeSuspendingGate(baseGate), + continuations = SuspendingReadStore(baseContinuations), + ), + ) + + assertThat(result).isEqualTo("executed") + assertThat(suspensions.identityReadResumes).isEqualTo(1) + // The recovered identity is installed around the resumed execution: the executor observes the + // governed run it belongs to rather than the caller's empty scope. + assertThat(executor.observedIdentity).isEqualTo(governedRecord().runIdentity) + } + + private fun governedRecord() = GovernedSuspendedInvocation(metadata(), governedIdentity(runId = "wf-1")) + + @Test + fun `a governed executor failure after suspension reaches the caller`() { + val failure = IllegalStateException("governed-executor-failed") + val governed = TestGovernedSuspendedInvocationStore(baseSuspensions).also { it.governed += governedRecord() } + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = failure) + + val thrown = + failureOf( + coordinator( + suspensions = ResumeSuspendingGovernedStore(governed), + executor = executor, + continuations = SuspendingReadStore(baseContinuations), + gate = ResumeSuspendingGate(baseGate), + audit = ResumeSuspendingAuditEmitter(baseAudit), + ), + ) + + assertReachesCaller(thrown, failure) + assertThat(baseContinuations.completeCalls).isZero() + } + + @Test + fun `a policy-decision audit failure after suspension reaches the caller`() { + val failure = IllegalStateException("policy-audit-failed") + policyAudit = ResumeSuspendingPolicyAuditEmitter(failAfterEmitResumes = failure) + + val thrown = + failureOf( + coordinator( + continuations = SuspendingReadStore(baseContinuations), + suspensions = ResumeSuspendingSuspendedStore(baseSuspensions), + gate = ResumeSuspendingGate(baseGate), + executor = ResumeSuspendingExecutor(baseExecutor), + audit = ResumeSuspendingAuditEmitter(baseAudit), + ), + ) + + assertReachesCaller(thrown, failure) + assertThat((policyAudit as ResumeSuspendingPolicyAuditEmitter).emitResumes).isEqualTo(1) + assertThat(baseContinuations.claimCalls).isZero() + } + + // ------------------------------------------------------------------ + // B. A collaborator failure on the resumed frame reaches the caller + // ------------------------------------------------------------------ + + @Test + fun `a continuation read failure after suspension reaches the caller`() { + val failure = IllegalStateException("continuation-read-failed") + val continuations = SuspendingReadStore(baseContinuations, failOnReadNumber = 1, failure = failure) + + assertReachesCaller(failureOf(coordinator(continuations = continuations)), failure) + assertThat(baseContinuations.claimCalls).isZero() + assertThat(baseContinuations.completeCalls).isZero() + } + + @Test + fun `a claim-time read failure after suspension reaches the caller without claiming`() { + val failure = IllegalStateException("claim-read-failed") + val continuations = SuspendingReadStore(baseContinuations, failOnReadNumber = 2, failure = failure) + + assertReachesCaller(failureOf(coordinator(continuations = continuations)), failure) + assertThat(baseContinuations.claimCalls).isZero() + assertThat(baseExecutor.calls).isZero() + } + + @Test + fun `a metadata load failure after suspension reaches the caller`() { + val failure = IllegalStateException("metadata-load-failed") + val suspensions = ResumeSuspendingSuspendedStore(baseSuspensions, failAfterMetadataLoadResumes = failure) + + assertReachesCaller(failureOf(coordinator(suspensions = suspensions)), failure) + assertThat(baseExecutor.calls).isZero() + } + + @Test + fun `a governed identity read failure after suspension reaches the caller before any claim`() { + val failure = IllegalStateException("governed-identity-read-failed") + val governed = TestGovernedSuspendedInvocationStore(baseSuspensions).also { it.governed += governedRecord() } + val suspensions = ResumeSuspendingGovernedStore(governed, failAfterIdentityReadResumes = failure) + + assertReachesCaller(failureOf(coordinator(suspensions = suspensions)), failure) + assertThat(baseContinuations.claimCalls).isZero() + } + + @Test + fun `an ordinary executor failure after suspension inside the governed resume reaches the caller`() { + val failure = IllegalStateException("governed-executor-failed") + val governed = TestGovernedSuspendedInvocationStore(baseSuspensions).also { it.governed += governedRecord() } + val suspensions = ResumeSuspendingGovernedStore(governed) + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = failure) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + + val thrown = failureOf(coordinator(suspensions = suspensions, executor = executor, audit = audit)) + + // The recovered governed identity is observed on the executor's own resumed frame, which is + // only possible inside withContext(GovernedRunScope(...)): proof the case-5 branch was taken. + assertThat(executor.observedIdentity).isNotNull() + assertThat(executor.executeResumes).isEqualTo(1) + assertReachesCaller(thrown, failure) + assertThat(audit.uncertainReasons.single()).startsWith("resume-failed:") + assertThat(audit.uncertainResumes).isEqualTo(1) + } + + @Test + fun `a token validation failure after suspension reaches the caller`() { + val failure = IllegalStateException("token-validation-failed") + val gate = ResumeSuspendingGate(baseGate, failAfterValidateResumes = failure) + + assertReachesCaller(failureOf(coordinator(gate = gate)), failure) + assertThat(baseContinuations.claimCalls).isZero() + } + + @Test + fun `an authorization failure after suspension reaches the caller before the claim`() { + val failure = IllegalStateException("authorization-failed") + val gate = ResumeSuspendingGate(baseGate, failAfterAuthorizeResumes = failure) + + assertReachesCaller(failureOf(coordinator(gate = gate)), failure) + assertThat(baseContinuations.claimCalls).isZero() + } + + @Test + fun `a claim write failure after suspension reaches the caller`() { + val failure = IllegalStateException("claim-write-failed") + val continuations = SuspendingReadStore(baseContinuations, failAfterClaimResumes = failure) + + val thrown = failureOf(coordinator(continuations = continuations)) + assertReachesCaller(thrown, failure) + assertThat(baseExecutor.calls).isZero() + assertThat(baseContinuations.completeCalls).isZero() + // The claim never happened, so the continuation is still claimable: PENDING v3, unchanged. + assertThat(baseContinuations.current.status).isEqualTo(ApprovalContinuationStatus.PENDING) + assertThat(baseContinuations.current.version).isEqualTo(3) + } + + @Test + fun `a replay reveal failure after suspension reaches the caller and completes nothing`() { + val failure = IllegalStateException("replay-reveal-failed") + val suspensions = ResumeSuspendingSuspendedStore(baseSuspensions, failAfterRevealResumes = failure) + + assertReachesCaller(failureOf(coordinator(suspensions = suspensions)), failure) + assertThat(baseExecutor.calls).isZero() + assertThat(baseContinuations.completeCalls).isZero() + assertThat(baseSuspensions.removeCalls).isZero() + } + + @Test + fun `a claimed-arguments integrity mismatch after suspension is rejected`() { + val mismatched = + ClaimedApprovalContinuation( + continuation().copy( + status = ApprovalContinuationStatus.CLAIMED, + claimedBy = resumedBy, + claimedAt = Instant.now(), + argumentsDigest = Sha256Digest.of("sha256:" + "9".repeat(64)), + version = continuation().version + 1, + ), + SensitiveToolArguments.of(input), + ) + baseContinuations.claimWith(mismatched) + val continuations = SuspendingReadStore(baseContinuations) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + + val thrown = failureOf(coordinator(continuations = continuations, audit = audit)) + + assertThat(thrown.message).contains("payload integrity mismatch") + assertThat(baseExecutor.calls).isZero() + assertThat(baseContinuations.completeCalls).isZero() + } + + @Test + fun `a replay-envelope digest mismatch is rejected and reports the mismatch before claiming`() { + val mismatched = + RecordingSuspensionSuspendedStore( + metadata(replayEnvelopeDigest = Sha256Digest.of("sha256:" + "7".repeat(64))), + prepared.envelope, + ) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + + val thrown = + failureOf(coordinator(suspensions = ResumeSuspendingSuspendedStore(mismatched), audit = audit)) + + assertThat(thrown).isInstanceOf(ConfigurationException::class.java) + assertThat(thrown.message).contains("Replay envelope digest mismatch") + assertThat(audit.uncertainReasons.single()).isEqualTo("replay-envelope-digest-mismatch") + assertThat(baseExecutor.calls).isZero() + assertThat(baseContinuations.completeCalls).isZero() + } + + @Test + fun `an executor failure after suspension reaches the caller and completes nothing`() { + val failure = IllegalStateException("executor-failed") + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = failure) + + assertReachesCaller(failureOf(coordinator(executor = executor)), failure) + assertThat(baseContinuations.completeCalls).isZero() + assertThat(baseSuspensions.removeCalls).isZero() + } + + @Test + fun `a completion write failure after suspension reaches the caller`() { + val failure = IllegalStateException("completion-write-failed") + val continuations = + ResumeSuspendingCompletionStore(baseContinuations, failAfterCompleteResumes = failure) + + assertReachesCaller(failureOf(coordinator(continuations = continuations)), failure) + // The completion write never landed, so the continuation stays claimed and unfinished: CLAIMED v4. + assertThat(baseContinuations.current.status).isEqualTo(ApprovalContinuationStatus.CLAIMED) + assertThat(baseContinuations.current.version).isEqualTo(4) + } + + @Test + fun `a structured-output failure after suspension is reported uncertain and propagated`() { + val failure = StructuredOutputException("structured-parse-failed") + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = failure) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + + val thrown = failureOf(coordinator(executor = executor, audit = audit)) + + assertReachesCaller(thrown, failure) + assertThat(audit.uncertainReasons).hasSize(1) + assertThat(audit.uncertainReasons.single()).isEqualTo("structured-parse-failed: StructuredOutputException") + assertThat(audit.uncertainResumes).isEqualTo(1) + } + + @Test + fun `a cancellation resumed at the payload-reveal uncertain-outcome emit reaches the caller`() { + val primary = IllegalStateException("reveal-failed") + val cancellation = CancellationException("reveal-uncertain-cancelled") + val suspensions = ResumeSuspendingSuspendedStore(baseSuspensions, failAfterRevealResumes = primary) + val audit = ResumeSuspendingAuditEmitter(baseAudit, failAfterUncertainResumes = cancellation) + + val thrown = failureOf(coordinator(suspensions = suspensions, audit = audit)) + + assertThat(suspensions.revealResumes).isEqualTo(1) + assertThat(audit.uncertainResumes).isEqualTo(1) + assertReachesCaller(thrown, cancellation) + } + + @Test + fun `a cancellation resumed at the executor inside the claimed-resume block reaches the caller`() { + val cancellation = CancellationException("execute-cancelled") + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = cancellation) + + val thrown = failureOf(coordinator(executor = executor)) + + assertThat(executor.executeResumes).isEqualTo(1) + assertReachesCaller(thrown, cancellation) + } + + @Test + fun `a cancellation resumed at the gate inside the resume authorization reaches the caller`() { + val cancellation = CancellationException("authorize-cancelled") + val gate = ResumeSuspendingGate(baseGate, failAfterAuthorizeResumes = cancellation) + + val thrown = failureOf(coordinator(gate = gate)) + + assertThat(gate.authorizeResumes).isEqualTo(1) + assertReachesCaller(thrown, cancellation) + } + + @Test + fun `a nested-approval requirement raised after suspension is reported uncertain and propagated`() { + val failure = NestedApprovalNotSupportedException(approvalId, "Nested approval not supported") + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = failure) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + + val thrown = failureOf(coordinator(executor = executor, audit = audit)) + + assertThat(thrown).isInstanceOf(NestedApprovalNotSupportedException::class.java) + assertThat(audit.uncertainReasons.single()).isEqualTo("nested-approval-not-supported") + assertThat(audit.uncertainResumes).isEqualTo(1) + } + + @Test + fun `an uncertain-outcome audit failure after suspension does not replace the primary failure`() { + val primary = IllegalStateException("executor-failed") + val auditFailure = IllegalStateException("audit-failed") + val audit = ResumeSuspendingAuditEmitter(baseAudit, failAfterUncertainResumes = auditFailure) + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = primary) + + var thrown: Throwable? = null + val diagnostics = + withCapturedSecondaryDiagnostics { thrown = failureOf(coordinator(executor = executor, audit = audit)) } + + assertReachesCaller(thrown ?: error("the resume was expected to fail"), primary) + assertThat(audit.uncertainResumes).isEqualTo(1) + assertThat(diagnostics.joinToString(" ")).contains("onUncertainOutcome") + } + + // ------------------------------------------------------------------ + // Cancellation delivered to the uncertain-outcome audit on its resumed frame + // + // Each of the three uncertain-outcome call sites resumes into a different state-machine case of `resume` + // (nested-approval, structured-parse-failed, resume-failed). `emitResumeUncertainOutcomeOnce` swallows an + // ordinary audit failure but rethrows cancellation, so a cancellation resumed into one of those cases must + // reach the caller instead of being replaced by the primary failure. + // ------------------------------------------------------------------ + + @Test + fun `a cancellation resumed into the nested-approval uncertain path is not replaced by the primary failure`() { + val primary = NestedApprovalNotSupportedException(approvalId, "Nested approval not supported") + val cancellation = CancellationException("nested-approval-cancelled") + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = primary) + val audit = ResumeSuspendingAuditEmitter(baseAudit, failAfterUncertainResumes = cancellation) + + val thrown = failureOf(coordinator(executor = executor, audit = audit)) + + assertThat(thrown).isInstanceOf(CancellationException::class.java) + assertReachesCaller(thrown, cancellation) + assertThat(audit.uncertainResumes).isEqualTo(1) + assertThat(audit.uncertainReasons.single()).isEqualTo("nested-approval-not-supported") + } + + @Test + fun `a cancellation resumed into the structured-parse uncertain path is not replaced by the primary failure`() { + val primary = StructuredOutputException("structured-parse-failed") + val cancellation = CancellationException("structured-parse-cancelled") + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = primary) + val audit = ResumeSuspendingAuditEmitter(baseAudit, failAfterUncertainResumes = cancellation) + + val thrown = failureOf(coordinator(executor = executor, audit = audit)) + + assertThat(thrown).isInstanceOf(CancellationException::class.java) + assertReachesCaller(thrown, cancellation) + assertThat(audit.uncertainResumes).isEqualTo(1) + assertThat(audit.uncertainReasons.single()).isEqualTo("structured-parse-failed: StructuredOutputException") + } + + @Test + fun `a cancellation resumed into the generic resume-failure uncertain path is not replaced`() { + val primary = IllegalStateException("executor-failed") + val cancellation = CancellationException("resume-failed-cancelled") + val executor = ResumeSuspendingExecutor(baseExecutor, failAfterExecuteResumes = primary) + val audit = ResumeSuspendingAuditEmitter(baseAudit, failAfterUncertainResumes = cancellation) + + var thrown: Throwable? = null + withCapturedSecondaryDiagnostics { thrown = failureOf(coordinator(executor = executor, audit = audit)) } + + val observed = thrown ?: error("the resume was expected to fail") + assertThat(observed).isInstanceOf(CancellationException::class.java) + assertReachesCaller(observed, cancellation) + assertThat(audit.uncertainResumes).isEqualTo(1) + assertThat(audit.uncertainReasons.single()).startsWith("resume-failed") + } + + @Test + fun `a completion audit failure after suspension is recorded and does not fail the resume`() { + val failure = IllegalStateException("completion-audit-failed") + val audit = ResumeSuspendingAuditEmitter(baseAudit, failAfterCompletionResumes = failure) + + val diagnostics = + withCapturedSecondaryDiagnostics { + val result = + resume( + coordinator( + continuations = SuspendingReadStore(baseContinuations), + suspensions = ResumeSuspendingSuspendedStore(baseSuspensions), + gate = ResumeSuspendingGate(baseGate), + executor = ResumeSuspendingExecutor(baseExecutor), + audit = audit, + ), + ) + assertThat(result).isEqualTo("executed") + } + + assertThat(audit.completionResumes).isEqualTo(1) + assertThat(diagnostics.joinToString(" ")).contains("onToolExecutionCompleted") + assertThat(baseContinuations.completedStatus).isEqualTo(ApprovalContinuationStatus.COMPLETED) + } + + // ------------------------------------------------------------------ + // C. Denial and nested-approval requirements across a suspension in the cancellation path + // ------------------------------------------------------------------ + + @Test + fun `a denied resume cancels and reaches the caller when the cancellation suspends`() { + val continuations = + ResumeSuspendingCompletionStore(baseContinuations, suspendingCancel = true) + val suspensions = ResumeSuspendingSuspendedStore(baseSuspensions) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + policyDecision = PolicyDecision.Deny("denied by policy", "resume-denied") + + val thrown = + failureOf( + coordinator( + continuations = continuations, + suspensions = suspensions, + audit = audit, + ), + ) + + assertThat(thrown).isInstanceOf(PolicyViolationException::class.java) + assertThat(continuations.cancelResumes).isEqualTo(1) + assertThat(continuations.cancelCalls).isEqualTo(1) + // The cancellation is a real state transition, not a returned copy: PENDING v3 -> CANCELLED v4. + assertThat(baseContinuations.current.status).isEqualTo(ApprovalContinuationStatus.CANCELLED) + assertThat(baseContinuations.current.version).isEqualTo(4) + assertThat(baseContinuations.claimCalls).isZero() + assertThat(baseExecutor.calls).isZero() + } + + @Test + fun `a nested-approval requirement cancels and reaches the caller when the cancellation suspends`() { + val continuations = + ResumeSuspendingCompletionStore(baseContinuations, suspendingCancel = true) + val suspensions = ResumeSuspendingSuspendedStore(baseSuspensions) + val audit = ResumeSuspendingAuditEmitter(baseAudit) + policyDecision = + PolicyDecision.RequireApproval( + ApprovalRequirement(toolName, inputDigest.value, "nested approval required", 600_000L), + ) + + val thrown = + failureOf( + coordinator( + continuations = continuations, + suspensions = suspensions, + audit = audit, + ), + ) + + assertThat(thrown).isInstanceOf(NestedApprovalNotSupportedException::class.java) + // The requirement is raised during authorization, before anything is claimed: the continuation + // is cancelled, but there is no uncertain outcome to report yet. + assertThat(audit.uncertainResumes).isZero() + assertThat(continuations.cancelResumes).isEqualTo(1) + assertThat(continuations.cancelCalls).isEqualTo(1) + assertThat(baseContinuations.current.status).isEqualTo(ApprovalContinuationStatus.CANCELLED) + assertThat(baseContinuations.current.version).isEqualTo(4) + assertThat(baseExecutor.calls).isZero() + } +} + +// ------------------------------------------------------------------ +// Recording doubles +// ------------------------------------------------------------------ + +private class SuspensionFakeTool( + override val name: String, +) : ResolvedTool { + override val description: String = "test" + override val inputSchemaJson: String = """{"type":"object"}""" + override val idempotent: Boolean = false + override val sideEffectLevel: dev.tramai.core.model.SideEffectLevel = + dev.tramai.core.model.SideEffectLevel.READ_ONLY + override val security: dev.tramai.core.policy.ToolSecurityMetadata? = null + + override suspend fun execute( + input: Any, + context: ToolExecutionContext, + ): ToolResult = ToolResult.Success("{}") +} + +/** + * A continuation store whose state evolves the way the production store's does: PENDING v3 -> CLAIMED v4 -> COMPLETED + * v5, or PENDING -> CANCELLED, each transition bumping the version and refusing a stale expectation. The coordinator's + * contract is about those transitions, so a snapshot double would let it pass against states no real store can reach. + */ +private class RecordingSuspensionContinuationStore( + private var value: ApprovalContinuation, +) : ApprovalContinuationStore { + var reads = 0 + private set + var claimCalls = 0 + private set + var completeCalls = 0 + private set + var cancelCalls = 0 + private set + var completedStatus: ApprovalContinuationStatus? = null + private set + var claimReturnsWith: ClaimedApprovalContinuation? = null + private set + + /** The continuation as the store holds it now, after whatever transitions have run. */ + val current: ApprovalContinuation + get() = value + + fun claimWith(claimed: ClaimedApprovalContinuation) { + claimReturnsWith = claimed + } + + override suspend fun create( + continuation: ApprovalContinuation, + arguments: SensitiveToolArguments, + ): ApprovalContinuation = continuation + + override suspend fun get(approvalId: String): ApprovalContinuation? { + reads++ + return value + } + + override suspend fun claimForExecution( + approvalId: String, + expectedVersion: Long, + claimedBy: String, + ): ClaimedApprovalContinuation { + claimCalls++ + if (value.version != expectedVersion) throw ApprovalContinuationConflictException(approvalId) + if (value.status != ApprovalContinuationStatus.PENDING) { + throw ApprovalContinuationNotClaimableException(approvalId) + } + value = + value.copy( + status = ApprovalContinuationStatus.CLAIMED, + claimedBy = claimedBy, + claimedAt = Instant.now(), + version = value.version + 1, + ) + return claimReturnsWith ?: ClaimedApprovalContinuation(value, SensitiveToolArguments.of("""{"x":2}""")) + } + + override suspend fun complete( + approvalId: String, + expectedVersion: Long, + completedBy: String, + ): ApprovalContinuation { + completeCalls++ + if (value.version != expectedVersion || value.status != ApprovalContinuationStatus.CLAIMED) { + throw ApprovalContinuationConflictException(approvalId) + } + completedStatus = ApprovalContinuationStatus.COMPLETED + value = + value.copy( + status = ApprovalContinuationStatus.COMPLETED, + completedAt = Instant.now(), + version = value.version + 1, + ) + return value + } + + override suspend fun expire( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation = value + + override suspend fun cancel( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation { + cancelCalls++ + if (value.version != expectedVersion || value.status != ApprovalContinuationStatus.PENDING) { + throw ApprovalContinuationConflictException(approvalId) + } + value = value.copy(status = ApprovalContinuationStatus.CANCELLED, version = value.version + 1) + return value + } + + override suspend fun findStaleClaimed( + claimedBefore: Instant, + limit: Int, + ): List = emptyList() + + override suspend fun forceCancelClaimed( + approvalId: String, + expectedVersion: Long, + cancelledBy: String, + reasonCode: String, + ): ApprovalContinuation = value + + override suspend fun sweepExpired(): Int = 0 +} + +private class RecordingSuspensionSuspendedStore( + private val value: SuspendedInvocationMetadata?, + private val envelope: SensitiveReplayEnvelope?, +) : SuspendedInvocationStore { + var removeCalls = 0 + private set + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) = Unit + + override suspend fun get(approvalId: String): SuspendedInvocationMetadata? = value + + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? = envelope + + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? { + removeCalls++ + return value + } +} + +private class RecordingSuspensionGate : ApprovalGateCoordinator { + override suspend fun createApproval(command: CreateApprovalCommand): ApprovalChallenge = + ApprovalChallenge("approval-1", ApprovalToken.parsePresented("token-1"), Instant.MAX) + + override suspend fun validateResume(command: ValidateResumeCommand): ApprovalValidation = + ApprovalValidation(command.approvalId, command.consumedBy, Instant.EPOCH, 0L) + + override suspend fun authorizeResume(command: AuthorizeResumeCommand): ApprovalAuthorization = + ApprovalAuthorization(command.approvalId, command.consumedBy, Instant.EPOCH, 0L, replayed = false) + + override suspend fun cancelApproval( + approvalId: String, + expectedVersion: Long, + reason: String, + ) = Unit +} + +private class RecordingSuspensionExecutor : ClaimedResumeExecutor { + var calls = 0 + private set + + override suspend fun execute(request: ClaimedResumeExecutionRequest): Any? { + calls++ + return "executed" + } +} + +private class RecordingSuspensionAuditEmitter : ApprovalLifecycleAuditEmitter { + val uncertainReasons = mutableListOf() + + override suspend fun onToolExecutionSuspended( + approvalId: String, + workflowRunId: String, + toolName: String, + toolCallId: String, + correlationId: String, + argumentsDigest: Sha256Digest, + expiresAt: Instant, + ) = Unit + + override suspend fun onToolExecutionResumed( + approvalId: String, + workflowRunId: String, + toolName: String, + resumedBy: String, + ) = Unit + + override suspend fun onToolExecutionCompleted( + approvalId: String, + workflowRunId: String, + toolName: String, + completedBy: String, + ) = Unit + + override suspend fun onUncertainOutcome( + approvalId: String, + workflowRunId: String, + toolName: String, + reason: String, + ) { + uncertainReasons += reason + } + + override suspend fun onSuspensionCancelled( + approvalId: String, + workflowRunId: String, + toolName: String, + reason: String, + ) = Unit + + override suspend fun onStaleClaimDetected( + approvalId: String, + workflowRunId: String, + toolName: String, + claimedAt: Instant, + ) = Unit + + override suspend fun onClaimedContinuationForceCancellationRequested( + approvalId: String, + workflowRunId: String, + toolName: String, + cancelledBy: String, + reasonCode: String, + ) = Unit + + override suspend fun onClaimedContinuationForceCancelled( + approvalId: String, + workflowRunId: String, + toolName: String, + cancelledBy: String, + reasonCode: String, + ) = Unit +} + +private class RecordingSuspensionObserver : EngineEventObserver { + override fun onEngineEvent( + name: String, + attributes: Map, + ) = Unit +} + +// ------------------------------------------------------------------ +// Delegating collaborators that genuinely suspend, and optionally fail on the resumed frame. +// Each carries at most one failure per operation it performs: "this collaborator suspends, then +// fails" — no mutation-shaped switches. +// ------------------------------------------------------------------ + +/** + * The continuation read/claim path suspends. [failure] is thrown when the read identified by + * [failOnReadNumber] resumes: a resume reads the continuation twice (eligibility, then claim), and the + * two reads fail different contracts, so the seam is named rather than counted. + */ +private class SuspendingReadStore( + private val delegate: ApprovalContinuationStore, + private val failOnReadNumber: Int? = null, + private val failure: Throwable? = null, + private val failAfterClaimResumes: Throwable? = null, +) : ApprovalContinuationStore by delegate { + var readResumes = 0 + private set + var claimResumes = 0 + private set + var completeResumes = 0 + private set + var reads = 0 + private set + + override suspend fun get(approvalId: String): ApprovalContinuation? { + delay(1) + reads++ + readResumes++ + if (failure != null && failOnReadNumber == reads) throw failure + return delegate.get(approvalId) + } + + override suspend fun claimForExecution( + approvalId: String, + expectedVersion: Long, + claimedBy: String, + ): ClaimedApprovalContinuation { + delay(1) + claimResumes++ + failAfterClaimResumes?.let { throw it } + return delegate.claimForExecution(approvalId, expectedVersion, claimedBy) + } + + override suspend fun complete( + approvalId: String, + expectedVersion: Long, + completedBy: String, + ): ApprovalContinuation { + delay(1) + completeResumes++ + return delegate.complete(approvalId, expectedVersion, completedBy) + } +} + +/** The continuation completion and cancellation path suspends. */ +private class ResumeSuspendingCompletionStore( + private val delegate: ApprovalContinuationStore, + private val failAfterCompleteResumes: Throwable? = null, + private val suspendingCancel: Boolean = false, +) : ApprovalContinuationStore by delegate { + var completeResumes = 0 + private set + var cancelResumes = 0 + private set + var cancelCalls = 0 + private set + + override suspend fun complete( + approvalId: String, + expectedVersion: Long, + completedBy: String, + ): ApprovalContinuation { + delay(1) + completeResumes++ + failAfterCompleteResumes?.let { throw it } + return delegate.complete(approvalId, expectedVersion, completedBy) + } + + override suspend fun cancel( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation { + if (suspendingCancel) delay(1) + cancelResumes++ + cancelCalls++ + return delegate.cancel(approvalId, expectedVersion) + } +} + +/** The suspended-invocation read path (metadata, replay envelope, cleanup) suspends. */ +private class ResumeSuspendingSuspendedStore( + private val delegate: SuspendedInvocationStore, + private val failAfterMetadataLoadResumes: Throwable? = null, + private val failAfterRevealResumes: Throwable? = null, +) : SuspendedInvocationStore by delegate { + var metadataLoadResumes = 0 + private set + var revealResumes = 0 + private set + var removeResumes = 0 + private set + + override suspend fun get(approvalId: String): SuspendedInvocationMetadata? { + delay(1) + metadataLoadResumes++ + failAfterMetadataLoadResumes?.let { throw it } + return delegate.get(approvalId) + } + + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? { + delay(1) + revealResumes++ + failAfterRevealResumes?.let { throw it } + return delegate.revealReplayEnvelope(approvalId) + } + + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? { + delay(1) + removeResumes++ + return delegate.remove(approvalId) + } +} + +/** The governed read path suspends; the recovered identity arrives on the resumed frame. */ +private class ResumeSuspendingGovernedStore( + private val delegate: dev.tramai.engine.GovernedSuspendedInvocationStore, + private val failAfterIdentityReadResumes: Throwable? = null, +) : dev.tramai.engine.GovernedSuspendedInvocationStore by delegate { + var metadataLoadResumes = 0 + private set + var identityReadResumes = 0 + private set + + override suspend fun get(approvalId: String): SuspendedInvocationMetadata? { + delay(1) + metadataLoadResumes++ + return delegate.get(approvalId) + } + + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? { + delay(1) + return delegate.revealReplayEnvelope(approvalId) + } + + override suspend fun governedRunIdentity(approvalId: String): dev.tramai.core.identity.GovernedRunIdentity? { + delay(1) + identityReadResumes++ + failAfterIdentityReadResumes?.let { throw it } + return delegate.governedRunIdentity(approvalId) + } +} + +/** The gate's validation and authorization calls suspend. */ +private class ResumeSuspendingGate( + private val delegate: ApprovalGateCoordinator, + private val failAfterValidateResumes: Throwable? = null, + private val failAfterAuthorizeResumes: Throwable? = null, +) : ApprovalGateCoordinator by delegate { + var validateResumes = 0 + private set + var authorizeResumes = 0 + private set + + override suspend fun validateResume(command: ValidateResumeCommand): ApprovalValidation { + delay(1) + validateResumes++ + failAfterValidateResumes?.let { throw it } + return delegate.validateResume(command) + } + + override suspend fun authorizeResume(command: AuthorizeResumeCommand): ApprovalAuthorization { + delay(1) + authorizeResumes++ + failAfterAuthorizeResumes?.let { throw it } + return delegate.authorizeResume(command) + } +} + +/** The claimed-resume executor suspends before performing the side effect. */ +private class ResumeSuspendingExecutor( + private val delegate: ClaimedResumeExecutor, + private val failAfterExecuteResumes: Throwable? = null, +) : ClaimedResumeExecutor by delegate { + var executeResumes = 0 + private set + var observedIdentity: dev.tramai.core.identity.GovernedRunIdentity? = null + private set + + override suspend fun execute(request: ClaimedResumeExecutionRequest): Any? { + delay(1) + executeResumes++ + observedIdentity = + dev.tramai.core.identity.GovernedRunScope + .resolve(kotlinx.coroutines.currentCoroutineContext()) + failAfterExecuteResumes?.let { throw it } + return delegate.execute(request) + } +} + +/** The lifecycle audit emitter suspends on its post-suspension emissions. */ +private class ResumeSuspendingAuditEmitter( + private val delegate: ApprovalLifecycleAuditEmitter, + private val failAfterUncertainResumes: Throwable? = null, + private val failAfterCompletionResumes: Throwable? = null, +) : ApprovalLifecycleAuditEmitter by delegate { + val uncertainReasons = mutableListOf() + var uncertainResumes = 0 + private set + var completionResumes = 0 + private set + + override suspend fun onUncertainOutcome( + approvalId: String, + workflowRunId: String, + toolName: String, + reason: String, + ) { + delay(1) + uncertainResumes++ + uncertainReasons += reason + failAfterUncertainResumes?.let { throw it } + delegate.onUncertainOutcome(approvalId, workflowRunId, toolName, reason) + } + + override suspend fun onToolExecutionCompleted( + approvalId: String, + workflowRunId: String, + toolName: String, + completedBy: String, + ) { + delay(1) + completionResumes++ + failAfterCompletionResumes?.let { throw it } + delegate.onToolExecutionCompleted(approvalId, workflowRunId, toolName, completedBy) + } +} + +/** + * The policy-decision audit emitter suspends, which is the only suspension point reachable from + * `ReplayAuthorizationService.decideResumePolicy` — and therefore the only way the resume can be + * left sitting on the policy seam's sentinel. + */ +private class ResumeSuspendingPolicyAuditEmitter( + private val failAfterEmitResumes: Throwable? = null, +) : PolicyDecisionAuditEmitter { + var emitResumes = 0 + private set + + override suspend fun emit( + enforcementPoint: EnforcementPoint, + context: PolicyContext, + decision: PolicyDecision, + ) { + delay(1) + emitResumes++ + failAfterEmitResumes?.let { throw it } + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalRunAttributionTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalRunAttributionTest.kt new file mode 100644 index 000000000..b46486f91 --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalRunAttributionTest.kt @@ -0,0 +1,141 @@ +package dev.tramai.engine.approval + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +/** + * Attribution codec for governed approvals (0.7.1d1). + * + * The invariants under test are the ones the ownership model depends on: the run id is never stored + * twice, a partial reserved set is corruption rather than legacy, and application metadata can never + * determine (or silently corrupt) framework attribution. + */ +class ApprovalRunAttributionTest { + @Test + fun `an encoded identity decodes back to the same identity with the run id from the binding`() { + val identity = governedIdentity(runId = "run-0.7.1d1") + + val decoded = decodeApprovalAttribution(identity.runId.value, encodeApprovalAttribution(identity)) + + assertThat(decoded).isEqualTo(ApprovalRunAttribution.Governed(identity)) + } + + @Test + fun `encoding stores exactly the five components and never a run id`() { + val encoded = encodeApprovalAttribution(governedIdentity(runId = "run-0.7.1d1")) + + assertThat(encoded.keys).containsExactlyInAnyOrderElementsOf(ApprovalAttributionKeys.ALL) + assertThat(encoded).doesNotContainValue("run-0.7.1d1") + } + + @Test + fun `the run id is read from the binding, not from the encoded identity`() { + val identity = governedIdentity(runId = "run-0.7.1d1") + + // The binding is authoritative for the run id; the persisted components cannot override it. + val decoded = decodeApprovalAttribution("run-binding", encodeApprovalAttribution(identity)) + + assertThat((decoded as ApprovalRunAttribution.Governed).identity.runId).isEqualTo(RunId("run-binding")) + } + + @Test + fun `an approval with no reserved keys is legacy and stays un-attributed`() { + assertThat(decodeApprovalAttribution("run-legacy", mapOf("requestedBy" to "operator"))).isEqualTo( + ApprovalRunAttribution.Ungoverned, + ) + } + + @Test + fun `every partial reserved set is corruption rather than a partial identity`() { + val encoded = encodeApprovalAttribution(governedIdentity()) + + for (missing in ApprovalAttributionKeys.ALL) { + val partial = encoded - missing + val failure = + assertFailsWith { + decodeApprovalAttribution("run-partial", partial) + } + assertThat(failure.message.orEmpty()).contains("incomplete", "run-partial") + } + } + + @Test + fun `a blank component is corruption, not absence`() { + val encoded = encodeApprovalAttribution(governedIdentity()).toMutableMap() + encoded[ApprovalAttributionKeys.CONFIGURATION_VERSION] = " " + + assertFailsWith { + decodeApprovalAttribution("run-blank", encoded) + } + } + + @Test + fun `application metadata supplying a reserved key is rejected, never overwritten`() { + val injected = governedIdentity(runId = "run-attacker").deployment + val applicationMetadata = + mapOf( + "requestedBy" to "operator", + ApprovalAttributionKeys.DEPLOYMENT to injected.deploymentId.value, + ) + + val failure = + assertFailsWith { + mergeApprovalAttribution(applicationMetadata, ApprovalRunAttribution.Governed(governedIdentity())) + } + + // Rejection, not silent overwrite: the injected value must never reach persistence. + assertThat(failure.message.orEmpty()).contains(ApprovalAttributionKeys.DEPLOYMENT) + assertThat(applicationMetadata[ApprovalAttributionKeys.DEPLOYMENT]).isEqualTo(injected.deploymentId.value) + } + + @Test + fun `merging governed attribution preserves application metadata and adds the reserved set`() { + val identity = governedIdentity() + + val merged = + mergeApprovalAttribution( + mapOf("requestedBy" to "operator", "decisionComment" to "reviewed"), + ApprovalRunAttribution.Governed(identity), + ) + + assertThat(merged) + .containsEntry("requestedBy", "operator") + .containsEntry("decisionComment", "reviewed") + .containsAllEntriesOf(encodeApprovalAttribution(identity)) + assertThat(merged.keys).hasSize(7) + } + + @Test + fun `merging un-attributed legacy approvals is byte-compatible`() { + val applicationMetadata = mapOf("requestedBy" to "operator") + + assertThat(mergeApprovalAttribution(applicationMetadata, ApprovalRunAttribution.Ungoverned)) + .isEqualTo(applicationMetadata) + } + + private fun governedIdentity(runId: String = "run-0.7.1d1"): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims-triage"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-triage-prod"), + version = ConfigurationVersion("v7"), + ), + environmentId = EnvironmentId("prod-eu"), + deploymentId = DeploymentId("deploy-42"), + ), + runId = RunId(runId), + ) +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalSuspensionRenewedRequirementContractTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalSuspensionRenewedRequirementContractTest.kt new file mode 100644 index 000000000..958eaaac2 --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalSuspensionRenewedRequirementContractTest.kt @@ -0,0 +1,102 @@ +package dev.tramai.engine.approval + +import dev.tramai.core.approval.NoOpApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.policy.ApprovalRequirement +import dev.tramai.core.policy.PolicyDecision +import dev.tramai.engine.InMemorySuspendedInvocationStore +import dev.tramai.engine.planning.ServiceDefinition +import dev.tramai.engine.tool.testTool +import dev.tramai.engine.tool.toolOperation +import dev.tramai.engine.tool.toolRequest +import dev.tramai.security.approval.Sha256ToolArgumentsDigester +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneId + +/** + * Contract test for renewed-approval binding validation in + * [ApprovalSuspensionCoordinator] — the resume path that re-validates a renewed + * approval requirement instead of creating a new suspension. + * + * The zero-timeout case is the sharp boundary: a requirement with + * `timeoutMillis = 0` describes an approval that is already expired at the moment + * it would be persisted, so accepting it suspends the run on a requirement that + * can never be satisfied. Rejection is the whole observable behaviour of that + * path, which is why it is asserted directly rather than through the saga. + * + * Deliberately isolated: the validation reads only the request, the policy + * decision and the arguments digester, so every other collaborator here is a + * stand-in that must never be reached. That keeps the test independent of the + * formatter/baseline state of the older suspension coordinator test file. + */ +class ApprovalSuspensionRenewedRequirementContractTest { + private val toolName = "test-tool" + private val input = """{"x":2}""" + private val digester = Sha256ToolArgumentsDigester() + + private fun coordinator() = + ApprovalSuspensionCoordinator( + approvalGateCoordinator = null, + approvalContinuationStore = null, + suspendedInvocationStore = InMemorySuspendedInvocationStore(), + resumeOperationRegistry = ResumeOperationRegistry(), + // Never dereferenced on this path; only carried by the coordinator. + serviceDefinition = + ServiceDefinition( + serviceType = ApprovalSuspensionRenewedRequirementContractTest::class, + systemPrompt = null, + operations = emptyMap(), + ), + resumeExecutor = + ClaimedResumeExecutor { + error("resume execution must not be reached while validating a renewed requirement") + }, + toolArgumentsDigester = digester, + clock = Clock.fixed(Instant.parse("2026-06-07T12:00:00Z"), ZoneId.of("UTC")), + approvalLifecycleAuditEmitter = NoOpApprovalLifecycleAuditEmitter, + ) + + private fun renewedRequest() = + toolRequest(testTool(toolName), toolOperation(toolName)).copy( + resumingApproval = true, + allowRenewedApprovedBindingDuringResume = true, + ) + + @Test + fun `a renewed approval requirement with zero timeout is rejected`() { + // The digest is the real one for `input`, so the binding checks pass and the + // positive-timeout requirement is the only thing that can reject this call. + val decision = + PolicyDecision.RequireApproval( + ApprovalRequirement( + toolName = toolName, + argumentsDigest = digester.digest(SensitiveToolArguments.of(input)).value, + reason = "testing", + timeoutMillis = 0, + ), + ) + + assertThatThrownBy { runBlocking { coordinator().requireApproval(renewedRequest(), decision, input) } } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Renewed approval requirement must have positive timeout") + } + + @Test + fun `a renewed approval requirement with a positive timeout passes validation`() { + val decision = + PolicyDecision.RequireApproval( + ApprovalRequirement( + toolName = toolName, + argumentsDigest = digester.digest(SensitiveToolArguments.of(input)).value, + reason = "testing", + timeoutMillis = 60_000, + ), + ) + + runBlocking { coordinator().requireApproval(renewedRequest(), decision, input) } + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalSuspensionSagaContractTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalSuspensionSagaContractTest.kt new file mode 100644 index 000000000..4fb10f6a2 --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/ApprovalSuspensionSagaContractTest.kt @@ -0,0 +1,999 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.engine.approval + +import dev.tramai.core.approval.ApprovalAuthorization +import dev.tramai.core.approval.ApprovalChallenge +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalGateCoordinator +import dev.tramai.core.approval.ApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.ApprovalToken +import dev.tramai.core.approval.ApprovalValidation +import dev.tramai.core.approval.AuthorizeResumeCommand +import dev.tramai.core.approval.ClaimedApprovalContinuation +import dev.tramai.core.approval.CreateApprovalCommand +import dev.tramai.core.approval.NoOpApprovalLifecycleAuditEmitter +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.ToolArgumentsDigester +import dev.tramai.core.approval.ValidateResumeCommand +import dev.tramai.core.exception.ApprovalContinuationConflictException +import dev.tramai.core.exception.ApprovalContinuationNotFoundException +import dev.tramai.core.exception.ApprovalSuspendedException +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.model.Message +import dev.tramai.core.model.MessageRole +import dev.tramai.core.model.ResolvedTool +import dev.tramai.core.model.SideEffectLevel +import dev.tramai.core.model.ToolCall +import dev.tramai.core.model.ToolExecutionContext +import dev.tramai.core.model.ToolResult +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.core.policy.ApprovalRequirement +import dev.tramai.core.policy.PolicyDecision +import dev.tramai.engine.EngineExecutionIdentity +import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.tool.ToolExecutionRequest +import dev.tramai.security.approval.Sha256ToolArgumentsDigester +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import kotlin.coroutines.resumeWithException + +/** + * Contract tests for the suspension saga in [ApprovalSuspensionCoordinator]. + * + * The saga has three suspend layers, and a failure can arrive on the resumed frame of any of them: + * + * ``` + * suspendToolExecution the saga itself + * └── compensateSuspension ordinary-failure recovery, in reverse order + * └── compensateStep per-action guard: ordinary failures are swallowed, cancellation is not + * └── the generated `compensateSuspension$2$*` lambdas + * └── suspendedInvocationStore.remove / continuationStore.cancel / gate.cancelApproval + * ``` + * + * Every collaborator below genuinely suspends (`delay`) before it either answers or fails, so the generated + * state-machine sentinel returns and case-entry rethrows actually execute. A synchronous double cannot + * reach them: the call returns normally, the sentinel branch is never taken, and the rethrow that must + * unwrap a failure delivered on the resumed frame never runs. + * + * The doubles stay small but they do not permit states the real SPI forbids: the continuation store refuses a + * stale expectation and a non-PENDING status, and cancellation of a continuation that was never persisted + * fails the way the production store fails. + */ +class ApprovalSuspensionSagaContractTest { + private val fixedClock = Clock.fixed(Instant.parse("2026-06-07T12:00:00Z"), ZoneId.of("UTC")) + private val digester: ToolArgumentsDigester = Sha256ToolArgumentsDigester() + private val digest = Sha256Digest.of("sha256:" + "1".repeat(64)) + private val toolName = "test_tool" + private val toolCallId = "call-1" + private val input = """{"x":2}""" + private val workflowRunId = "wf-1" + private val correlationId = "corr-1" + private val approvalId = "challenge-1" + private val identity = EngineExecutionIdentity(workflowRunId, correlationId, digest, "policy-v1", "actor-1") + + private val tool: ResolvedTool = SagaFakeTool(toolName) + private val toolCall = ToolCall(toolCallId, toolName, input) + private val operation = approvalOperation(ApprovalRegistryService::class.java.getMethod("first")) + + /** The single ordered log every collaborator appends to: this is how the saga's order is proved. */ + private val events = mutableListOf() + + private val gate = SagaGate(events) + private val continuations = SagaContinuationStore(events) + private val suspensions = SagaGovernedSuspendedStore(events) + private val plainSuspensions = SagaSuspendedStore(events) + private val audit = SagaAuditEmitter(events) + + private fun request( + resumingApproval: Boolean = false, + parentApprovalId: String? = null, + allowRenewed: Boolean = false, + ) = ToolExecutionRequest( + tool = tool, + toolCall = toolCall, + operation = operation, + correlationId = correlationId, + securityContext = ExecutionSecurityContext(), + identity = identity, + messages = + listOf( + Message(MessageRole.USER, "hi"), + Message(MessageRole.ASSISTANT, "", toolCalls = listOf(toolCall)), + ), + toolCallIndex = 0, + resumingApproval = resumingApproval, + parentApprovalId = parentApprovalId, + allowRenewedApprovedBindingDuringResume = allowRenewed, + ) + + private fun requireDecision(timeoutMillis: Long = 60_000) = + PolicyDecision.RequireApproval( + ApprovalRequirement( + toolName = toolName, + argumentsDigest = digester.digest(SensitiveToolArguments.of(input)).value, + reason = "testing", + timeoutMillis = timeoutMillis, + ), + ) + + private fun coordinator( + gate: ApprovalGateCoordinator = this.gate, + continuations: ApprovalContinuationStore? = this.continuations, + suspensions: SuspendedInvocationStore = this.suspensions, + audit: ApprovalLifecycleAuditEmitter = this.audit, + ) = ApprovalSuspensionCoordinator( + approvalGateCoordinator = gate, + approvalContinuationStore = continuations, + suspendedInvocationStore = suspensions, + resumeOperationRegistry = ResumeOperationRegistry(), + serviceDefinition = approvalService(operation), + resumeExecutor = StubSagaExecutor(), + toolArgumentsDigester = digester, + clock = fixedClock, + approvalLifecycleAuditEmitter = audit, + ) + + /** Bounded, deterministic driver: the saga is driven inside the governed scope [scope] installs, if any. */ + private fun bounded( + scope: GovernedRunIdentity? = null, + block: suspend () -> T, + ): T = + runBlocking { + withTimeout(2_000) { + if (scope == null) block() else withContext(GovernedRunScope(scope)) { block() } + } + } + + /** + * kotlinx's stack-trace recovery hands the caller a *copy* of the throwable, so instance identity is not the + * contract. What is: the exact failure - by type and by its unique message - reaches the caller. + */ + private fun assertReachesCaller( + thrown: Throwable, + expected: Throwable, + ) { + assertThat(thrown::class).isEqualTo(expected::class) + assertThat(thrown.message).isEqualTo(expected.message) + } + + /** Drives one saga to its outcome, which the suspension contract requires to be a throw. */ + private suspend fun drive(coordinator: ApprovalSuspensionCoordinator): Throwable { + try { + coordinator.requireApproval(request(), requireDecision(), input) + } catch (thrown: Throwable) { + return thrown + } + throw AssertionError("the suspension saga was expected to terminate by throwing") + } + + private fun suspensionOf( + coordinator: ApprovalSuspensionCoordinator, + scope: GovernedRunIdentity? = null, + ): Throwable = bounded(scope) { drive(coordinator) } + + // ------------------------------------------------------------------ + // The intended suspension path + // ------------------------------------------------------------------ + + @Test + fun `the saga creates challenge continuation suspended invocation and audit then throws the suspension`() { + val thrown = suspensionOf(coordinator(), scope = governedIdentity(workflowRunId)) + + assertThat(thrown).isInstanceOf(ApprovalSuspendedException::class.java) + val suspended = thrown as ApprovalSuspendedException + assertThat(suspended.approvalId).isEqualTo(approvalId) + assertThat(suspended.workflowRunId).isEqualTo(workflowRunId) + assertThat(suspended.continuationVersion).isEqualTo(0L) + + // The saga's order, proved rather than inferred: nothing happens before the challenge exists. + assertThat(events) + .containsExactly("gate.create", "store.create", "suspended.createGoverned", "audit.suspended") + + assertThat(gate.created).isEqualTo(1) + assertThat(continuations.created?.status).isEqualTo(ApprovalContinuationStatus.PENDING) + assertThat(continuations.created?.version).isEqualTo(0L) + assertThat(audit.suspended).isEqualTo(1) + assertThat(suspensions.governedCreates).isEqualTo(1) + assertThat(suspensions.plainCreates).isZero() + + // Suspension is the intended outcome, not a failure: it never compensates. + assertThat(gate.cancelled).isZero() + assertThat(continuations.cancelled).isZero() + assertThat(suspensions.removes).isZero() + } + + @Test + fun `an ungoverned saga persists through the plain store operation`() { + // The same store instance implements both SPI halves, so the branch taken is directly observable. + val thrown = suspensionOf(coordinator()) + + assertThat(thrown).isInstanceOf(ApprovalSuspendedException::class.java) + assertThat(events).containsExactly("gate.create", "store.create", "suspended.create", "audit.suspended") + assertThat(suspensions.plainCreates).isEqualTo(1) + assertThat(suspensions.governedCreates).isZero() + } + + // ------------------------------------------------------------------ + // Compensation gradient: compensation must reflect what was created + // ------------------------------------------------------------------ + + @Test + fun `a refusal before the challenge exists compensates nothing`() { + val failure = IllegalStateException("gate-refused") + val thrown = suspensionOf(coordinator(gate = RefusingGate(failure))) + + assertReachesCaller(thrown, failure) + assertThat(events).isEmpty() + } + + @Test + fun `a failure after the challenge but before the continuation is compensated in reverse order`() { + val failure = IllegalStateException("continuation-create-failed") + val thrown = + suspensionOf( + coordinator(continuations = FailingContinuationStore(continuations, failOnCreate = failure)), + ) + + assertReachesCaller(thrown, failure) + assertThat(events).containsExactly("gate.create", "suspended.remove", "gate.cancel") + assertThat(gate.cancelled).isEqualTo(1) + // Nothing persisted, so nothing was cancelled: the store refuses the impossible transition and + // compensation swallows that refusal rather than replacing the initiating failure. + assertThat(continuations.created).isNull() + assertThat(continuations.cancelled).isZero() + } + + @Test + fun `a failure on the challenge creation's resumed frame reaches the caller and compensates nothing`() { + val failure = IllegalStateException("challenge-create-failed") + val thrown = suspensionOf(coordinator(gate = FailingGate(gate, failOnCreate = failure))) + + // The gate suspended and then failed: the failure must arrive unwrapped, and because the challenge never + // existed there is nothing to compensate for. + assertReachesCaller(thrown, failure) + assertThat(events).isEmpty() + assertThat(gate.cancelled).isZero() + assertThat(continuations.created).isNull() + } + + @Test + fun `a failure after the continuation is persisted cancels it`() { + val failure = IllegalStateException("suspended-create-failed") + val thrown = + suspensionOf( + coordinator(suspensions = FailingSuspendedStore(plainSuspensions, failOnCreate = failure)), + ) + + assertReachesCaller(thrown, failure) + assertThat(events) + .containsExactly( + "gate.create", + "store.create", + "suspended.remove", + "store.cancel", + "gate.cancel", + ) + assertThat(continuations.cancelled).isEqualTo(1) + assertThat(continuations.state?.status).isEqualTo(ApprovalContinuationStatus.CANCELLED) + assertThat(continuations.state?.version).isEqualTo(1L) + assertThat(gate.cancelled).isEqualTo(1) + } + + @Test + fun `a failure during the suspension audit compensates everything that was created`() { + val failure = IllegalStateException("audit-failed") + val thrown = + suspensionOf( + coordinator(audit = FailingAuditEmitter(audit, failOnSuspended = failure)), + ) + + assertReachesCaller(thrown, failure) + assertThat(events) + .containsExactly( + "gate.create", + "store.create", + "suspended.create", + "suspended.remove", + "store.cancel", + "gate.cancel", + ) + assertThat(continuations.state?.status).isEqualTo(ApprovalContinuationStatus.CANCELLED) + } + + @Test + fun `an ordinary failure inside compensation is swallowed and compensation continues`() { + val failure = IllegalStateException("audit-failed") + val compensationFailure = IllegalStateException("remove-failed") + val failures = FailingSuspendedStore(plainSuspensions, failOnRemove = compensationFailure) + val thrown = + suspensionOf( + coordinator( + suspensions = failures, + audit = FailingAuditEmitter(audit, failOnSuspended = failure), + ), + ) + + // The initiating failure stays primary; the compensation failure does not replace it. + assertReachesCaller(thrown, failure) + assertThat(events) + .containsExactly("gate.create", "store.create", "suspended.create", "store.cancel", "gate.cancel") + assertThat(failures.removeAttempts).isEqualTo(1) + assertThat(continuations.cancelled).isEqualTo(1) + assertThat(gate.cancelled).isEqualTo(1) + } + + @Test + fun `a failure on the continuation cancellation still runs the approval cancellation`() { + val failure = IllegalStateException("audit-failed") + val stepFailure = IllegalStateException("continuation-cancel-failed") + val thrown = + suspensionOf( + coordinator( + continuations = FailingContinuationStore(continuations, failOnCancel = stepFailure), + audit = FailingAuditEmitter(audit, failOnSuspended = failure), + ), + ) + + // The middle compensation step failed; the last one still ran, and the initiating failure stayed primary. + assertReachesCaller(thrown, failure) + assertThat(events) + .containsExactly("gate.create", "store.create", "suspended.create", "suspended.remove", "gate.cancel") + assertThat(continuations.cancelled).isZero() + assertThat(gate.cancelled).isEqualTo(1) + } + + @Test + fun `a failure on the approval cancellation is swallowed by the last compensation step`() { + val failure = IllegalStateException("audit-failed") + val stepFailure = IllegalStateException("approval-cancel-failed") + val thrown = + suspensionOf( + coordinator( + gate = FailingGate(gate, failOnCancel = stepFailure), + audit = FailingAuditEmitter(audit, failOnSuspended = failure), + ), + ) + + assertReachesCaller(thrown, failure) + assertThat(events) + .containsExactly( + "gate.create", + "store.create", + "suspended.create", + "suspended.remove", + "store.cancel", + ) + assertThat(continuations.cancelled).isEqualTo(1) + assertThat(gate.cancelled).isZero() + } + + // ------------------------------------------------------------------ + // Cancellation is not an ordinary failure + // ------------------------------------------------------------------ + + @Test + fun `a cancellation as the initiating failure propagates without compensation`() { + val cancellation = CancellationException("cancelled-by-caller") + val thrown = suspensionOf(coordinator(gate = RefusingGate(cancellation))) + + assertReachesCaller(thrown, cancellation) + assertThat(events).isEmpty() + } + + @Test + fun `a cancellation delivered to a compensation action propagates and stops later steps`() { + val failure = IllegalStateException("audit-failed") + val cancellation = CancellationException("cancelled-during-compensation") + val failures = FailingSuspendedStore(plainSuspensions, failOnRemove = cancellation) + val thrown = + suspensionOf( + coordinator( + suspensions = failures, + audit = FailingAuditEmitter(audit, failOnSuspended = failure), + ), + ) + + // compensateStep rethrows cancellation instead of swallowing it, so the later steps never run. + assertReachesCaller(thrown, cancellation) + assertThat(events).containsExactly("gate.create", "store.create", "suspended.create") + assertThat(failures.removeAttempts).isEqualTo(1) + assertThat(continuations.cancelled).isZero() + assertThat(gate.cancelled).isZero() + } + + // ------------------------------------------------------------------ + // Resumed-frame routing of the later compensation actions + // + // The g1G4d work cancelled the FIRST compensation action. These two drive the SECOND and the THIRD: + // the action genuinely returns COROUTINE_SUSPENDED, the test resumes it later with its own + // CancellationException, and the assertion is that the caller still observes that cancellation + // instead of compensation swallowing it. + // ------------------------------------------------------------------ + + @Test + fun `a cancellation delivered to the second compensation action after suspension reaches the caller`(): Unit = + bounded { + val initiating = IllegalStateException("audit-failed") + val cancellation = CancellationException("second-action-cancelled") + val second = PausingContinuationStore(continuations) + val saga = + coordinator( + continuations = second, + audit = FailingAuditEmitter(audit, failOnSuspended = initiating), + ) + var thrown: Throwable? = null + + coroutineScope { + val job = launch { thrown = drive(saga) } + + second.suspended.await() + assertThat(second.suspendCount).isEqualTo(1) + assertThat(events) + .containsExactly("gate.create", "store.create", "suspended.create", "suspended.remove") + second.resumeWith(cancellation) + job.join() + } + + assertThat(thrown).isInstanceOf(CancellationException::class.java) + // The test supplies this exact instance; kotlinx's stack-trace recovery hands the caller a copy of it, + // so what is asserted is the same cancellation by type and by its unique message (see assertReachesCaller). + assertReachesCaller(thrown!!, cancellation) + assertThat(gate.cancelled).isZero() + } + + @Test + fun `a cancellation delivered to the third compensation action after suspension reaches the caller`(): Unit = + bounded { + val initiating = IllegalStateException("audit-failed") + val cancellation = CancellationException("third-action-cancelled") + val third = PausingGate(gate) + val saga = + coordinator( + gate = third, + audit = FailingAuditEmitter(audit, failOnSuspended = initiating), + ) + var thrown: Throwable? = null + + coroutineScope { + val job = launch { thrown = drive(saga) } + + third.suspended.await() + assertThat(third.suspendCount).isEqualTo(1) + assertThat(events) + .containsExactly( + "gate.create", + "store.create", + "suspended.create", + "suspended.remove", + "store.cancel", + ) + third.resumeWith(cancellation) + job.join() + } + + assertThat(thrown).isInstanceOf(CancellationException::class.java) + // The test supplies this exact instance; kotlinx's stack-trace recovery hands the caller a copy of it, + // so what is asserted is the same cancellation by type and by its unique message (see assertReachesCaller). + assertReachesCaller(thrown!!, cancellation) + assertThat(gate.cancelled).isZero() + assertThat(continuations.cancelled).isEqualTo(1) + } + + // Suspension inside the compensation layers + // ------------------------------------------------------------------ + + @Test + fun `every compensation action genuinely suspends before it runs`() { + val failure = IllegalStateException("audit-failed") + val thrown = + suspensionOf(coordinator(audit = FailingAuditEmitter(audit, failOnSuspended = failure))) + + assertReachesCaller(thrown, failure) + assertThat(events).hasSize(6) + // Each count is only reachable after the call suspended and resumed: a synchronous double + // cannot produce a single one of them. + assertThat(suspensions.removeResumes).isEqualTo(1) + assertThat(continuations.cancelResumes).isEqualTo(1) + assertThat(gate.cancelResumes).isEqualTo(1) + } + + @Test + fun `a governed saga requires a store that can persist the governed record`() { + // A governed run scope with a store that cannot persist the canonical identity: the saga refuses + // before creating anything, so there is nothing to compensate. + val thrown = + suspensionOf( + coordinator(suspensions = SagaSuspendedStore(events)), + scope = governedIdentity(workflowRunId), + ) + + assertThat(thrown).isInstanceOf(ConfigurationException::class.java) + assertThat(events).isEmpty() + } +} + +// ------------------------------------------------------------------ +// Semantic collaborators +// +// Each one suspends before it answers (that is the obligation under test), records what it was asked to do +// into the shared ordered log, and refuses states the production SPI forbids. +// ------------------------------------------------------------------ + +/** The approval gate: creates the challenge the saga is built around and cancels it during compensation. */ +private class SagaGate( + private val events: MutableList, + private val approvalId: String = "challenge-1", +) : ApprovalGateCoordinator { + var created = 0 + private set + var cancelled = 0 + private set + var cancelResumes = 0 + private set + var lastCancelReason: String? = null + private set + + override suspend fun createApproval(command: CreateApprovalCommand): ApprovalChallenge { + delay(1) + created++ + events += "gate.create" + val token = ApprovalToken.parsePresented("token-1") + return ApprovalChallenge(approvalId, token, Instant.parse("2026-06-07T12:10:00Z")) + } + + override suspend fun cancelApproval( + approvalId: String, + expectedVersion: Long, + reason: String, + ) { + delay(1) + cancelResumes++ + cancelled++ + lastCancelReason = reason + events += "gate.cancel" + } + + override suspend fun validateResume(command: ValidateResumeCommand): ApprovalValidation = error("unused") + + override suspend fun authorizeResume(command: AuthorizeResumeCommand): ApprovalAuthorization = error("unused") +} + +/** A gate that refuses before doing anything: the saga has no challenge to compensate for. */ +private class RefusingGate( + private val failure: Throwable, +) : ApprovalGateCoordinator { + override suspend fun createApproval(command: CreateApprovalCommand): ApprovalChallenge = throw failure + + override suspend fun cancelApproval( + approvalId: String, + expectedVersion: Long, + reason: String, + ) = Unit + + override suspend fun validateResume(command: ValidateResumeCommand): ApprovalValidation = error("unused") + + override suspend fun authorizeResume(command: AuthorizeResumeCommand): ApprovalAuthorization = error("unused") +} + +/** + * The continuation store: PENDING v0 on create, CANCELLED v1 on cancel. It refuses a stale expectation and a + * non-PENDING status the way the production store does, so the saga cannot pass against a state that cannot + * exist. + */ +private class SagaContinuationStore( + private val events: MutableList, +) : ApprovalContinuationStore { + var created: ApprovalContinuation? = null + private set + var state: ApprovalContinuation? = null + private set + var cancelled = 0 + private set + var cancelResumes = 0 + private set + + override suspend fun create( + continuation: ApprovalContinuation, + arguments: SensitiveToolArguments, + ): ApprovalContinuation { + delay(1) + created = continuation + state = continuation + events += "store.create" + return continuation + } + + override suspend fun get(approvalId: String): ApprovalContinuation? = state + + override suspend fun claimForExecution( + approvalId: String, + expectedVersion: Long, + claimedBy: String, + ): ClaimedApprovalContinuation = error("not part of the suspension saga") + + override suspend fun complete( + approvalId: String, + expectedVersion: Long, + completedBy: String, + ): ApprovalContinuation = error("not part of the suspension saga") + + override suspend fun expire( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation = error("not part of the suspension saga") + + override suspend fun cancel( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation { + delay(1) + cancelResumes++ + val current = state ?: throw ApprovalContinuationNotFoundException(approvalId) + if (current.version != expectedVersion || current.status != ApprovalContinuationStatus.PENDING) { + throw ApprovalContinuationConflictException(approvalId) + } + cancelled++ + events += "store.cancel" + state = current.copy(status = ApprovalContinuationStatus.CANCELLED, version = current.version + 1) + return state!! + } + + override suspend fun findStaleClaimed( + claimedBefore: Instant, + limit: Int, + ): List = emptyList() + + override suspend fun forceCancelClaimed( + approvalId: String, + expectedVersion: Long, + cancelledBy: String, + reasonCode: String, + ): ApprovalContinuation = error("not part of the suspension saga") + + override suspend fun sweepExpired(): Int = 0 +} + +/** The suspended-invocation store, without the governed half. */ +private class SagaSuspendedStore( + private val events: MutableList, +) : SuspendedInvocationStore { + var creates = 0 + private set + var createResumes = 0 + private set + var removes = 0 + private set + var removeResumes = 0 + private set + var persisted: SuspendedInvocationMetadata? = null + private set + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) { + delay(1) + createResumes++ + creates++ + persisted = metadata + events += "suspended.create" + } + + override suspend fun get(approvalId: String) = persisted?.takeIf { it.approvalId == approvalId } + + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? = null + + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? { + delay(1) + removeResumes++ + removes++ + events += "suspended.remove" + val removed = persisted?.takeIf { it.approvalId == approvalId } + persisted = null + return removed + } +} + +/** + * A store implementing both halves of the SPI. Which operation the saga uses is therefore directly observable, + * which is exactly what the governed-vs-ungoverned branch is about. + */ +private class SagaGovernedSuspendedStore( + private val events: MutableList, +) : GovernedSuspendedInvocationStore { + var governedCreates = 0 + private set + var governedCreateResumes = 0 + private set + var plainCreates = 0 + private set + var plainCreateResumes = 0 + private set + var removes = 0 + private set + var removeResumes = 0 + private set + var persistedGoverned: GovernedSuspendedInvocation? = null + private set + + override suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) { + delay(1) + governedCreateResumes++ + governedCreates++ + persistedGoverned = suspended + events += "suspended.createGoverned" + } + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) { + delay(1) + plainCreateResumes++ + plainCreates++ + events += "suspended.create" + } + + override suspend fun get(approvalId: String): SuspendedInvocationMetadata? = null + + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? = null + + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? { + delay(1) + removeResumes++ + removes++ + events += "suspended.remove" + return null + } + + override suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? = + persistedGoverned?.takeIf { it.metadata.approvalId == approvalId }?.runIdentity +} + +/** The audit emitter: the saga's last forward step before it throws the suspension. */ +private class SagaAuditEmitter( + private val events: MutableList, +) : ApprovalLifecycleAuditEmitter by NoOpApprovalLifecycleAuditEmitter { + var suspended = 0 + private set + var suspendedResumes = 0 + private set + + override suspend fun onToolExecutionSuspended( + approvalId: String, + workflowRunId: String, + toolName: String, + toolCallId: String, + correlationId: String, + argumentsDigest: Sha256Digest, + expiresAt: Instant, + ) { + delay(1) + suspendedResumes++ + suspended++ + events += "audit.suspended" + } +} + +private class StubSagaExecutor : ClaimedResumeExecutor { + override suspend fun execute(request: ClaimedResumeExecutionRequest): Any? = "executed" +} + +private class SagaFakeTool( + override val name: String, +) : ResolvedTool { + override val description: String = "test" + override val inputSchemaJson: String = """{"type":"object"}""" + override val idempotent: Boolean = false + override val sideEffectLevel: SideEffectLevel = SideEffectLevel.READ_ONLY + override val security: dev.tramai.core.policy.ToolSecurityMetadata? = null + + override suspend fun execute( + input: Any, + context: ToolExecutionContext, + ): ToolResult = ToolResult.Success("{}") +} + +// ------------------------------------------------------------------ +// Failure injection: one semantic knob per collaborator call site +// ------------------------------------------------------------------ + +/** The gate refuses at creation (before suspending) or fails on creation's resumed frame. */ +private class FailingGate( + private val delegate: ApprovalGateCoordinator, + private val failOnCreate: Throwable? = null, + private val failOnCancel: Throwable? = null, +) : ApprovalGateCoordinator by delegate { + override suspend fun createApproval(command: CreateApprovalCommand): ApprovalChallenge { + if (failOnCreate != null) { + delay(1) + throw failOnCreate + } + return delegate.createApproval(command) + } + + override suspend fun cancelApproval( + approvalId: String, + expectedVersion: Long, + reason: String, + ) { + if (failOnCancel != null) { + delay(1) + throw failOnCancel + } + delegate.cancelApproval(approvalId, expectedVersion, reason) + } +} + +/** The continuation store fails on create's resumed frame, or on the cancellation compensation performs. */ +private class FailingContinuationStore( + private val delegate: ApprovalContinuationStore, + private val failOnCreate: Throwable? = null, + private val failOnCancel: Throwable? = null, +) : ApprovalContinuationStore by delegate { + override suspend fun create( + continuation: ApprovalContinuation, + arguments: SensitiveToolArguments, + ): ApprovalContinuation { + if (failOnCreate != null) { + delay(1) + throw failOnCreate + } + return delegate.create(continuation, arguments) + } + + override suspend fun cancel( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation { + if (failOnCancel != null) { + delay(1) + throw failOnCancel + } + return delegate.cancel(approvalId, expectedVersion) + } +} + +/** The suspended-invocation store fails on persistence or on the removal compensation performs. */ +private class FailingSuspendedStore( + private val delegate: SuspendedInvocationStore, + private val failOnCreate: Throwable? = null, + private val failOnRemove: Throwable? = null, +) : SuspendedInvocationStore by delegate { + /** Attempts, not successes: a step that fails records nothing on the delegate. */ + var removeAttempts = 0 + private set + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) { + if (failOnCreate != null) { + delay(1) + throw failOnCreate + } + delegate.create(metadata, replayEnvelope) + } + + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? { + removeAttempts++ + if (failOnRemove != null) { + delay(1) + throw failOnRemove + } + return delegate.remove(approvalId) + } +} + +/** The audit emitter fails on the suspension entry's resumed frame. */ +private class FailingAuditEmitter( + private val delegate: ApprovalLifecycleAuditEmitter, + private val failOnSuspended: Throwable? = null, +) : ApprovalLifecycleAuditEmitter by delegate { + override suspend fun onToolExecutionSuspended( + approvalId: String, + workflowRunId: String, + toolName: String, + toolCallId: String, + correlationId: String, + argumentsDigest: Sha256Digest, + expiresAt: Instant, + ) { + if (failOnSuspended != null) { + delay(1) + throw failOnSuspended + } + delegate.onToolExecutionSuspended( + approvalId, + workflowRunId, + toolName, + toolCallId, + correlationId, + argumentsDigest, + expiresAt, + ) + } +} + +/** + * The third compensation action (the gate's `cancelApproval`): genuinely suspends and is resumed only by the test. + * A successful resume delegates, so an experiment that never suspended cannot masquerade as a passing one. + */ +private class PausingGate( + private val delegate: ApprovalGateCoordinator, +) : ApprovalGateCoordinator by delegate { + val suspended = CompletableDeferred() + private var pending: CancellableContinuation? = null + var suspendCount = 0 + private set + + override suspend fun cancelApproval( + approvalId: String, + expectedVersion: Long, + reason: String, + ) { + suspendCancellableCoroutine { continuation -> + pending = continuation + suspendCount++ + suspended.complete(Unit) + } + delegate.cancelApproval(approvalId, expectedVersion, reason) + } + + fun resumeWith(failure: Throwable) { + requireNotNull(pending) { "the gate cancellation never suspended" }.resumeWithException(failure) + } +} + +/** The second compensation action (the continuation store's `cancel`), same resume-only-by-test shape. */ +private class PausingContinuationStore( + private val delegate: ApprovalContinuationStore, +) : ApprovalContinuationStore by delegate { + val suspended = CompletableDeferred() + private var pending: CancellableContinuation? = null + var suspendCount = 0 + private set + + override suspend fun cancel( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation { + suspendCancellableCoroutine { continuation -> + pending = continuation + suspendCount++ + suspended.complete(Unit) + } + return delegate.cancel(approvalId, expectedVersion) + } + + fun resumeWith(failure: Throwable) { + requireNotNull(pending) { "the continuation cancellation never suspended" }.resumeWithException(failure) + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/DefaultApprovalGatewayTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/DefaultApprovalGatewayTest.kt index 987d800fe..73df72d03 100644 --- a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/DefaultApprovalGatewayTest.kt +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/DefaultApprovalGatewayTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + package dev.tramai.engine.approval import dev.tramai.core.approval.ApprovalBinding @@ -19,66 +21,95 @@ import dev.tramai.core.approval.gateway.ApproverRole import dev.tramai.core.approval.gateway.AuditStreamId import dev.tramai.core.approval.gateway.ResumeToken import dev.tramai.core.approval.gateway.WorkflowRunId -import dev.tramai.engine.EngineExecutionIdentity -import dev.tramai.engine.ExecutionSecurityContext -import dev.tramai.engine.ResumeOperationReference -import dev.tramai.engine.ResumeToolReference +import dev.tramai.core.exception.ApprovalStoreNotFoundException +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolCall +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.engine.EngineExecutionIdentity +import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore import dev.tramai.engine.ReplayEnvelopeDigestHelper +import dev.tramai.engine.ResumeOperationReference +import dev.tramai.engine.ResumeToolReference import dev.tramai.engine.SensitiveReplayEnvelope import dev.tramai.engine.SuspendedInvocationMetadata import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.approval.ApprovalRunAttribution.Governed import dev.tramai.engine.inMemorySuspendedInvocationStore import dev.tramai.security.approval.InMemoryApprovalContinuationStore import dev.tramai.security.approval.InMemoryApprovalStore import dev.tramai.security.approval.Sha256ToolArgumentsDigester -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.time.ZoneId import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import kotlin.test.assertFailsWith class DefaultApprovalGatewayTest { - - private val fixedClock: Clock = Clock.fixed( - Instant.parse("2026-06-25T10:00:00Z"), - ZoneId.of("UTC"), - ) + private val fixedClock: Clock = + Clock.fixed( + Instant.parse("2026-06-25T10:00:00Z"), + ZoneId.of("UTC"), + ) private lateinit var approvalStore: InMemoryApprovalStore private lateinit var continuationStore: InMemoryApprovalContinuationStore private lateinit var suspendedInvocationStore: SuspendedInvocationStore private lateinit var factory: FakeApprovalGatewayRequestFactory - private fun createGateway(): DefaultApprovalGateway = DefaultApprovalGateway( - approvalStore = approvalStore, - continuationStore = continuationStore, - suspendedInvocationStore = suspendedInvocationStore, - requestFactory = factory, - clock = fixedClock, - ) + private fun createGateway( + approvals: dev.tramai.core.approval.ApprovalStore = approvalStore, + suspensions: SuspendedInvocationStore = suspendedInvocationStore, + continuations: ApprovalContinuationStore = continuationStore, + ): DefaultApprovalGateway = + DefaultApprovalGateway( + approvalStore = approvals, + continuationStore = continuations, + suspendedInvocationStore = suspensions, + requestFactory = factory, + clock = fixedClock, + ) @BeforeEach fun setUp() { - approvalStore = InMemoryApprovalStore( - clock = fixedClock, - maxCreationTtl = Duration.ofHours(2), - ) - continuationStore = InMemoryApprovalContinuationStore( - clock = fixedClock, - maxContinuationTtl = Duration.ofHours(2), - ) + approvalStore = + InMemoryApprovalStore( + clock = fixedClock, + maxCreationTtl = Duration.ofHours(2), + ) + continuationStore = + InMemoryApprovalContinuationStore( + clock = fixedClock, + maxContinuationTtl = Duration.ofHours(2), + ) suspendedInvocationStore = inMemorySuspendedInvocationStore() - factory = FakeApprovalGatewayRequestFactory( - fixedClock = fixedClock, - defaultApprovalId = "gateway-test-1", - ) + factory = + FakeApprovalGatewayRequestFactory( + fixedClock = fixedClock, + defaultApprovalId = "gateway-test-1", + ) } // ----------------------------------------------------------------------- @@ -86,423 +117,584 @@ class DefaultApprovalGatewayTest { // ----------------------------------------------------------------------- @Test - fun `new request persists approval, suspended invocation, and continuation`(): Unit = runBlocking { - val gateway = createGateway() - val approvalId = "fresh-request" - factory.defaultApprovalId = approvalId - - val result = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Medical review required"), - requiredRole = ApproverRole("medical-reviewer"), - ) - - val storedApproval = approvalStore.get(approvalId) - assertThat(storedApproval).isNotNull - assertThat(storedApproval!!.status).isEqualTo(ApprovalStatus.PENDING) - - val storedMetadata = suspendedInvocationStore.get(approvalId) - assertThat(storedMetadata).isNotNull - - val storedContinuation = continuationStore.get(approvalId) - assertThat(storedContinuation).isNotNull - - assertThat(result).isInstanceOf(ApprovalRequestResult.Suspended::class.java) - val suspended = result as ApprovalRequestResult.Suspended - assertThat(suspended.approvalId.value).isEqualTo(approvalId) - assertThat(suspended.workflowRunId.value).isEqualTo("wf-run-1") - assertThat(suspended.auditStreamId.value).isNotEmpty() - assertThat(suspended.resumeToken.value).isNotEmpty() - } + fun `new request persists approval, suspended invocation, and continuation`(): Unit = + runBlocking { + val gateway = createGateway() + val approvalId = "fresh-request" + factory.defaultApprovalId = approvalId + + val result = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + val storedApproval = approvalStore.get(approvalId) + assertThat(storedApproval).isNotNull + assertThat(storedApproval!!.status).isEqualTo(ApprovalStatus.PENDING) + + val storedMetadata = suspendedInvocationStore.get(approvalId) + assertThat(storedMetadata).isNotNull + + val storedContinuation = continuationStore.get(approvalId) + assertThat(storedContinuation).isNotNull + + assertThat(result).isInstanceOf(ApprovalRequestResult.Suspended::class.java) + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.approvalId.value).isEqualTo(approvalId) + assertThat(suspended.workflowRunId.value).isEqualTo("wf-run-1") + assertThat(suspended.auditStreamId.value).isNotEmpty() + assertThat(suspended.resumeToken.value).isNotEmpty() + } // ----------------------------------------------------------------------- // 2. Does not block waiting for human (returns Suspended immediately) // ----------------------------------------------------------------------- @Test - fun `new request returns immediately without blocking`(): Unit = runBlocking { - val gateway = createGateway() - - val result = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Quick review"), - requiredRole = ApproverRole("reviewer"), - ) - - assertThat(result).isInstanceOf(ApprovalRequestResult.Suspended::class.java) - } + fun `new request returns immediately without blocking`(): Unit = + runBlocking { + val gateway = createGateway() + + val result = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Quick review"), + requiredRole = ApproverRole("reviewer"), + ) + + assertThat(result).isInstanceOf(ApprovalRequestResult.Suspended::class.java) + } // ----------------------------------------------------------------------- // 3. Existing pending — duplicate does not create, no duplicate store writes // ----------------------------------------------------------------------- @Test - fun `existing pending approval returns Suspended without duplicate create`(): Unit = runBlocking { - val approvalId = "duplicate-pending" - factory.defaultApprovalId = approvalId - val gateway = createGateway() - val publicToken = ResumeToken("public-pending-token") - factory.defaultResumeToken = publicToken - - // First call creates the request - val firstResult = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) - assertThat(firstResult).isInstanceOf(ApprovalRequestResult.Suspended::class.java) - val firstSuspended = firstResult as ApprovalRequestResult.Suspended - - // Capture state after first call - val initialApprovalVersion = approvalStore.get(approvalId)!!.version - assertThat(suspendedInvocationStore.get(approvalId)).isNotNull() - assertThat(continuationStore.get(approvalId)).isNotNull() - - // Second call with same data — should not duplicate store writes - val secondResult = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) - - assertThat(secondResult).isInstanceOf(ApprovalRequestResult.Suspended::class.java) - val secondSuspended = secondResult as ApprovalRequestResult.Suspended - assertThat(secondSuspended.approvalId).isEqualTo(firstSuspended.approvalId) - - // Approval version unchanged — create was not called again - val approvalAfterSecondCall = approvalStore.get(approvalId)!! - assertThat(approvalAfterSecondCall.version).isEqualTo(initialApprovalVersion) - - // Suspended invocation and continuation still exist and were not duplicated - assertThat(suspendedInvocationStore.get(approvalId)).isNotNull() - assertThat(continuationStore.get(approvalId)).isNotNull() - - // Resume token from factory, not from stored digest - assertThat(secondSuspended.resumeToken).isEqualTo(publicToken) - assertThat(secondSuspended.resumeToken.value).isNotEqualTo(twoDigest.value) - } + fun `existing pending approval returns Suspended without duplicate create`(): Unit = + runBlocking { + val approvalId = "duplicate-pending" + factory.defaultApprovalId = approvalId + val gateway = createGateway() + val publicToken = ResumeToken("public-pending-token") + factory.defaultResumeToken = publicToken + + // First call creates the request + val firstResult = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + assertThat(firstResult).isInstanceOf(ApprovalRequestResult.Suspended::class.java) + val firstSuspended = firstResult as ApprovalRequestResult.Suspended + + // Capture state after first call + val initialApprovalVersion = approvalStore.get(approvalId)!!.version + assertThat(suspendedInvocationStore.get(approvalId)).isNotNull() + assertThat(continuationStore.get(approvalId)).isNotNull() + + // Second call with same data — should not duplicate store writes + val secondResult = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + assertThat(secondResult).isInstanceOf(ApprovalRequestResult.Suspended::class.java) + val secondSuspended = secondResult as ApprovalRequestResult.Suspended + assertThat(secondSuspended.approvalId).isEqualTo(firstSuspended.approvalId) + + // Approval version unchanged — create was not called again + val approvalAfterSecondCall = approvalStore.get(approvalId)!! + assertThat(approvalAfterSecondCall.version).isEqualTo(initialApprovalVersion) + + // Suspended invocation and continuation still exist and were not duplicated + assertThat(suspendedInvocationStore.get(approvalId)).isNotNull() + assertThat(continuationStore.get(approvalId)).isNotNull() + + // Resume token from factory, not from stored digest + assertThat(secondSuspended.resumeToken).isEqualTo(publicToken) + assertThat(secondSuspended.resumeToken.value).isNotEqualTo(twoDigest.value) + } // ----------------------------------------------------------------------- // 4. Existing approved request returns AlreadyApproved // ----------------------------------------------------------------------- @Test - fun `existing approved approval returns AlreadyApproved`(): Unit = runBlocking { - val approvalId = "already-approved" - val requestingActor = "test-actor" - val decidingActor = "human-1" - val factory = FakeApprovalGatewayRequestFactory( - fixedClock = fixedClock, - defaultApprovalId = approvalId, - defaultRequestedBy = requestingActor, - ) - val gateway = DefaultApprovalGateway( - approvalStore = approvalStore, - continuationStore = continuationStore, - suspendedInvocationStore = suspendedInvocationStore, - requestFactory = factory, - clock = fixedClock, - ) - - val pendingRequest = createPendingApprovalRequest(approvalId, workflowRunId = "wf-approved", requestedBy = requestingActor) - approvalStore.create(pendingRequest) - approvalStore.transition( - approvalId = approvalId, - expectedVersion = 0L, - transition = ApprovalTransition.Approve(decidedBy = decidingActor, comment = "Looks good"), - ) - - val result = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) + fun `existing approved approval returns AlreadyApproved`(): Unit = + runBlocking { + val approvalId = "already-approved" + val requestingActor = "test-actor" + val decidingActor = "human-1" + val factory = + FakeApprovalGatewayRequestFactory( + fixedClock = fixedClock, + defaultApprovalId = approvalId, + defaultRequestedBy = requestingActor, + ) + val gateway = + DefaultApprovalGateway( + approvalStore = approvalStore, + continuationStore = continuationStore, + suspendedInvocationStore = suspendedInvocationStore, + requestFactory = factory, + clock = fixedClock, + ) + + val pendingRequest = createPendingApprovalRequest(approvalId, workflowRunId = "wf-approved", requestedBy = requestingActor) + approvalStore.create(pendingRequest) + approvalStore.transition( + approvalId = approvalId, + expectedVersion = 0L, + transition = ApprovalTransition.Approve(decidedBy = decidingActor, comment = "Looks good"), + ) - assertThat(result).isInstanceOf(ApprovalRequestResult.AlreadyApproved::class.java) - val approved = result as ApprovalRequestResult.AlreadyApproved - assertThat(approved.decision.decidedBy).isEqualTo(decidingActor) - assertThat(approved.decision.comment).isEqualTo("Looks good") - } + val result = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + assertThat(result).isInstanceOf(ApprovalRequestResult.AlreadyApproved::class.java) + val approved = result as ApprovalRequestResult.AlreadyApproved + assertThat(approved.decision.decidedBy).isEqualTo(decidingActor) + assertThat(approved.decision.comment).isEqualTo("Looks good") + } // ----------------------------------------------------------------------- // 5. Existing denied request returns AlreadyDenied // ----------------------------------------------------------------------- @Test - fun `existing denied approval returns AlreadyDenied`(): Unit = runBlocking { - val approvalId = "already-denied" - val requestingActor = "test-actor" - val decidingActor = "human-2" - val factory = FakeApprovalGatewayRequestFactory( - fixedClock = fixedClock, - defaultApprovalId = approvalId, - defaultRequestedBy = requestingActor, - ) - val gateway = DefaultApprovalGateway( - approvalStore = approvalStore, - continuationStore = continuationStore, - suspendedInvocationStore = suspendedInvocationStore, - requestFactory = factory, - clock = fixedClock, - ) - - val pendingRequest = createPendingApprovalRequest(approvalId, workflowRunId = "wf-denied", requestedBy = requestingActor) - approvalStore.create(pendingRequest) - approvalStore.transition( - approvalId = approvalId, - expectedVersion = 0L, - transition = ApprovalTransition.Deny(decidedBy = decidingActor, comment = "Not appropriate"), - ) - - val result = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) + fun `existing denied approval returns AlreadyDenied`(): Unit = + runBlocking { + val approvalId = "already-denied" + val requestingActor = "test-actor" + val decidingActor = "human-2" + val factory = + FakeApprovalGatewayRequestFactory( + fixedClock = fixedClock, + defaultApprovalId = approvalId, + defaultRequestedBy = requestingActor, + ) + val gateway = + DefaultApprovalGateway( + approvalStore = approvalStore, + continuationStore = continuationStore, + suspendedInvocationStore = suspendedInvocationStore, + requestFactory = factory, + clock = fixedClock, + ) + + val pendingRequest = createPendingApprovalRequest(approvalId, workflowRunId = "wf-denied", requestedBy = requestingActor) + approvalStore.create(pendingRequest) + approvalStore.transition( + approvalId = approvalId, + expectedVersion = 0L, + transition = ApprovalTransition.Deny(decidedBy = decidingActor, comment = "Not appropriate"), + ) - assertThat(result).isInstanceOf(ApprovalRequestResult.AlreadyDenied::class.java) - val denied = result as ApprovalRequestResult.AlreadyDenied - assertThat(denied.decision.decidedBy).isEqualTo(decidingActor) - assertThat(denied.decision.reason).isEqualTo("Not appropriate") - } + val result = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + assertThat(result).isInstanceOf(ApprovalRequestResult.AlreadyDenied::class.java) + val denied = result as ApprovalRequestResult.AlreadyDenied + assertThat(denied.decision.decidedBy).isEqualTo(decidingActor) + assertThat(denied.decision.reason).isEqualTo("Not appropriate") + } // ----------------------------------------------------------------------- // 6. Existing expired/timed-out request returns Expired // ----------------------------------------------------------------------- @Test - fun `existing timed-out approval returns Expired`(): Unit = runBlocking { - val approvalId = "already-timed-out" - val requestingActor = "test-actor" - val mutableClock = MutableTestClock(fixedClock.instant()) - val expiry = mutableClock.instant().plusSeconds(60) - - val timeoutingStore = InMemoryApprovalStore( - clock = mutableClock, - maxCreationTtl = Duration.ofHours(2), - ) - val timeoutingContinuationStore = InMemoryApprovalContinuationStore( - clock = mutableClock, - maxContinuationTtl = Duration.ofHours(2), - ) - val factory = FakeApprovalGatewayRequestFactory( - fixedClock = mutableClock, - defaultApprovalId = approvalId, - defaultRequestedBy = requestingActor, - ) - val gateway = DefaultApprovalGateway( - approvalStore = timeoutingStore, - continuationStore = timeoutingContinuationStore, - suspendedInvocationStore = inMemorySuspendedInvocationStore(), - requestFactory = factory, - clock = mutableClock, - ) - - val pendingRequest = createPendingApprovalRequest( - approvalId = approvalId, - workflowRunId = "wf-expired", - requestedBy = requestingActor, - requestedAt = mutableClock.instant(), - expiresAt = expiry, - ) - timeoutingStore.create(pendingRequest) - - mutableClock.advance(Duration.ofMinutes(5)) - - timeoutingStore.transition( - approvalId = approvalId, - expectedVersion = 0L, - transition = ApprovalTransition.Timeout, - ) - - val result = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) + fun `existing timed-out approval returns Expired`(): Unit = + runBlocking { + val approvalId = "already-timed-out" + val requestingActor = "test-actor" + val mutableClock = MutableTestClock(fixedClock.instant()) + val expiry = mutableClock.instant().plusSeconds(60) + + val timeoutingStore = + InMemoryApprovalStore( + clock = mutableClock, + maxCreationTtl = Duration.ofHours(2), + ) + val timeoutingContinuationStore = + InMemoryApprovalContinuationStore( + clock = mutableClock, + maxContinuationTtl = Duration.ofHours(2), + ) + val factory = + FakeApprovalGatewayRequestFactory( + fixedClock = mutableClock, + defaultApprovalId = approvalId, + defaultRequestedBy = requestingActor, + ) + val gateway = + DefaultApprovalGateway( + approvalStore = timeoutingStore, + continuationStore = timeoutingContinuationStore, + suspendedInvocationStore = inMemorySuspendedInvocationStore(), + requestFactory = factory, + clock = mutableClock, + ) + + val pendingRequest = + createPendingApprovalRequest( + approvalId = approvalId, + workflowRunId = "wf-expired", + requestedBy = requestingActor, + requestedAt = mutableClock.instant(), + expiresAt = expiry, + ) + timeoutingStore.create(pendingRequest) + + mutableClock.advance(Duration.ofMinutes(5)) + + timeoutingStore.transition( + approvalId = approvalId, + expectedVersion = 0L, + transition = ApprovalTransition.Timeout, + ) - assertThat(result).isInstanceOf(ApprovalRequestResult.Expired::class.java) - val expired = result as ApprovalRequestResult.Expired - assertThat(expired.approvalId.value).isEqualTo(approvalId) - assertThat(expired.reason).isEqualTo("approval-expired") - } + val result = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + assertThat(result).isInstanceOf(ApprovalRequestResult.Expired::class.java) + val expired = result as ApprovalRequestResult.Expired + assertThat(expired.approvalId.value).isEqualTo(approvalId) + assertThat(expired.reason).isEqualTo("approval-expired") + } @Test - fun `expired-by-clock approval returns Expired`(): Unit = runBlocking { - val approvalId = "clock-expired" - val requestingActor = "test-actor" - val mutableClock = MutableTestClock(fixedClock.instant()) - val pastExpiry = mutableClock.instant().plusSeconds(60) - - val expiringStore = InMemoryApprovalStore( - clock = mutableClock, - maxCreationTtl = Duration.ofHours(2), - ) - val expiringContinuationStore = InMemoryApprovalContinuationStore( - clock = mutableClock, - maxContinuationTtl = Duration.ofHours(2), - ) - val factory = FakeApprovalGatewayRequestFactory( - fixedClock = mutableClock, - defaultApprovalId = approvalId, - defaultRequestedBy = requestingActor, - ) - val gateway = DefaultApprovalGateway( - approvalStore = expiringStore, - continuationStore = expiringContinuationStore, - suspendedInvocationStore = inMemorySuspendedInvocationStore(), - requestFactory = factory, - clock = mutableClock, - ) - - val pendingRequest = createPendingApprovalRequest( - approvalId = approvalId, - workflowRunId = "wf-expired-clock", - requestedBy = requestingActor, - requestedAt = mutableClock.instant(), - expiresAt = pastExpiry, - ) - expiringStore.create(pendingRequest) - - mutableClock.advance(Duration.ofMinutes(5)) - - val result = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) - - assertThat(result).isInstanceOf(ApprovalRequestResult.Expired::class.java) - val expired = result as ApprovalRequestResult.Expired - assertThat(expired.approvalId.value).isEqualTo(approvalId) - assertThat(expired.reason).isEqualTo("approval-expired") - } + fun `expired-by-clock approval returns Expired`(): Unit = + runBlocking { + val approvalId = "clock-expired" + val requestingActor = "test-actor" + val mutableClock = MutableTestClock(fixedClock.instant()) + val pastExpiry = mutableClock.instant().plusSeconds(60) + + val expiringStore = + InMemoryApprovalStore( + clock = mutableClock, + maxCreationTtl = Duration.ofHours(2), + ) + val expiringContinuationStore = + InMemoryApprovalContinuationStore( + clock = mutableClock, + maxContinuationTtl = Duration.ofHours(2), + ) + val factory = + FakeApprovalGatewayRequestFactory( + fixedClock = mutableClock, + defaultApprovalId = approvalId, + defaultRequestedBy = requestingActor, + ) + val gateway = + DefaultApprovalGateway( + approvalStore = expiringStore, + continuationStore = expiringContinuationStore, + suspendedInvocationStore = inMemorySuspendedInvocationStore(), + requestFactory = factory, + clock = mutableClock, + ) + + val pendingRequest = + createPendingApprovalRequest( + approvalId = approvalId, + workflowRunId = "wf-expired-clock", + requestedBy = requestingActor, + requestedAt = mutableClock.instant(), + expiresAt = pastExpiry, + ) + expiringStore.create(pendingRequest) + + mutableClock.advance(Duration.ofMinutes(5)) + + val result = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + assertThat(result).isInstanceOf(ApprovalRequestResult.Expired::class.java) + val expired = result as ApprovalRequestResult.Expired + assertThat(expired.approvalId.value).isEqualTo(approvalId) + assertThat(expired.reason).isEqualTo("approval-expired") + } // ----------------------------------------------------------------------- // 7. CancellationException is rethrown // ----------------------------------------------------------------------- @Test - fun `cancellation exception from store is rethrown`(): Unit = runBlocking { - val failingStore = object : ApprovalStore by approvalStore { - override suspend fun create(request: ApprovalRequest): ApprovalRequest { - throw CancellationException("simulated-cancel") - } + fun `cancellation exception from store is rethrown`(): Unit = + runBlocking { + val failingStore = CancellingApprovalStore(approvalStore) + val gateway = + DefaultApprovalGateway( + approvalStore = failingStore, + continuationStore = continuationStore, + suspendedInvocationStore = suspendedInvocationStore, + requestFactory = factory, + clock = fixedClock, + ) + + val thrown = + try { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Cancel test"), + requiredRole = ApproverRole("reviewer"), + ) + null + } catch (e: CancellationException) { + e + } + + assertThat(thrown).isNotNull + assertThat(thrown!!.message).isEqualTo("simulated-cancel") } - val gateway = DefaultApprovalGateway( - approvalStore = failingStore, - continuationStore = continuationStore, - suspendedInvocationStore = suspendedInvocationStore, - requestFactory = factory, - clock = fixedClock, - ) - val thrown = try { - gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Cancel test"), - requiredRole = ApproverRole("reviewer"), - ) - null - } catch (e: CancellationException) { - e + // ----------------------------------------------------------------------- + // 8. Factory failure fails closed without creating approval + // ----------------------------------------------------------------------- + + @Test + fun `factory failure does not write stores`(): Unit = + runBlocking { + val failingFactory = + object : ApprovalGatewayRequestFactory { + override suspend fun createRequest( + subject: ApprovalSubject, + recommendation: ApprovalRecommendation, + requiredRole: ApproverRole, + workflowRunId: WorkflowRunId?, + ): ApprovalGatewayPersistenceRequest = throw RuntimeException("factory-exploded") + } + val gateway = + DefaultApprovalGateway( + approvalStore = approvalStore, + continuationStore = continuationStore, + suspendedInvocationStore = suspendedInvocationStore, + requestFactory = failingFactory, + clock = fixedClock, + ) + + val thrown = + try { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Factory failure"), + requiredRole = ApproverRole("reviewer"), + ) + null + } catch (e: RuntimeException) { + e + } + + assertThat(thrown).isNotNull + assertThat(thrown!!.message).isEqualTo("factory-exploded") + + assertThat(approvalStore.get("any-id")).isNull() + assertThat(continuationStore.get("any-id")).isNull() + assertThat(suspendedInvocationStore.get("any-id")).isNull() } - assertThat(thrown).isNotNull - assertThat(thrown!!.message).isEqualTo("simulated-cancel") - } + // ----------------------------------------------------------------------- + // 9. A caller run id that disagrees with the canonical identity aborts (0.7.1d1) + // + // The caller names 'wf-run-1' while the active scope's canonical run is 'governed-run-1'. + // The scope is authoritative: a caller may not pair one run's id with another run's identity, + // and nothing durable may exist afterwards. + // ----------------------------------------------------------------------- + + @Test + fun `a caller run id disagreeing with the canonical identity is rejected before any write`(): Unit = + runBlocking { + val approvalId = "governed-rejected" + factory.defaultApprovalId = approvalId + val gateway = createGateway() + + val governedRun = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId("governed-run-1"), + ) + + val thrown = + try { + withContext(GovernedRunScope(governedRun)) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + workflowRunId = WorkflowRunId("wf-run-1"), + ) + } + null + } catch (e: GovernedRunContinuityException) { + e + } + + assertThat(thrown).isInstanceOf(GovernedRunContinuityException::class.java) + assertThat(thrown!!.message).contains("governed-run-1") + + // Fail closed means nothing reaches durable state: no approval, no suspension, no + // continuation, and no silent downgrade to a legacy un-attributed suspension. + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(suspendedInvocationStore.get(approvalId)).isNull() + assertThat(continuationStore.get(approvalId)).isNull() + } // ----------------------------------------------------------------------- - // 8. Factory failure fails closed without creating approval + // 10. ResumeToken is the public credential, not the approval token digest // ----------------------------------------------------------------------- @Test - fun `factory failure does not write stores`(): Unit = runBlocking { - val failingFactory = object : ApprovalGatewayRequestFactory { - override suspend fun createRequest( - subject: ApprovalSubject, - recommendation: ApprovalRecommendation, - requiredRole: ApproverRole, - workflowRunId: WorkflowRunId?, - ): ApprovalGatewayPersistenceRequest { - throw RuntimeException("factory-exploded") - } + fun `suspended result uses public resume token from factory not approval token digest`(): Unit = + runBlocking { + val approvalId = "token-test" + factory.defaultApprovalId = approvalId + val publicToken = ResumeToken("public-resume-token") + factory.defaultResumeToken = publicToken + + val result = + createGateway().requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.resumeToken).isEqualTo(publicToken) + assertThat(suspended.resumeToken.value).isNotEqualTo(twoDigest.value) } - val gateway = DefaultApprovalGateway( - approvalStore = approvalStore, - continuationStore = continuationStore, - suspendedInvocationStore = suspendedInvocationStore, - requestFactory = failingFactory, - clock = fixedClock, - ) - val thrown = try { + @Test + fun `existing pending result uses factory resume token not stored digest`(): Unit = + runBlocking { + val approvalId = "duplicate-token" + factory.defaultApprovalId = approvalId + val publicToken = ResumeToken("public-existing-pending-token") + factory.defaultResumeToken = publicToken + val gateway = createGateway() + + // First call creates gateway.requestApproval( subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Factory failure"), - requiredRole = ApproverRole("reviewer"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), ) - null - } catch (e: RuntimeException) { - e - } - - assertThat(thrown).isNotNull - assertThat(thrown!!.message).isEqualTo("factory-exploded") - assertThat(approvalStore.get("any-id")).isNull() - assertThat(continuationStore.get("any-id")).isNull() - assertThat(suspendedInvocationStore.get("any-id")).isNull() - } + // Second call returns existing pending + val result = + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Review"), + requiredRole = ApproverRole("medical-reviewer"), + ) + + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.resumeToken).isEqualTo(publicToken) + assertThat(suspended.resumeToken.value).isNotEqualTo(twoDigest.value) + } // ----------------------------------------------------------------------- - // 9. ResumeToken is the public credential, not the approval token digest + // 11. The suspended-run protocol is a real contract, not an artefact of a + // synchronous store: it is observable only when a collaborator genuinely + // suspends (and, for the failure case, resumes exceptionally). // ----------------------------------------------------------------------- @Test - fun `suspended result uses public resume token from factory not approval token digest`(): Unit = runBlocking { - val approvalId = "token-test" - factory.defaultApprovalId = approvalId - val publicToken = ResumeToken("public-resume-token") - factory.defaultResumeToken = publicToken - - val result = createGateway().requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) - - val suspended = result as ApprovalRequestResult.Suspended - assertThat(suspended.resumeToken).isEqualTo(publicToken) - assertThat(suspended.resumeToken.value).isNotEqualTo(twoDigest.value) - } + fun `requestApproval resumes across a genuinely suspending approval store get`(): Unit = + runBlocking { + val approvalId = "suspending-get-resume" + factory.defaultApprovalId = approvalId + val approvals = SuspendingApprovalStore(approvalStore) + val gateway = createGateway(approvals = approvals) + + val result = + withTimeout(2_000) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + + // The store really suspended and the caller resumed with the real value: a corrupted + // suspension protocol (the returned sentinel replaced by null) cannot satisfy this. + assertThat(approvals.getCalls).isEqualTo(1) + assertThat(approvals.resumedGets).isEqualTo(1) + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.approvalId.value).isEqualTo(approvalId) + assertThat(approvalStore.get(approvalId)).isNotNull + } @Test - fun `existing pending result uses factory resume token not stored digest`(): Unit = runBlocking { - val approvalId = "duplicate-token" - factory.defaultApprovalId = approvalId - val publicToken = ResumeToken("public-existing-pending-token") - factory.defaultResumeToken = publicToken - val gateway = createGateway() - - // First call creates - gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) - - // Second call returns existing pending - val result = gateway.requestApproval( - subject = ApprovalSubject("claim-42"), - recommendation = ApprovalRecommendation("review", "Review"), - requiredRole = ApproverRole("medical-reviewer"), - ) - - val suspended = result as ApprovalRequestResult.Suspended - assertThat(suspended.resumeToken).isEqualTo(publicToken) - assertThat(suspended.resumeToken.value).isNotEqualTo(twoDigest.value) - } + fun `a failure that resumes a genuinely suspending approval store get reaches the caller unchanged`(): Unit = + runBlocking { + val approvalId = "suspending-get-failure" + factory.defaultApprovalId = approvalId + val approvals = + SuspendingApprovalStore( + delegate = approvalStore, + failAfterResume = IllegalStateException("resumed-get-failure"), + ) + val gateway = createGateway(approvals = approvals) + + val thrown: Throwable? = + try { + withTimeout(2_000) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + null + } catch (e: Throwable) { + e + } + + assertThat(approvals.resumedGets).isEqualTo(1) + + // The store's own failure must reach the caller unwrapped; a dropped rethrow on the + // resume path turns this into a type-confusion artefact or a silent success instead. + assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + assertThat(thrown!!.message).isEqualTo("resumed-get-failure") + + // Fail closed: a failed resume leaves no durable trace. + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(continuationStore.get(approvalId)).isNull() + assertThat(suspendedInvocationStore.get(approvalId)).isNull() + } // ----------------------------------------------------------------------- // Test helpers @@ -514,23 +706,22 @@ class DefaultApprovalGatewayTest { private fun createPendingApprovalRequest( approvalId: String, - workflowRunId: String = "wf-run-${approvalId}", + workflowRunId: String = "wf-run-$approvalId", requestedBy: String = "test-actor", requestedAt: Instant = fixedClock.instant(), expiresAt: Instant = fixedClock.instant().plusSeconds(3600), - status: ApprovalStatus = ApprovalStatus.PENDING, - version: Long = 0L, ) = ApprovalRequest( approvalId = approvalId, - binding = ApprovalBinding( - workflowRunId = workflowRunId, - toolName = "test-tool", - argumentsDigest = zeroDigest, - policyVersion = "v1", - workflowDigest = oneDigest, - approvalTokenDigest = twoDigest, - ), - status = status, + binding = + ApprovalBinding( + workflowRunId = workflowRunId, + toolName = "test-tool", + argumentsDigest = zeroDigest, + policyVersion = "v1", + workflowDigest = oneDigest, + approvalTokenDigest = twoDigest, + ), + status = ApprovalStatus.PENDING, requestedBy = requestedBy, requestedAt = requestedAt, expiresAt = expiresAt, @@ -539,7 +730,7 @@ class DefaultApprovalGatewayTest { decisionComment = null, consumedBy = null, consumedAt = null, - version = version, + version = 0L, ) } @@ -549,10 +740,13 @@ class DefaultApprovalGatewayTest { internal class FakeApprovalGatewayRequestFactory( private val fixedClock: Clock, var defaultApprovalId: String = "gateway-test-1", + var overrideWorkflowRunId: String? = null, var defaultRequestedBy: String = "test-actor", var defaultResumeToken: ResumeToken = ResumeToken("public-resume-token"), private val digester: Sha256ToolArgumentsDigester = Sha256ToolArgumentsDigester(), ) : ApprovalGatewayRequestFactory { + var calls = 0 + var lastRequestedWorkflowRunId: WorkflowRunId? = null private val zeroDigest = Sha256Digest.of("sha256:0000000000000000000000000000000000000000000000000000000000000000") private val oneDigest = Sha256Digest.of("sha256:1111111111111111111111111111111111111111111111111111111111111111") @@ -564,98 +758,109 @@ internal class FakeApprovalGatewayRequestFactory( requiredRole: ApproverRole, workflowRunId: WorkflowRunId?, ): ApprovalGatewayPersistenceRequest { + calls++ + lastRequestedWorkflowRunId = workflowRunId val now = fixedClock.instant() - val wfRunId = workflowRunId?.value ?: "wf-run-1" + val wfRunId = overrideWorkflowRunId ?: workflowRunId?.value ?: "wf-run-1" val argumentJson = "{\"subject\":\"${subject.value}\"}" val sensitiveArgs = SensitiveToolArguments.of(argumentJson) val argsDigest = digester.digest(sensitiveArgs) - val approvalRequest = ApprovalRequest( - approvalId = defaultApprovalId, - binding = ApprovalBinding( + val approvalRequest = + ApprovalRequest( + approvalId = defaultApprovalId, + binding = + ApprovalBinding( + workflowRunId = wfRunId, + toolName = "test-tool", + argumentsDigest = argsDigest, + policyVersion = "v1", + workflowDigest = oneDigest, + approvalTokenDigest = twoDigest, + ), + status = ApprovalStatus.PENDING, + requestedBy = defaultRequestedBy, + requestedAt = now, + expiresAt = now.plusSeconds(3600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) + + val continuation = + ApprovalContinuation( + approvalId = defaultApprovalId, workflowRunId = wfRunId, + correlationId = "corr-$defaultApprovalId", + toolCallId = "tc-1", toolName = "test-tool", argumentsDigest = argsDigest, policyVersion = "v1", workflowDigest = oneDigest, - approvalTokenDigest = twoDigest, - ), - status = ApprovalStatus.PENDING, - requestedBy = defaultRequestedBy, - requestedAt = now, - expiresAt = now.plusSeconds(3600), - decidedBy = null, - decidedAt = null, - decisionComment = null, - consumedBy = null, - consumedAt = null, - version = 0L, - ) - - val continuation = ApprovalContinuation( - approvalId = defaultApprovalId, - workflowRunId = wfRunId, - correlationId = "corr-${defaultApprovalId}", - toolCallId = "tc-1", - toolName = "test-tool", - argumentsDigest = argsDigest, - policyVersion = "v1", - workflowDigest = oneDigest, - status = ApprovalContinuationStatus.PENDING, - createdAt = now, - approvalExpiresAt = now.plusSeconds(3600), - claimedBy = null, - claimedAt = null, - completedAt = null, - version = 0L, - ) + status = ApprovalContinuationStatus.PENDING, + createdAt = now, + approvalExpiresAt = now.plusSeconds(3600), + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, + ) - val operationReference = ResumeOperationReference( - serviceInterface = "dev.tramai.test.TestService", - methodName = "testMethod", - jvmMethodDescriptor = "(Ltest/Input;)Ltest/Output;", - resumeDefinitionDigest = zeroDigest, - ) + val operationReference = + ResumeOperationReference( + serviceInterface = "dev.tramai.test.TestService", + methodName = "testMethod", + jvmMethodDescriptor = "(Ltest/Input;)Ltest/Output;", + resumeDefinitionDigest = zeroDigest, + ) // The envelope must bind to the metadata (assistant tool-call message // with matching id/name/index), carry the canonical digest, and have // the selected arguments replaced by the redaction sentinel — per the // shared SuspendedInvocationStore contract. - val envelopeMessages = listOf( - Message( - role = MessageRole.ASSISTANT, - content = "", - toolCalls = listOf( - ToolCall( - id = "tc-1", - name = "test-tool", - argumentsJson = "__redacted_approval_continuation_args__", - ), + val envelopeMessages = + listOf( + Message( + role = MessageRole.ASSISTANT, + content = "", + toolCalls = + listOf( + ToolCall( + id = "tc-1", + name = "test-tool", + argumentsJson = "__redacted_approval_continuation_args__", + ), + ), ), - ), - ) + ) val envelopeDigest = ReplayEnvelopeDigestHelper.compute(operationReference, envelopeMessages) - val suspendedInvocationMetadata = SuspendedInvocationMetadata( - approvalId = defaultApprovalId, - toolCallId = "tc-1", - toolName = "test-tool", - toolCallIndex = 0, - correlationId = "corr-${defaultApprovalId}", - identity = EngineExecutionIdentity( - workflowRunId = wfRunId, - correlationId = "corr-${defaultApprovalId}", - workflowDigest = oneDigest, - policyVersion = "v1", - actorId = defaultRequestedBy, - ), - securityContext = ExecutionSecurityContext(), - operationReference = operationReference, - replayEnvelopeDigest = envelopeDigest, - toolReference = ResumeToolReference( + val suspendedInvocationMetadata = + SuspendedInvocationMetadata( + approvalId = defaultApprovalId, + toolCallId = "tc-1", toolName = "test-tool", - declarationDigest = zeroDigest, - ), - ) + toolCallIndex = 0, + correlationId = "corr-$defaultApprovalId", + identity = + EngineExecutionIdentity( + workflowRunId = wfRunId, + correlationId = "corr-$defaultApprovalId", + workflowDigest = oneDigest, + policyVersion = "v1", + actorId = defaultRequestedBy, + ), + securityContext = ExecutionSecurityContext(), + operationReference = operationReference, + replayEnvelopeDigest = envelopeDigest, + toolReference = + ResumeToolReference( + toolName = "test-tool", + declarationDigest = zeroDigest, + ), + ) val replayEnvelope = SensitiveReplayEnvelope.of(envelopeMessages) @@ -670,6 +875,42 @@ internal class FakeApprovalGatewayRequestFactory( } } +/** + * Delegating approval store whose `create` always cancels, used to prove the gateway rethrows + * cancellation instead of swallowing it. + */ +private class CancellingApprovalStore( + private val delegate: ApprovalStore, +) : ApprovalStore by delegate { + override suspend fun create(request: ApprovalRequest) = throw CancellationException("simulated-cancel") +} + +/** + * Delegating approval store whose `get` genuinely suspends before consulting the delegate, and + * optionally fails after resuming. + * + * The in-memory store completes synchronously, so the gateway's suspension protocol — the + * `COROUTINE_SUSPENDED` return and the compiler-generated rethrow on the resumed path — is never + * reached. Only a genuinely suspending (and, optionally, failing) collaborator exercises it. + */ +private class SuspendingApprovalStore( + private val delegate: ApprovalStore, + private val failAfterResume: Throwable? = null, +) : ApprovalStore by delegate { + var getCalls = 0 + private set + var resumedGets = 0 + private set + + override suspend fun get(approvalId: String): ApprovalRequest? { + getCalls++ + delay(1) + resumedGets++ + failAfterResume?.let { throw it } + return delegate.get(approvalId) + } +} + /** * A mutable [Clock] that can be advanced programmatically for time-dependent testing. */ @@ -678,7 +919,9 @@ internal class MutableTestClock( private val zone: ZoneId = ZoneId.of("UTC"), ) : Clock() { override fun instant(): Instant = now + override fun withZone(zone: ZoneId): Clock = MutableTestClock(now, zone) + override fun getZone(): ZoneId = zone fun advance(amount: Duration) { diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GatewaySuspensionContractTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GatewaySuspensionContractTest.kt new file mode 100644 index 000000000..ec856113e --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GatewaySuspensionContractTest.kt @@ -0,0 +1,614 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.engine.approval + +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStore +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.gateway.ApprovalRecommendation +import dev.tramai.core.approval.gateway.ApprovalRequestResult +import dev.tramai.core.approval.gateway.ApprovalSubject +import dev.tramai.core.approval.gateway.ApproverRole +import dev.tramai.core.approval.gateway.WorkflowRunId +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.inMemorySuspendedInvocationStore +import dev.tramai.security.approval.InMemoryApprovalContinuationStore +import dev.tramai.security.approval.InMemoryApprovalStore +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId + +/** + * The collaborator contracts `DefaultApprovalGateway` must honour when a store genuinely suspends. + * + * Every store wired in the released gateway suites is in-memory and completes synchronously, so the + * suspension protocol itself — the sentinel returned when a nested call suspends, and the rethrow + * that must unwrap a failure arriving on the resumed frame — is never exercised. These tests state + * the contract at the only level where it is observable: a collaborator suspends for real, and the + * gateway must either complete the suspension saga or fail closed with the collaborator's own + * failure reaching the caller. + * + * Scope note: the gateway documents that it has no single transactional boundary across the three + * stores. What these tests therefore pin is that a failing write is *propagated* and never silently + * swallowed, and that no later step of the chain runs after a failure. + */ +class GatewaySuspensionContractTest { + private val fixedClock: Clock = + Clock.fixed( + Instant.parse("2026-06-25T10:00:00Z"), + ZoneId.of("UTC"), + ) + + private lateinit var approvalStore: InMemoryApprovalStore + private lateinit var continuationStore: InMemoryApprovalContinuationStore + private lateinit var suspendedInvocationStore: SuspendedInvocationStore + private lateinit var factory: FakeApprovalGatewayRequestFactory + + @BeforeEach + fun setUp() { + val ttl = Duration.ofHours(2) + approvalStore = InMemoryApprovalStore(clock = fixedClock, maxCreationTtl = ttl) + continuationStore = InMemoryApprovalContinuationStore(clock = fixedClock, maxContinuationTtl = ttl) + suspendedInvocationStore = inMemorySuspendedInvocationStore() + factory = FakeApprovalGatewayRequestFactory(fixedClock, defaultApprovalId = "suspension-contract") + } + + private fun createGateway( + approvals: ApprovalStore = approvalStore, + suspensions: SuspendedInvocationStore = suspendedInvocationStore, + continuations: ApprovalContinuationStore = continuationStore, + requestFactory: ApprovalGatewayRequestFactory = factory, + ): DefaultApprovalGateway = + DefaultApprovalGateway( + approvalStore = approvals, + continuationStore = continuations, + suspendedInvocationStore = suspensions, + requestFactory = requestFactory, + clock = fixedClock, + ) + + private suspend fun captureFailure(block: suspend () -> Unit): Throwable? = + try { + withTimeout(2_000) { block() } + null + } catch (e: Throwable) { + e + } + + private suspend fun DefaultApprovalGateway.request(workflowRunId: WorkflowRunId? = null): ApprovalRequestResult = + withTimeout(2_000) { + requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + workflowRunId = workflowRunId, + ) + } + + // ----------------------------------------------------------------------- + // Ungoverned suspension saga + // ----------------------------------------------------------------------- + + @Test + fun `an ungoverned request completes when every persistence collaborator genuinely suspends`(): Unit = + runBlocking { + val approvalId = "suspending-ungoverned" + factory.defaultApprovalId = approvalId + val approvals = SuspendingOnWriteApprovalStore(approvalStore) + val suspensions = SuspendingSuspendedInvocationStore(suspendedInvocationStore) + val continuations = SuspendingContinuationStore(continuationStore) + val requestFactory = SuspendingRequestFactory(factory) + val gateway = + createGateway( + approvals = approvals, + suspensions = suspensions, + continuations = continuations, + requestFactory = requestFactory, + ) + + val result = gateway.request() + + // Proof that each collaborator really suspended and the caller resumed with the value + // it returned: a corrupted suspension protocol cannot reach this state. + assertThat(requestFactory.resumedCalls).isEqualTo(1) + assertThat(approvals.getResumes).isEqualTo(1) + assertThat(approvals.writeResumes).isEqualTo(1) + assertThat(suspensions.writeResumes).isEqualTo(1) + assertThat(continuations.writeResumes).isEqualTo(1) + + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.approvalId.value).isEqualTo(approvalId) + assertThat(approvalStore.get(approvalId)).isNotNull + assertThat(suspendedInvocationStore.get(approvalId)).isNotNull + assertThat(continuationStore.get(approvalId)).isNotNull + } + + @Test + fun `a request factory failure after suspension reaches the caller and writes nothing`(): Unit = + runBlocking { + val approvalId = "factory-suspending-failure" + factory.defaultApprovalId = approvalId + val suspensions = SuspendingSuspendedInvocationStore(suspendedInvocationStore) + val continuations = SuspendingContinuationStore(continuationStore) + val requestFactory = + SuspendingRequestFactory( + factory, + failAfterSuspension = IllegalStateException("factory-failed-after-suspension"), + ) + val gateway = + createGateway( + suspensions = suspensions, + continuations = continuations, + requestFactory = requestFactory, + ) + + val failure = captureFailure { gateway.request() } + + assertThat(requestFactory.resumedCalls).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("factory-failed-after-suspension") + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(suspensions.writeResumes).isEqualTo(0) + assertThat(continuations.writeResumes).isEqualTo(0) + } + + @Test + fun `an approval write failure after suspension reaches the caller and stops the chain`(): Unit = + runBlocking { + val approvalId = "approval-suspending-failure" + factory.defaultApprovalId = approvalId + val approvals = + SuspendingOnWriteApprovalStore( + approvalStore, + failAfterWriteResumes = IllegalStateException("approval-failed-after-suspension"), + ) + val suspensions = SuspendingSuspendedInvocationStore(suspendedInvocationStore) + val continuations = SuspendingContinuationStore(continuationStore) + val gateway = + createGateway( + approvals = approvals, + suspensions = suspensions, + continuations = continuations, + requestFactory = SuspendingRequestFactory(factory), + ) + + val failure = captureFailure { gateway.request() } + + assertThat(approvals.writeResumes).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("approval-failed-after-suspension") + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(suspensions.writeResumes).isEqualTo(0) + assertThat(continuations.writeResumes).isEqualTo(0) + } + + @Test + fun `a suspended-invocation write failure after suspension reaches the caller and stops the chain`(): Unit = + runBlocking { + val approvalId = "suspension-suspending-failure" + factory.defaultApprovalId = approvalId + val suspensions = + SuspendingSuspendedInvocationStore( + suspendedInvocationStore, + failAfterWriteResumes = IllegalStateException("suspension-failed-after-suspension"), + ) + val continuations = SuspendingContinuationStore(continuationStore) + val gateway = + createGateway( + approvals = SuspendingOnWriteApprovalStore(approvalStore), + suspensions = suspensions, + continuations = continuations, + requestFactory = SuspendingRequestFactory(factory), + ) + + val failure = captureFailure { gateway.request() } + + assertThat(suspensions.writeResumes).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("suspension-failed-after-suspension") + assertThat(continuationStore.get(approvalId)).isNull() + assertThat(continuations.writeResumes).isEqualTo(0) + } + + @Test + fun `a continuation write failure after suspension reaches the caller and leaves no continuation`(): Unit = + runBlocking { + val approvalId = "continuation-suspending-failure" + factory.defaultApprovalId = approvalId + val continuations = + SuspendingContinuationStore( + continuationStore, + failAfterWriteResumes = IllegalStateException("continuation-failed-after-suspension"), + ) + val gateway = + createGateway( + approvals = SuspendingOnWriteApprovalStore(approvalStore), + suspensions = SuspendingSuspendedInvocationStore(suspendedInvocationStore), + continuations = continuations, + requestFactory = SuspendingRequestFactory(factory), + ) + + val failure = captureFailure { gateway.request() } + + assertThat(continuations.writeResumes).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("continuation-failed-after-suspension") + assertThat(continuationStore.get(approvalId)).isNull() + } + + // ----------------------------------------------------------------------- + // Governed suspension saga + // ----------------------------------------------------------------------- + + @Test + fun `a governed request completes when every governed persistence collaborator genuinely suspends`(): Unit = + runBlocking { + val approvalId = "gov-suspending" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val governedSuspensions = TestGovernedSuspendedInvocationStore(suspendedInvocationStore) + val approvals = SuspendingGovernedApprovalStore(governedApprovals) + val suspensions = SuspendingGovernedSuspendedInvocationStore(governedSuspensions) + val continuations = SuspendingContinuationStore(continuationStore) + val gateway = + createGateway( + approvals = approvals, + suspensions = suspensions, + continuations = continuations, + requestFactory = SuspendingRequestFactory(factory), + ) + val identity = governedIdentity("governed-run-1") + + val result = withContext(GovernedRunScope(identity)) { gateway.request() } + + assertThat(approvals.governedWriteResumes).isEqualTo(1) + assertThat(suspensions.governedWriteResumes).isEqualTo(1) + assertThat(continuations.writeResumes).isEqualTo(1) + + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.workflowRunId.value).isEqualTo("governed-run-1") + assertThat(governedApprovals.attributionOf(approvalId)).isEqualTo(ApprovalRunAttribution.Governed(identity)) + assertThat(governedSuspensions.governed.single().runIdentity).isEqualTo(identity) + assertThat(continuationStore.get(approvalId)).isNotNull + } + + @Test + fun `a governed approval write failure after suspension reaches the caller and writes nothing`(): Unit = + runBlocking { + val approvalId = "gov-approval-failure" + factory.defaultApprovalId = approvalId + val governedSuspensions = TestGovernedSuspendedInvocationStore(suspendedInvocationStore) + val approvals = + SuspendingGovernedApprovalStore( + TestGovernedApprovalStore(approvalStore), + failAfterGovernedWriteResumes = IllegalStateException("governed-approval-failed-after-suspension"), + ) + val suspensions = SuspendingGovernedSuspendedInvocationStore(governedSuspensions) + val continuations = SuspendingContinuationStore(continuationStore) + val gateway = + createGateway( + approvals = approvals, + suspensions = suspensions, + continuations = continuations, + requestFactory = SuspendingRequestFactory(factory), + ) + val identity = governedIdentity("governed-run-1") + + val failure = captureFailure { withContext(GovernedRunScope(identity)) { gateway.request() } } + + assertThat(approvals.governedWriteResumes).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("governed-approval-failed-after-suspension") + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(suspensions.governedWriteResumes).isEqualTo(0) + assertThat(continuations.writeResumes).isEqualTo(0) + } + + @Test + fun `a governed suspension write failure after suspension reaches the caller and writes no continuation`(): Unit = + runBlocking { + val approvalId = "gov-suspension-failure" + factory.defaultApprovalId = approvalId + val approvals = SuspendingGovernedApprovalStore(TestGovernedApprovalStore(approvalStore)) + val suspensions = + SuspendingGovernedSuspendedInvocationStore( + TestGovernedSuspendedInvocationStore(suspendedInvocationStore), + failAfterGovernedWriteResumes = + IllegalStateException("governed-suspension-failed-after-suspension"), + ) + val continuations = SuspendingContinuationStore(continuationStore) + val gateway = + createGateway( + approvals = approvals, + suspensions = suspensions, + continuations = continuations, + requestFactory = SuspendingRequestFactory(factory), + ) + val identity = governedIdentity("governed-run-1") + + val failure = captureFailure { withContext(GovernedRunScope(identity)) { gateway.request() } } + + assertThat(suspensions.governedWriteResumes).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("governed-suspension-failed-after-suspension") + assertThat(continuations.writeResumes).isEqualTo(0) + assertThat(continuationStore.get(approvalId)).isNull() + } + + @Test + fun `a governed continuation write failure after suspension reaches the caller and leaves no continuation`(): Unit = + runBlocking { + val approvalId = "gov-continuation-failure" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val governedSuspensions = TestGovernedSuspendedInvocationStore(suspendedInvocationStore) + val approvals = SuspendingGovernedApprovalStore(governedApprovals) + val suspensions = SuspendingGovernedSuspendedInvocationStore(governedSuspensions) + val continuations = + SuspendingContinuationStore( + continuationStore, + failAfterWriteResumes = + IllegalStateException("governed-continuation-failed-after-suspension"), + ) + val gateway = + createGateway( + approvals = approvals, + suspensions = suspensions, + continuations = continuations, + requestFactory = SuspendingRequestFactory(factory), + ) + val identity = governedIdentity("governed-run-1") + + val failure = captureFailure { withContext(GovernedRunScope(identity)) { gateway.request() } } + + assertThat(continuations.writeResumes).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("governed-continuation-failed-after-suspension") + assertThat(continuationStore.get(approvalId)).isNull() + // The two governed writes that precede the failing write are durable: this pins the real + // partial-creation state instead of assuming the saga rolls back. + assertThat(governedApprovals.attributionOf(approvalId)) + .isEqualTo(ApprovalRunAttribution.Governed(identity)) + assertThat(governedSuspensions.governed.single().runIdentity).isEqualTo(identity) + } + + @Test + fun `an existing governed approval is re-validated across a suspending attribution read`(): Unit = + runBlocking { + val approvalId = "gov-existing-suspending-read" + factory.defaultApprovalId = approvalId + val identity = governedIdentity("governed-run-1") + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val approvals = SuspendingGovernedApprovalStore(governedApprovals) + val suspensions = + SuspendingGovernedSuspendedInvocationStore( + TestGovernedSuspendedInvocationStore(suspendedInvocationStore), + ) + val gateway = + createGateway( + approvals = approvals, + suspensions = suspensions, + continuations = SuspendingContinuationStore(continuationStore), + requestFactory = SuspendingRequestFactory(factory), + ) + + // A durable approval carrying this run's canonical attribution already exists. + val persistenceRequest = + factory.createRequest( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + workflowRunId = WorkflowRunId("governed-run-1"), + ) + governedApprovals.createGovernedApproval( + persistenceRequest.approvalRequest, + ApprovalRunAttribution.Governed(identity), + ) + + val result = withContext(GovernedRunScope(identity)) { gateway.request() } + + assertThat(approvals.attributionReadResumes).isEqualTo(1) + assertThat(governedApprovals.governedCreates).isEqualTo(1) + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.approvalId.value).isEqualTo(approvalId) + } + + @Test + fun `a failure resuming the attribution read reaches the caller`(): Unit = + runBlocking { + val approvalId = "gov-attribution-read-failure" + factory.defaultApprovalId = approvalId + val identity = governedIdentity("governed-run-1") + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val approvals = + SuspendingGovernedApprovalStore( + governedApprovals, + failAfterAttributionReadResumes = IllegalStateException("attribution-read-failed-after-suspension"), + ) + val gateway = + createGateway( + approvals = approvals, + suspensions = + SuspendingGovernedSuspendedInvocationStore( + TestGovernedSuspendedInvocationStore(suspendedInvocationStore), + ), + continuations = SuspendingContinuationStore(continuationStore), + requestFactory = SuspendingRequestFactory(factory), + ) + val persistenceRequest = + factory.createRequest( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + workflowRunId = WorkflowRunId("governed-run-1"), + ) + governedApprovals.createGovernedApproval( + persistenceRequest.approvalRequest, + ApprovalRunAttribution.Governed(identity), + ) + + val failure = captureFailure { withContext(GovernedRunScope(identity)) { gateway.request() } } + + assertThat(approvals.attributionReadResumes).isEqualTo(1) + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(failure!!.message).isEqualTo("attribution-read-failed-after-suspension") + } +} + +/** Delegating request factory that suspends before delegating, optionally failing after resuming. */ +private class SuspendingRequestFactory( + private val delegate: ApprovalGatewayRequestFactory, + private val failAfterSuspension: Throwable? = null, +) : ApprovalGatewayRequestFactory { + var resumedCalls = 0 + private set + + override suspend fun createRequest( + subject: ApprovalSubject, + recommendation: ApprovalRecommendation, + requiredRole: ApproverRole, + workflowRunId: WorkflowRunId?, + ): ApprovalGatewayPersistenceRequest { + delay(1) + resumedCalls++ + failAfterSuspension?.let { throw it } + return delegate.createRequest(subject, recommendation, requiredRole, workflowRunId) + } +} + +/** Delegating approval store that suspends on both read and write, optionally failing the write. */ +private class SuspendingOnWriteApprovalStore( + private val delegate: ApprovalStore, + private val failAfterWriteResumes: Throwable? = null, +) : ApprovalStore by delegate { + var getResumes = 0 + private set + var writeResumes = 0 + private set + + override suspend fun get(approvalId: String): ApprovalRequest? { + delay(1) + getResumes++ + return delegate.get(approvalId) + } + + override suspend fun create(request: ApprovalRequest): ApprovalRequest { + delay(1) + writeResumes++ + failAfterWriteResumes?.let { throw it } + return delegate.create(request) + } +} + +/** Delegating suspended-invocation store that suspends on write, optionally failing afterwards. */ +private class SuspendingSuspendedInvocationStore( + private val delegate: SuspendedInvocationStore, + private val failAfterWriteResumes: Throwable? = null, +) : SuspendedInvocationStore by delegate { + var writeResumes = 0 + private set + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) { + delay(1) + writeResumes++ + failAfterWriteResumes?.let { throw it } + delegate.create(metadata, replayEnvelope) + } +} + +/** Delegating continuation store that suspends on write, optionally failing afterwards. */ +private class SuspendingContinuationStore( + private val delegate: ApprovalContinuationStore, + private val failAfterWriteResumes: Throwable? = null, +) : ApprovalContinuationStore by delegate { + var writeResumes = 0 + private set + + override suspend fun create( + continuation: ApprovalContinuation, + arguments: SensitiveToolArguments, + ): ApprovalContinuation { + delay(1) + writeResumes++ + failAfterWriteResumes?.let { throw it } + return delegate.create(continuation, arguments) + } +} + +/** Governed approval capability over the shared test store, suspending on both governed operations. */ +private class SuspendingGovernedApprovalStore( + private val delegate: TestGovernedApprovalStore, + private val failAfterGovernedWriteResumes: Throwable? = null, + private val failAfterAttributionReadResumes: Throwable? = null, +) : GovernedApprovalStore by delegate { + var governedWriteResumes = 0 + private set + var attributionReadResumes = 0 + private set + + override suspend fun createGovernedApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution.Governed, + ): ApprovalRequest { + delay(1) + governedWriteResumes++ + failAfterGovernedWriteResumes?.let { throw it } + return delegate.createGovernedApproval(request, attribution) + } + + override suspend fun attributionOf(approvalId: String): ApprovalRunAttribution { + delay(1) + attributionReadResumes++ + failAfterAttributionReadResumes?.let { throw it } + return delegate.attributionOf(approvalId) + } +} + +/** + * Governed suspension capability over the shared test store: both write paths suspend, so the + * governed path exercises the same suspension protocol as the legacy one. + */ +private class SuspendingGovernedSuspendedInvocationStore( + private val delegate: TestGovernedSuspendedInvocationStore, + private val failAfterGovernedWriteResumes: Throwable? = null, +) : GovernedSuspendedInvocationStore by delegate { + var governedWriteResumes = 0 + private set + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) { + delay(1) + delegate.create(metadata, replayEnvelope) + } + + override suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) { + delay(1) + governedWriteResumes++ + failAfterGovernedWriteResumes?.let { throw it } + delegate.createGoverned(suspended, replayEnvelope) + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedApprovalGatewayTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedApprovalGatewayTest.kt new file mode 100644 index 000000000..8ecf81387 --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedApprovalGatewayTest.kt @@ -0,0 +1,480 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.engine.approval + +import dev.tramai.core.approval.ApprovalBinding +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.approval.ApprovalStore +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.gateway.ApprovalRecommendation +import dev.tramai.core.approval.gateway.ApprovalRequestResult +import dev.tramai.core.approval.gateway.ApprovalSubject +import dev.tramai.core.approval.gateway.ApproverRole +import dev.tramai.core.approval.gateway.WorkflowRunId +import dev.tramai.core.exception.ApprovalStoreNotFoundException +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.approval.ApprovalRunAttribution.Governed +import dev.tramai.engine.approval.ApprovalRunAttribution.Ungoverned +import dev.tramai.engine.inMemorySuspendedInvocationStore +import dev.tramai.security.approval.InMemoryApprovalContinuationStore +import dev.tramai.security.approval.InMemoryApprovalStore +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import kotlin.test.assertFailsWith + +/** + * 0.7.1d1 P3: the preview approval gateway carries a governed run's canonical identity. + * + * The gateway resolves the scope once and treats the identity as the single value for the request: + * the request factory stays identity-blind and is merely handed the canonical run id, while the + * approval attribution is derived at the governed store boundary and the same identity goes into the + * governed suspension record. + * + * These discriminators pin the boundaries that make that safe: capability discovery before the + * factory runs, no fallback to the legacy create paths, no adoption of an approval whose durable + * attribution disagrees with the active run, and no writing of anything before those checks pass. + * + * Kept in its own class rather than appended to the released gateway suite so that both stay + * readable; it owns its own stores and reuses the shared fake request factory. + */ +class GovernedApprovalGatewayTest { + private val fixedClock: Clock = + Clock.fixed( + Instant.parse("2026-06-25T10:00:00Z"), + ZoneId.of("UTC"), + ) + + private lateinit var approvalStore: InMemoryApprovalStore + private lateinit var continuationStore: InMemoryApprovalContinuationStore + private lateinit var suspendedInvocationStore: SuspendedInvocationStore + private lateinit var factory: FakeApprovalGatewayRequestFactory + + @BeforeEach + fun setUp() { + val ttl = Duration.ofHours(2) + approvalStore = InMemoryApprovalStore(clock = fixedClock, maxCreationTtl = ttl) + continuationStore = InMemoryApprovalContinuationStore(clock = fixedClock, maxContinuationTtl = ttl) + suspendedInvocationStore = inMemorySuspendedInvocationStore() + factory = FakeApprovalGatewayRequestFactory(fixedClock, defaultApprovalId = "governed-gateway-test") + } + + private fun createGateway( + approvals: ApprovalStore = approvalStore, + suspensions: SuspendedInvocationStore = suspendedInvocationStore, + continuations: ApprovalContinuationStore = continuationStore, + ): DefaultApprovalGateway = + DefaultApprovalGateway( + approvalStore = approvals, + continuationStore = continuations, + suspendedInvocationStore = suspensions, + requestFactory = factory, + clock = fixedClock, + ) + + @Test + fun `a governed request derives the canonical run id for the factory when the caller omits it`(): Unit = + runBlocking { + val approvalId = "gov-derived" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val governedSuspensions = TestGovernedSuspendedInvocationStore(suspendedInvocationStore) + val gateway = createGateway(approvals = governedApprovals, suspensions = governedSuspensions) + val identity = governedIdentity("governed-run-1") + + val result = + withContext(GovernedRunScope(identity)) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + + // The factory stays identity-blind, but it must be handed the canonical run id rather + // than nothing: an omitted argument is derived, not left absent. + assertThat(factory.lastRequestedWorkflowRunId?.value).isEqualTo("governed-run-1") + + val suspended = result as ApprovalRequestResult.Suspended + assertThat(suspended.workflowRunId.value).isEqualTo("governed-run-1") + assertThat(governedApprovals.attributionOf(approvalId)).isEqualTo(Governed(identity)) + assertThat(governedSuspensions.governed.single().runIdentity).isEqualTo(identity) + } + + @Test + fun `a governed request persists through the governed capabilities and never the legacy ones`(): Unit = + runBlocking { + val approvalId = "gov-happy" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val governedSuspensions = TestGovernedSuspendedInvocationStore(suspendedInvocationStore) + val gateway = createGateway(approvals = governedApprovals, suspensions = governedSuspensions) + val identity = governedIdentity("governed-run-1") + + withContext(GovernedRunScope(identity)) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + workflowRunId = WorkflowRunId("governed-run-1"), + ) + } + + assertThat(governedApprovals.governedCreates).isEqualTo(1) + assertThat(governedSuspensions.governed.size).isEqualTo(1) + assertThat(governedSuspensions.governed.single().runIdentity).isEqualTo(identity) + // No fallback: a governed request never touches either legacy create path. + assertThat(governedApprovals.legacyCreates).isZero() + assertThat(governedSuspensions.legacyCreates).isZero() + } + + @Test + fun `a governed request against legacy-only wiring fails configuration with no factory call and no writes`(): Unit = + runBlocking { + val approvalId = "gov-legacy-wiring" + factory.defaultApprovalId = approvalId + val plainApprovals = CountingPlainApprovalStore(approvalStore) + val plainSuspensions = CountingPlainSuspendedInvocationStore(suspendedInvocationStore) + val continuations = CountingContinuationStore(continuationStore) + val gateway = + createGateway( + approvals = plainApprovals, + suspensions = plainSuspensions, + continuations = continuations, + ) + + val thrown = + assertFailsWith { + withContext(GovernedRunScope(governedIdentity("governed-run-1"))) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + } + + assertThat(thrown.message).contains("GovernedApprovalStore") + // A deployment that cannot persist governed records has no reason to invoke the factory. + assertThat(factory.calls).isZero() + assertThat(plainApprovals.legacyCreates).isZero() + assertThat(plainSuspensions.legacyCreates).isZero() + assertThat(continuations.creates).isZero() + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(suspendedInvocationStore.get(approvalId)).isNull() + } + + @Test + fun `a partially governed wiring fails as one precondition before the factory and before any write`(): Unit = + runBlocking { + val approvalId = "gov-partial-wiring" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val plainSuspensions = CountingPlainSuspendedInvocationStore(suspendedInvocationStore) + val continuations = CountingContinuationStore(continuationStore) + val gateway = + createGateway( + approvals = governedApprovals, + suspensions = plainSuspensions, + continuations = continuations, + ) + + val thrown = + assertFailsWith { + withContext(GovernedRunScope(governedIdentity("governed-run-1"))) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + } + + // Capability discovery is one precondition: the approval capability being present must + // not let the request commit an approval and only then refuse the suspension. + assertThat(thrown.message).contains("GovernedSuspendedInvocationStore") + assertThat(factory.calls).isZero() + assertThat(governedApprovals.governedCreates).isZero() + assertThat(governedApprovals.legacyCreates).isZero() + assertThat(plainSuspensions.legacyCreates).isZero() + assertThat(continuations.creates).isZero() + } + + @Test + fun `a factory that re-points the binding aborts before anything durable exists`(): Unit = + runBlocking { + val approvalId = "gov-factory-drift" + factory.defaultApprovalId = approvalId + factory.overrideWorkflowRunId = "somewhere-else" + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val governedSuspensions = TestGovernedSuspendedInvocationStore(suspendedInvocationStore) + val gateway = createGateway(approvals = governedApprovals, suspensions = governedSuspensions) + + val thrown = + assertFailsWith { + withContext(GovernedRunScope(governedIdentity("governed-run-1"))) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + } + + assertThat(thrown.message).contains("somewhere-else") + assertThat(governedApprovals.governedCreates).isZero() + assertThat(governedSuspensions.governed).isEmpty() + assertThat(approvalStore.get(approvalId)).isNull() + } + + @Test + fun `an existing un-attributed approval is not adopted by a governed run`(): Unit = + runBlocking { + val approvalId = "gov-existing-legacy" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val gateway = createGateway(approvals = governedApprovals) + approvalStore.create(pendingRequest(approvalId, "governed-run-1")) + + val thrown = + assertFailsWith { + withContext(GovernedRunScope(governedIdentity("governed-run-1"))) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + } + + assertThat(thrown.message).contains(approvalId) + assertThat(governedApprovals.governedCreates).isZero() + } + + @Test + fun `an existing approval governed by another identity is not adopted`(): Unit = + runBlocking { + val approvalId = "gov-existing-other" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val gateway = createGateway(approvals = governedApprovals) + governedApprovals.createGovernedApproval( + pendingRequest(approvalId, "governed-run-other"), + Governed(governedIdentity("governed-run-other")), + ) + + val thrown = + assertFailsWith { + withContext(GovernedRunScope(governedIdentity("governed-run-1"))) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + workflowRunId = WorkflowRunId("governed-run-1"), + ) + } + } + + assertThat(thrown.message).contains(approvalId) + assertThat(governedApprovals.governedCreates).isEqualTo(1) + } + + @Test + fun `an existing approval carrying the canonical identity returns the idempotent result`(): Unit = + runBlocking { + val approvalId = "gov-existing-canonical" + factory.defaultApprovalId = approvalId + val governedApprovals = TestGovernedApprovalStore(approvalStore) + val governedSuspensions = TestGovernedSuspendedInvocationStore(suspendedInvocationStore) + val gateway = createGateway(approvals = governedApprovals, suspensions = governedSuspensions) + val identity = governedIdentity("governed-run-1") + governedApprovals.createGovernedApproval(pendingRequest(approvalId, "governed-run-1"), Governed(identity)) + + val result = + withContext(GovernedRunScope(identity)) { + gateway.requestApproval( + subject = ApprovalSubject("claim-42"), + recommendation = ApprovalRecommendation("review", "Medical review required"), + requiredRole = ApproverRole("medical-reviewer"), + ) + } + + assertThat(result).isInstanceOf(ApprovalRequestResult.Suspended::class.java) + // Idempotent: the existing approval is returned rather than re-created. + assertThat(governedApprovals.governedCreates).isEqualTo(1) + assertThat(governedApprovals.legacyCreates).isZero() + assertThat(governedSuspensions.governed).isEmpty() + } + + private fun pendingRequest( + approvalId: String, + workflowRunId: String, + ): ApprovalRequest { + val now = fixedClock.instant() + return ApprovalRequest( + approvalId = approvalId, + binding = + ApprovalBinding( + workflowRunId = workflowRunId, + toolName = "test-tool", + argumentsDigest = SEEDED_DIGEST, + policyVersion = "v1", + workflowDigest = SEEDED_DIGEST, + approvalTokenDigest = SEEDED_DIGEST, + ), + status = ApprovalStatus.PENDING, + requestedBy = "test-actor", + requestedAt = now, + expiresAt = now.plusSeconds(3600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) + } +} + +private val SEEDED_DIGEST: Sha256Digest = + Sha256Digest.of("sha256:3333333333333333333333333333333333333333333333333333333333333333") + +internal fun governedIdentity(runId: String): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId(runId), + ) + +/** Governed approval capability over the shared in-memory store, counting both write paths. */ +internal class TestGovernedApprovalStore( + private val delegate: ApprovalStore, +) : ApprovalStore by delegate, + GovernedApprovalStore { + var legacyCreates = 0 + var governedCreates = 0 + private val attribution = mutableMapOf() + + override suspend fun create(request: ApprovalRequest): ApprovalRequest { + legacyCreates++ + return delegate.create(request) + } + + override suspend fun createGovernedApproval( + request: ApprovalRequest, + attribution: Governed, + ): ApprovalRequest { + requireAttributionMatchesBinding(request, attribution) + governedCreates++ + this.attribution[request.approvalId] = attribution + return delegate.create(request) + } + + override suspend fun attributionOf(approvalId: String): ApprovalRunAttribution { + delegate.get(approvalId) ?: throw ApprovalStoreNotFoundException(approvalId) + return attribution[approvalId] ?: ApprovalRunAttribution.Ungoverned + } +} + +internal class CountingPlainApprovalStore( + private val delegate: ApprovalStore, +) : ApprovalStore by delegate { + var legacyCreates = 0 + + override suspend fun create(request: ApprovalRequest): ApprovalRequest { + legacyCreates++ + return delegate.create(request) + } +} + +/** Governed suspension capability, counting which write path was taken. */ +internal class TestGovernedSuspendedInvocationStore( + private val delegate: SuspendedInvocationStore, +) : SuspendedInvocationStore by delegate, + GovernedSuspendedInvocationStore { + var legacyCreates = 0 + val governed = mutableListOf() + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) { + legacyCreates++ + delegate.create(metadata, replayEnvelope) + } + + override suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) { + governed += suspended + delegate.create(suspended.metadata, replayEnvelope) + } + + override suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? = + governed.firstOrNull { it.metadata.approvalId == approvalId }?.runIdentity +} + +internal class CountingPlainSuspendedInvocationStore( + private val delegate: SuspendedInvocationStore, +) : SuspendedInvocationStore by delegate { + var legacyCreates = 0 + + override suspend fun create( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + ) { + legacyCreates++ + delegate.create(metadata, replayEnvelope) + } +} + +internal class CountingContinuationStore( + private val delegate: ApprovalContinuationStore, +) : ApprovalContinuationStore by delegate { + var creates = 0 + + override suspend fun create( + continuation: ApprovalContinuation, + arguments: SensitiveToolArguments, + ): ApprovalContinuation { + creates++ + return delegate.create(continuation, arguments) + } +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedApprovalStoreTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedApprovalStoreTest.kt new file mode 100644 index 000000000..8292201d7 --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedApprovalStoreTest.kt @@ -0,0 +1,77 @@ +package dev.tramai.engine.approval + +import dev.tramai.core.approval.ApprovalBinding +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.approval.Sha256Digest +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant + +/** + * Contract tests for [requireAttributionMatchesBinding]. + * + * The rule is that the attribution of a governed approval must describe the same run as the + * request binding, so "run A + workload/configuration/deployment B" is unrepresentable. Both + * directions are asserted: a matching attribution is accepted, and one naming another run is + * rejected with a message that names the inconsistency. The rejection case is the discriminator + * that proves the check exists — with the binding comparison absent, an approval created under a + * substituted identity would be accepted silently. + */ +class GovernedApprovalStoreTest { + private val digest = Sha256Digest.of("sha256:" + "3".repeat(64)) + + @Test + fun `attribution for the bound run is accepted`() { + requireAttributionMatchesBinding( + request(workflowRunId = "run-binding"), + ApprovalRunAttribution.Governed(governedIdentity(runId = "run-binding")), + ) + } + + @Test + fun `attribution naming another run is rejected`() { + val failure = + assertThrows { + requireAttributionMatchesBinding( + request(workflowRunId = "run-binding"), + ApprovalRunAttribution.Governed(governedIdentity(runId = "run-other")), + ) + } + + assertTrue( + failure.message.orEmpty().contains("inconsistent with its binding"), + "the rejection must name the binding inconsistency, got: ${failure.message}", + ) + } + + @Test + fun `an ungoverned attribution carries no binding to check`() { + requireAttributionMatchesBinding(request(workflowRunId = "run-binding"), ApprovalRunAttribution.Ungoverned) + } + + private fun request(workflowRunId: String) = + ApprovalRequest( + approvalId = "approval-1", + binding = + ApprovalBinding( + workflowRunId = workflowRunId, + toolName = "test-tool", + argumentsDigest = digest, + policyVersion = "v1", + workflowDigest = digest, + approvalTokenDigest = digest, + ), + status = ApprovalStatus.PENDING, + requestedBy = "test-actor", + requestedAt = Instant.EPOCH, + expiresAt = Instant.EPOCH.plusSeconds(3600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) +} diff --git a/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedContinuationContinuityTest.kt b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedContinuationContinuityTest.kt new file mode 100644 index 000000000..4fb9675cb --- /dev/null +++ b/tramai-engine/src/test/kotlin/dev/tramai/engine/approval/GovernedContinuationContinuityTest.kt @@ -0,0 +1,69 @@ +package dev.tramai.engine.approval + +import dev.tramai.core.exception.GovernedRunContinuityException +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Contract tests for [requireGovernedContinuity]. + * + * The rule is that a standalone continuation can only be resumed by the identity it was persisted + * with: a legacy suspension cannot be attributed to a governed run, and a governed suspension + * cannot be resumed under a different one. The rejection is asserted together with the run it + * refused to attribute, because the message is what makes the substitution auditable — a message + * that reads `null` describes no run at all. + */ +class GovernedContinuationContinuityTest { + @Test + fun `a governed resume of a legacy suspension is rejected naming the requested run`() { + val requested = governedIdentity(runId = "run-substituted") + + val failure = + assertThrows { + requireGovernedContinuity( + approvalId = "approval-1", + persisted = null, + requested = requested, + ) + } + + assertTrue( + failure.message.orEmpty().contains("run-substituted"), + "the rejection must name the run it refused to attribute, got: ${failure.message}", + ) + } + + @Test + fun `a governed resume of the same identity proceeds`() { + val identity = governedIdentity(runId = "run-1") + + requireGovernedContinuity(approvalId = "approval-1", persisted = identity, requested = identity) + } + + @Test + fun `a standalone resume recovers the persisted identity`() { + requireGovernedContinuity( + approvalId = "approval-1", + persisted = governedIdentity(runId = "run-1"), + requested = null, + ) + } + + @Test + fun `substituted attribution is rejected before claim or execution`() { + val failure = + assertThrows { + requireGovernedContinuity( + approvalId = "approval-1", + persisted = governedIdentity(runId = "run-1"), + requested = governedIdentity(runId = "run-2"), + ) + } + + assertTrue( + failure.message.orEmpty().contains("attribution was substituted"), + "the rejection must name the substitution, got: ${failure.message}", + ) + } +} diff --git a/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueArchitectureTest.kt b/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueArchitectureTest.kt index 70a466e2e..3f08eec96 100644 --- a/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueArchitectureTest.kt +++ b/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueArchitectureTest.kt @@ -41,43 +41,52 @@ class RuntimeEventCatalogueArchitectureTest { * `tramai.` literals in production code. A literal merely *resembling* a * configuration namespace (e.g. `tramai.security.some_new_event`) is * treated as protocol and must reference the catalogue. */ - private val configPropertyLiterals = setOf( - "tramai.dashboard", - "tramai.dashboard.auth.required", - "tramai.dashboard.auth.provider", - "tramai.mcp", - "tramai.profile", - "tramai.providers.anthropic", - "tramai.providers.anthropic.apiKey", - "tramai.providers.local-lab-provider", - "tramai.providers.ollama", - "tramai.providers.openai", - "tramai.providers.openai.apiKey", - "tramai.providers.openai.bearerToken", - "tramai.providers.openai-compatible", - "tramai.providers.openai-compatible.apiKey", - "tramai.providers.openai-compatible.bearerToken", - "tramai.secrets.aws-secrets-manager", - "tramai.secrets.aws-secrets-manager.accessKeyId", - "tramai.secrets.aws-secrets-manager.secretAccessKey", - "tramai.secrets.aws-secrets-manager.sessionToken", - "tramai.secrets.file", - "tramai.secrets.vault", - "tramai.secrets.vault.token", - "tramai.security.classification", - "tramai.sovereign", - "tramai.sovereign.approved_resume_queue", - "tramai.sovereign.approved_resume_worker", - "tramai.sovereign.ops", - "tramai.sovereign.ops.actuator.approved-resume-worker-health", - "tramai.sovereign.ops.actuator.approved-resume-worker-metrics", - "tramai.sovereign.ops.actuator.worker-health", - "tramai.sovereign.ops.actuator.worker-status", - "tramai.sovereign.ops.approval-gateway", - "tramai.sovereign.ops.approved-resume-worker", - "tramai.sovereign.ops.outbox.worker", - "tramai.sovereign.persistence", - ) + private val configPropertyLiterals = + setOf( + // 0.7.1e: the control-plane HTTP adapter is gated off by default; this is the exact + // Spring prefix of that gate, declared like every other configuration namespace. + "tramai.control-plane.http", + "tramai.dashboard", + "tramai.dashboard.auth.required", + "tramai.dashboard.auth.provider", + "tramai.mcp", + "tramai.profile", + "tramai.providers.anthropic", + "tramai.providers.anthropic.apiKey", + "tramai.providers.local-lab-provider", + "tramai.providers.ollama", + "tramai.providers.openai", + "tramai.providers.openai.apiKey", + "tramai.providers.openai.bearerToken", + "tramai.providers.openai-compatible", + "tramai.providers.openai-compatible.apiKey", + "tramai.providers.openai-compatible.bearerToken", + "tramai.secrets.aws-secrets-manager", + "tramai.secrets.aws-secrets-manager.accessKeyId", + "tramai.secrets.aws-secrets-manager.secretAccessKey", + "tramai.secrets.aws-secrets-manager.sessionToken", + "tramai.secrets.file", + "tramai.secrets.vault", + "tramai.secrets.vault.token", + "tramai.security.classification", + "tramai.server.governed.configuration-id", + "tramai.server.governed.configuration-version", + "tramai.server.governed.deployment-id", + "tramai.server.governed.environment-id", + "tramai.server.governed.workload-id", + "tramai.sovereign", + "tramai.sovereign.approved_resume_queue", + "tramai.sovereign.approved_resume_worker", + "tramai.sovereign.ops", + "tramai.sovereign.ops.actuator.approved-resume-worker-health", + "tramai.sovereign.ops.actuator.approved-resume-worker-metrics", + "tramai.sovereign.ops.actuator.worker-health", + "tramai.sovereign.ops.actuator.worker-status", + "tramai.sovereign.ops.approval-gateway", + "tramai.sovereign.ops.approved-resume-worker", + "tramai.sovereign.ops.outbox.worker", + "tramai.sovereign.persistence", + ) /** * Fail-closed classification of a `tramai.` literal found in production @@ -89,8 +98,7 @@ class RuntimeEventCatalogueArchitectureTest { * `RuntimeAttributes.X`, never repeat the underlying string. Anything that * merely starts with a configuration namespace is protocol, not config. */ - internal fun classifySourceLiteral(literal: String): Boolean = - literal in configPropertyLiterals + internal fun classifySourceLiteral(literal: String): Boolean = literal in configPropertyLiterals @Test fun `no tramai identifier literals outside the runtime event catalogue`() { @@ -117,25 +125,31 @@ class RuntimeEventCatalogueArchitectureTest { .withFailMessage( "Runtime event/metric/attribute identifiers must live in the runtime event catalogue " + "(dev.tramai.core.observation.event). Found literals elsewhere: $offenders", - ) - .isEmpty() + ).isEmpty() } - private fun moduleClassesLocations(): List> = listOf( - "core" to "dev.tramai.core.observation.event.RuntimeEventCatalogue", - "engine" to "dev.tramai.engine.EngineEventObserver", - "orchestration" to "dev.tramai.orchestration.TramaiWorker", - "observability" to "dev.tramai.observability.OpenTelemetryAttributesKt", - ).map { (label, className) -> - label to checkNotNull( - Class.forName(className).protectionDomain.codeSource.location.toString(), - ) { "Unable to locate classes for $className" } - } + private fun moduleClassesLocations(): List> = + listOf( + "core" to "dev.tramai.core.observation.event.RuntimeEventCatalogue", + "engine" to "dev.tramai.engine.EngineEventObserver", + "orchestration" to "dev.tramai.orchestration.TramaiWorker", + "observability" to "dev.tramai.observability.OpenTelemetryAttributesKt", + ).map { (label, className) -> + label to + checkNotNull( + Class + .forName(className) + .protectionDomain.codeSource.location + .toString(), + ) { "Unable to locate classes for $className" } + } private fun loadClasses(location: String): List> { if (location.endsWith(".jar")) { return JarFile(File(URI.create(location))).use { jar -> - jar.entries().asSequence() + jar + .entries() + .asSequence() .filter { it.name.endsWith(".class") } .map { it.name.removeSuffix(".class") to jar.getInputStream(it).readBytes() } .toList() @@ -148,27 +162,30 @@ class RuntimeEventCatalogueArchitectureTest { .filter { it.isFile && it.name.endsWith(".class") } .map { classFile -> classFile.relativeTo(dir).path.removeSuffix(".class") to classFile.readBytes() - } - .toList() + }.toList() } private fun tramaiLiterals(bytes: ByteArray): List { val literals = mutableListOf() - ClassReader(bytes).accept(object : ClassVisitor(Opcodes.ASM9) { - override fun visitMethod( - access: Int, - name: String, - descriptor: String, - signature: String?, - exceptions: Array?, - ): MethodVisitor = object : MethodVisitor(Opcodes.ASM9) { - override fun visitLdcInsn(value: Any?) { - if (value is String && value.startsWith("tramai.")) { - literals.add(value) + ClassReader(bytes).accept( + object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array?, + ): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9) { + override fun visitLdcInsn(value: Any?) { + if (value is String && value.startsWith("tramai.")) { + literals.add(value) + } + } } - } - } - }, 0) + }, + 0, + ) return literals } @@ -180,16 +197,20 @@ class RuntimeEventCatalogueArchitectureTest { * exact declared configuration-property literal. */ private fun sourceLiterals(): List> { - val repoRoot = generateSequence(File(".").absoluteFile) { it.parentFile } - .first { it.resolve("settings.gradle.kts").isFile } + val repoRoot = + generateSequence(File(".").absoluteFile) { it.parentFile } + .first { it.resolve("settings.gradle.kts").isFile } val offenders = mutableListOf>() val literalRegex = Regex("\"tramai\\.[a-zA-Z0-9_.-]*\"") - repoRoot.listFiles()?.asSequence() + repoRoot + .listFiles() + ?.asSequence() ?.filter { it.isDirectory && it.name.startsWith("tramai-") } ?.forEach { module -> val mainDir = File(module, "src/main") if (!mainDir.isDirectory) return@forEach - mainDir.walkTopDown() + mainDir + .walkTopDown() .filter { it.isFile && it.extension == "kt" } .forEach { file -> val relative = file.relativeTo(repoRoot).path diff --git a/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueVerifierRulesTest.kt b/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueVerifierRulesTest.kt index abd6cfd8c..12ed9be02 100644 --- a/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueVerifierRulesTest.kt +++ b/tramai-observability/src/test/kotlin/dev/tramai/observability/RuntimeEventCatalogueVerifierRulesTest.kt @@ -14,7 +14,6 @@ import kotlin.test.assertTrue * declared configuration-property literals may appear as `tramai.` strings. */ class RuntimeEventCatalogueVerifierRulesTest { - private val verifier = RuntimeEventCatalogueArchitectureTest() @Test @@ -44,6 +43,15 @@ class RuntimeEventCatalogueVerifierRulesTest { assertTrue(verifier.classifySourceLiteral("tramai.sovereign.ops.outbox.worker")) assertTrue(verifier.classifySourceLiteral("tramai.security.classification")) assertTrue(verifier.classifySourceLiteral("tramai.providers.openai.apiKey")) + assertTrue(verifier.classifySourceLiteral("tramai.control-plane.http")) + } + + @Test + fun `a new literal beneath a declared control-plane prefix is still an offender`() { + // Declaring the adapter's own gate must not open the subtree: anything else under the + // control-plane namespace is protocol and would have to reference the catalogue. + assertFalse(verifier.classifySourceLiteral("tramai.control-plane.http.mutations")) + assertFalse(verifier.classifySourceLiteral("tramai.control-plane.state_version")) } @Test diff --git a/tramai-orchestration/api/tramai-orchestration.api b/tramai-orchestration/api/tramai-orchestration.api index 97314f10f..9b733a30d 100644 --- a/tramai-orchestration/api/tramai-orchestration.api +++ b/tramai-orchestration/api/tramai-orchestration.api @@ -218,6 +218,23 @@ public final class dev/tramai/orchestration/GateDecision$Companion { public final fun reject (Ljava/lang/String;)Ldev/tramai/orchestration/GateDecision; } +public final class dev/tramai/orchestration/GovernedRun { + public static final field Companion Ldev/tramai/orchestration/GovernedRun$Companion; + public fun (Ldev/tramai/orchestration/WorkflowContext;Ldev/tramai/core/identity/GovernedRunIdentity;)V + public final fun getContext ()Ldev/tramai/orchestration/WorkflowContext; + public final fun getIdentity ()Ldev/tramai/core/identity/GovernedRunIdentity; +} + +public final class dev/tramai/orchestration/GovernedRun$Companion { + public final fun resume (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ljava/lang/String;)Ldev/tramai/orchestration/GovernedRun; + public final fun start (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ljava/util/Map;)Ldev/tramai/orchestration/GovernedRun; + public static synthetic fun start$default (Ldev/tramai/orchestration/GovernedRun$Companion;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ljava/util/Map;ILjava/lang/Object;)Ldev/tramai/orchestration/GovernedRun; +} + +public final class dev/tramai/orchestration/GovernedRunKt { + public static final fun recoverGovernedRun (Ldev/tramai/orchestration/WorkflowPersistence;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/orchestration/HermesStepConfig { public fun ()V public fun (JJLjava/lang/String;Ljava/lang/String;Ldev/tramai/core/security/StepSecurityConfig;)V @@ -1107,9 +1124,13 @@ public final class dev/tramai/orchestration/Workflow { public final fun getSchedule ()Ldev/tramai/orchestration/WorkflowScheduleDefinition; public final fun getStateType ()Lkotlin/reflect/KType; public final fun requiredExternalStepTypes ()Ljava/util/Set; + public final fun resume (Ldev/tramai/orchestration/GovernedRun;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun resume (Ldev/tramai/orchestration/WorkflowContext;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun resume$default (Ldev/tramai/orchestration/Workflow;Ldev/tramai/orchestration/GovernedRun;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun resume$default (Ldev/tramai/orchestration/Workflow;Ldev/tramai/orchestration/WorkflowContext;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun run (Ljava/lang/Object;Ldev/tramai/orchestration/GovernedRun;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun run (Ljava/lang/Object;Ldev/tramai/orchestration/WorkflowContext;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun run$default (Ldev/tramai/orchestration/Workflow;Ljava/lang/Object;Ldev/tramai/orchestration/GovernedRun;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun run$default (Ldev/tramai/orchestration/Workflow;Ljava/lang/Object;Ldev/tramai/orchestration/WorkflowContext;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } diff --git a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/GovernedRun.kt b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/GovernedRun.kt new file mode 100644 index 000000000..90eb6974f --- /dev/null +++ b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/GovernedRun.kt @@ -0,0 +1,91 @@ +package dev.tramai.orchestration + +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadDeploymentIdentity + +/** + * Recovers the governed execution envelope of an ALREADY EXISTING run (0.7.1d). + * + * Continuation paths recover identity; they never recreate it. The deployment identity + * is therefore read from the run's own durable checkpoint — never rebuilt from whatever + * schedule, registration or binding happens to exist at continuation time, which could + * have changed after the run was created. + * + * Returns null for an intentionally ungoverned (legacy) run, and fails closed (throws) + * when persisted attribution is partial or malformed: a run that was governed must never + * continue un-attributed. + */ +suspend fun WorkflowPersistence.recoverGovernedRun( + workflowName: String, + workflowId: String, +): GovernedRun? { + // Missing checkpoint and legacy-without-attribution are both "nothing to recover"; + // PARTIAL attribution throws inside the decoder, so a governed run can never be + // silently downgraded to an unattributed one. + val checkpoint = checkpointStore.load(workflowName, workflowId) ?: return null + val identity = decodeGovernedRunAttribution(checkpoint.workflowId, checkpoint.metadata) + return identity?.let { + GovernedRun(context = WorkflowContext(workflowId = checkpoint.workflowId), identity = it) + } +} + +/** + * Additive governed execution envelope (0.7.1d). + * + * Carries the canonical [GovernedRunIdentity] ALONGSIDE the existing + * [WorkflowContext] instead of widening it: `WorkflowContext(workflowId, + * attributes)` stays the public contract, so ungoverned execution and its ABI are + * untouched and an ungoverned run cannot accidentally acquire attribution. + * + * The boundary invariant is enforced once, at construction: + * `context.workflowId == identity.runId.value`. An envelope that could describe two + * different runs cannot exist, so no subsystem has to re-check it later. + * + * This type states identity only. Nothing here generates, rewrites or interprets + * attribution: the run id IS the workflow id, for the whole life of the run. + */ +class GovernedRun( + val context: WorkflowContext, + val identity: GovernedRunIdentity, +) { + init { + require(identity.runId.value == context.workflowId) { + "GovernedRunIdentity.runId ('${identity.runId.value}') must equal " + + "WorkflowContext.workflowId ('${context.workflowId}')" + } + } + + companion object { + /** + * Starts a NEW governed execution of a workload deployment. + * + * The canonical identity is established exactly once, here: the generated + * workflow id becomes the run id, so identity and run cannot drift apart. + */ + fun start( + deployment: WorkloadDeploymentIdentity, + attributes: Map = emptyMap(), + ): GovernedRun { + val context = WorkflowContext(attributes = attributes) + return GovernedRun( + context = context, + identity = GovernedRunIdentity(deployment = deployment, runId = RunId(context.workflowId)), + ) + } + + /** + * Rebuilds the boundary for an EXISTING governed run (restart, resume, + * worker recovery or delayed wake-up). The run id is never regenerated: a new + * one would silently turn a resume into a second execution of the same run. + */ + fun resume( + deployment: WorkloadDeploymentIdentity, + runId: String, + ): GovernedRun = + GovernedRun( + context = WorkflowContext(workflowId = runId), + identity = GovernedRunIdentity(deployment = deployment, runId = RunId(runId)), + ) + } +} diff --git a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/GovernedRunAttribution.kt b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/GovernedRunAttribution.kt new file mode 100644 index 000000000..f9afddfbd --- /dev/null +++ b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/GovernedRunAttribution.kt @@ -0,0 +1,172 @@ +package dev.tramai.orchestration + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId + +/** + * Reserved framework metadata keys carrying governed-run attribution (0.7.1d). + * + * This is a PERSISTENCE ENCODING, not an authority: the authority is the + * canonical [GovernedRunIdentity] of the running execution. The reserved keys are + * owned by the framework — application checkpoint metadata must never override + * them, and the framework always writes them last. + * + * Deliberately NOT prefixed `tramai.`: the architecture guard + * (`RuntimeEventCatalogueArchitectureTest`) reserves that namespace for runtime + * identifiers and configuration properties, and a persisted metadata key is + * neither. Renaming these keys back under `tramai.` re-breaks that guard. + */ +internal const val GOVERNED_RUN_WORKLOAD_KEY: String = "checkpoint.identity.workload" +internal const val GOVERNED_RUN_CONFIGURATION_KEY: String = "checkpoint.identity.configuration" +internal const val GOVERNED_RUN_CONFIGURATION_VERSION_KEY: String = "checkpoint.identity.configuration_version" +internal const val GOVERNED_RUN_ENVIRONMENT_KEY: String = "checkpoint.identity.environment" +internal const val GOVERNED_RUN_DEPLOYMENT_KEY: String = "checkpoint.identity.deployment" + +private val GOVERNED_RUN_ATTRIBUTION_KEYS: List = + listOf( + GOVERNED_RUN_WORKLOAD_KEY, + GOVERNED_RUN_CONFIGURATION_KEY, + GOVERNED_RUN_CONFIGURATION_VERSION_KEY, + GOVERNED_RUN_ENVIRONMENT_KEY, + GOVERNED_RUN_DEPLOYMENT_KEY, + ) + +/** + * Encodes attribution into the reserved checkpoint metadata keys. + * + * The run id is deliberately NOT encoded: the checkpoint's own `workflowId` IS the + * run id, so persistence cannot store a second identifier that diverges from the + * run it describes. Absent attribution encodes to an empty map, which keeps + * ungoverned (legacy) checkpoints byte-compatible with records written before + * attribution existed. + */ +internal fun encodeGovernedRunAttribution(identity: GovernedRunIdentity?): Map { + if (identity == null) return emptyMap() + val deployment = identity.deployment + return mapOf( + GOVERNED_RUN_WORKLOAD_KEY to deployment.workloadId.value, + GOVERNED_RUN_CONFIGURATION_KEY to deployment.configuration.id.value, + GOVERNED_RUN_CONFIGURATION_VERSION_KEY to deployment.configuration.version.value, + GOVERNED_RUN_ENVIRONMENT_KEY to deployment.environmentId.value, + GOVERNED_RUN_DEPLOYMENT_KEY to deployment.deploymentId.value, + ) +} + +/** + * Decodes persisted attribution against the checkpoint's own `workflowId`, which is + * the authoritative run id. + * + * Fail closed: all reserved keys absent means an intentionally ungoverned (legacy) + * checkpoint; a PARTIAL set is corruption and throws instead of resuming + * un-attributed. Never returns a partially-attributed identity. + */ +internal fun decodeGovernedRunAttribution( + workflowId: String, + metadata: Map, +): GovernedRunIdentity? { + val values = GOVERNED_RUN_ATTRIBUTION_KEYS.map { key -> metadata[key]?.takeIf { it.isNotBlank() } } + if (values.all { it == null }) return null + if (values.any { it == null }) { + throw WorkflowCheckpointCorruptionException( + "Persisted governed run attribution is incomplete for workflow run '$workflowId': " + + "all of ${GOVERNED_RUN_ATTRIBUTION_KEYS.joinToString()} must be present, or none", + ) + } + return try { + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(metadata.component(GOVERNED_RUN_WORKLOAD_KEY)), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(metadata.component(GOVERNED_RUN_CONFIGURATION_KEY)), + version = + ConfigurationVersion( + metadata.component(GOVERNED_RUN_CONFIGURATION_VERSION_KEY), + ), + ), + environmentId = EnvironmentId(metadata.component(GOVERNED_RUN_ENVIRONMENT_KEY)), + deploymentId = DeploymentId(metadata.component(GOVERNED_RUN_DEPLOYMENT_KEY)), + ), + runId = RunId(workflowId), + ) + } catch (error: IllegalArgumentException) { + throw WorkflowCheckpointCorruptionException( + "Persisted governed run attribution is invalid for workflow run '$workflowId': ${error.message}", + ).apply { initCause(error) } + } +} + +/** + * Continuity gate at every reconstruction boundary (0.7.1d). + * + * A governed run has exactly one canonical [GovernedRunIdentity]; resume must + * restore the persisted one unchanged or refuse. Comparing the run id alone is + * explicitly insufficient: a run id can be replayed against another workload, + * configuration, environment or deployment. + * + * - both absent → intentionally ungoverned (legacy) run, resume proceeds; + * - exactly one absent → fails closed (a governed run cannot become un-attributed, + * and an ungoverned run cannot be resumed as governed); + * - both present and equal → continuity proven; + * - both present and different → substitution attempt, rejected. + */ +internal fun requireGovernedRunAttributionContinuity( + workflowName: String, + workflowId: String, + persisted: GovernedRunIdentity?, + requested: GovernedRunIdentity?, +) { + if (persisted == requested) return + val detail = + when { + persisted == null -> "checkpoint carries no governed attribution" + requested == null -> "resume carries no governed attribution" + else -> "attribution was substituted (${describeAttributionSubstitution(persisted, requested)})" + } + throw WorkflowResumeException( + "Governed run attribution is not continuous for workflow '$workflowName' and workflowId='$workflowId': $detail", + ) +} + +private fun describeAttributionSubstitution( + persisted: GovernedRunIdentity, + requested: GovernedRunIdentity, +): String = + buildList { + if (persisted.runId != requested.runId) { + add("runId '${persisted.runId}' != '${requested.runId}'") + } + val expected = persisted.deployment + val actual = requested.deployment + if (expected.workloadId != actual.workloadId) { + add("workloadId '${expected.workloadId}' != '${actual.workloadId}'") + } + if (expected.configuration.id != actual.configuration.id) { + add("configurationId '${expected.configuration.id}' != '${actual.configuration.id}'") + } + if (expected.configuration.version != actual.configuration.version) { + add("configurationVersion '${expected.configuration.version}' != '${actual.configuration.version}'") + } + if (expected.environmentId != actual.environmentId) { + add("environmentId '${expected.environmentId}' != '${actual.environmentId}'") + } + if (expected.deploymentId != actual.deploymentId) { + add("deploymentId '${expected.deploymentId}' != '${actual.deploymentId}'") + } + }.joinToString(", ").ifEmpty { "identity differs" } + +/** + * One reserved component, read by KEY rather than by position so the mapping between the + * persisted keys and the identity is readable and cannot drift. The caller has already + * established all-or-none, so an absent value here is corruption, not a missing default. + */ +private fun Map.component(key: String): String = + requireNotNull(get(key)) { "governed run attribution is missing a required component" } diff --git a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/Workflow.kt b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/Workflow.kt index 9a08279da..3b4aaae59 100644 --- a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/Workflow.kt +++ b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/Workflow.kt @@ -13,7 +13,9 @@ interface WorkflowScheduleDefinition { val kind: String val expression: String val zoneId: ZoneId + fun validate() + fun canonicalForm(): String = "$kind:${zoneId.id}:$expression" } @@ -40,27 +42,29 @@ class Workflow internal constructor( private val outboundNetworkPolicy: OutboundNetworkPolicy = OutboundNetworkPolicies.defenceInDepth(), private val failureDiagnosticObserver: WorkflowStepFailureDiagnosticObserver = NoOpWorkflowStepFailureDiagnosticObserver, ) { - private val definitionCompatibility: WorkflowDefinitionCompatibility = workflowDefinitionCompatibility( - workflowName = name, - definitionVersion = definitionVersion, - schedule = schedule, - stopPolicy = stopPolicy, - steps = steps, - ) - - private val runner = WorkflowRunner( - name = name, - steps = steps, - resultSelector = resultSelector, - stopPolicy = stopPolicy, - clock = clock, - externalStepExecutorResolver = externalStepExecutorResolver, - httpClient = httpClient, - httpTransport = httpTransport, - outboundNetworkPolicy = outboundNetworkPolicy, - failureDiagnosticObserver = failureDiagnosticObserver, - definitionCompatibility = definitionCompatibility, - ) + private val definitionCompatibility: WorkflowDefinitionCompatibility = + workflowDefinitionCompatibility( + workflowName = name, + definitionVersion = definitionVersion, + schedule = schedule, + stopPolicy = stopPolicy, + steps = steps, + ) + + private val runner = + WorkflowRunner( + name = name, + steps = steps, + resultSelector = resultSelector, + stopPolicy = stopPolicy, + clock = clock, + externalStepExecutorResolver = externalStepExecutorResolver, + httpClient = httpClient, + httpTransport = httpTransport, + outboundNetworkPolicy = outboundNetworkPolicy, + failureDiagnosticObserver = failureDiagnosticObserver, + definitionCompatibility = definitionCompatibility, + ) suspend fun run( initialState: S, @@ -75,6 +79,27 @@ class Workflow internal constructor( persistence: WorkflowPersistence, ): R = runner.resume(context, observer, persistence) + /** + * Governed execution boundary (0.7.1d). + * + * The canonical [GovernedRunIdentity] travels alongside the unchanged + * [WorkflowContext] rather than inside it: an ungoverned run cannot acquire + * attribution, and a governed run cannot lose it. + */ + suspend fun run( + initialState: S, + run: GovernedRun, + observer: WorkflowObserver = NoOpWorkflowObserver, + persistence: WorkflowPersistence? = null, + ): R = runner.run(initialState, run.context, observer, persistence, run.identity) + + /** Governed resume of an existing run: the persisted identity must match exactly. */ + suspend fun resume( + run: GovernedRun, + observer: WorkflowObserver = NoOpWorkflowObserver, + persistence: WorkflowPersistence, + ): R = runner.resume(run.context, observer, persistence, run.identity) + fun requiredExternalStepTypes(): Set = collectPluginStepTypes(steps) /** Canonical definition digest — stable identity of this workflow's definition. */ @@ -120,20 +145,59 @@ inline fun workflow( private suspend fun InternalWorkflowStep.replayDescriptor( state: S, context: WorkflowContext, -): WorkflowStepReplayDescriptor = when (this) { - is LocalWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) - is AiWorkflowStep -> replayDescriptor(state, context) - is HttpWorkflowStep -> replayDescriptor(state, context) - is ShellWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) - is HermesWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) - is CodexWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) - is McpWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) - is PluginWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.NON_REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) - is GateWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) - is DelayWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) - is BranchWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) - is ParallelWorkflowStep -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) -} +): WorkflowStepReplayDescriptor = + when (this) { + is LocalWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) + } + + is AiWorkflowStep -> { + replayDescriptor(state, context) + } + + is HttpWorkflowStep -> { + replayDescriptor(state, context) + } + + is ShellWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) + } + + is HermesWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) + } + + is CodexWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) + } + + is McpWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) + } + + is PluginWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.NON_REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) + } + + is GateWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) + } + + is DelayWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) + } + + is BranchWorkflowStep -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.PURE) + } + + is ParallelWorkflowStep -> { + WorkflowStepReplayDescriptor( + WorkflowStepReplayability.REPLAYABLE, + WorkflowStepRepetitionSafety.UNSAFE, + ) + } + } private suspend fun HttpWorkflowStep.replayDescriptor( state: S, @@ -147,14 +211,18 @@ private suspend fun HttpWorkflowStep.replayDescriptor( "OPTIONS", "PUT", // idempotent per HTTP; replay safety still assumes no extra side effects on repeat "DELETE", - -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.IDEMPOTENT) + -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.IDEMPOTENT) + } "POST", "PATCH", -> { - val idempotencyKey = request.headers.entries.firstOrNull { (name, _) -> - name.equals("Idempotency-Key", ignoreCase = true) - }?.value + val idempotencyKey = + request.headers.entries + .firstOrNull { (name, _) -> + name.equals("Idempotency-Key", ignoreCase = true) + }?.value if (idempotencyKey.isNullOrBlank()) { WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) } else { @@ -166,21 +234,30 @@ private suspend fun HttpWorkflowStep.replayDescriptor( } } - else -> WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) + else -> { + WorkflowStepReplayDescriptor(WorkflowStepReplayability.REPLAYABLE, WorkflowStepRepetitionSafety.UNSAFE) + } } } -private fun collectPluginStepTypes(steps: List>): Set = buildSet { - steps.forEach { step -> - when (step) { - is PluginWorkflowStep<*> -> add(step.type) - is BranchWorkflowStep<*> -> { - step.branches.values.forEach { branchSteps -> - addAll(collectPluginStepTypes(branchSteps)) +private fun collectPluginStepTypes(steps: List>): Set = + buildSet { + steps.forEach { step -> + when (step) { + is PluginWorkflowStep<*> -> { + add(step.type) + } + + is BranchWorkflowStep<*> -> { + step.branches.values.forEach { branchSteps -> + addAll(collectPluginStepTypes(branchSteps)) + } + step.defaultSteps?.let { addAll(collectPluginStepTypes(it)) } + } + + else -> { + Unit } - step.defaultSteps?.let { addAll(collectPluginStepTypes(it)) } } - else -> Unit } } -} diff --git a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowDefinitionCompatibility.kt b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowDefinitionCompatibility.kt index 500ec9423..20b65edcc 100644 --- a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowDefinitionCompatibility.kt +++ b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowDefinitionCompatibility.kt @@ -23,21 +23,22 @@ internal fun workflowDefinitionCompatibility( stopPolicy: StopPolicy, steps: List>, ): WorkflowDefinitionCompatibility { - val canonical = buildString { - append("workflow:") - append(workflowName) - append('\n') - append("stop_policy.max_step_executions:") - append(stopPolicy.maxStepExecutions) - append('\n') - append("stop_policy.max_parallel_branches:") - append(stopPolicy.maxParallelBranches) - append('\n') - append("schedule:") - append(schedule?.canonicalForm() ?: "none") - append('\n') - append(renderStepsCanonical(steps)) - } + val canonical = + buildString { + append("workflow:") + append(workflowName) + append('\n') + append("stop_policy.max_step_executions:") + append(stopPolicy.maxStepExecutions) + append('\n') + append("stop_policy.max_parallel_branches:") + append(stopPolicy.maxParallelBranches) + append('\n') + append("schedule:") + append(schedule?.canonicalForm() ?: "none") + append('\n') + append(renderStepsCanonical(steps)) + } return WorkflowDefinitionCompatibility( version = definitionVersion, digest = sha256Hex(canonical), @@ -45,26 +46,25 @@ internal fun workflowDefinitionCompatibility( ) } -private fun renderStepsCanonical( - steps: List>, -): String = buildString { - for (step in steps) { - when (step) { - is LocalWorkflowStep -> renderLocalStepCanonical(this, step) - is AiWorkflowStep<*, *, *> -> renderAiStepCanonical(this, step) - is HttpWorkflowStep<*> -> renderHttpStepCanonical(this, step) - is ShellWorkflowStep<*> -> renderShellStepCanonical(this, step) - is HermesWorkflowStep<*> -> renderHermesStepCanonical(this, step) - is CodexWorkflowStep<*> -> renderCodexStepCanonical(this, step) - is McpWorkflowStep<*> -> renderMcpStepCanonical(this, step) - is PluginWorkflowStep<*> -> renderPluginStepCanonical(this, step) - is GateWorkflowStep -> renderGateStepCanonical(this, step) - is DelayWorkflowStep -> renderDelayStepCanonical(this, step) - is ParallelWorkflowStep<*, *, *> -> renderParallelStepCanonical(this, step) - is BranchWorkflowStep -> renderBranchStepCanonical(this, step) +private fun renderStepsCanonical(steps: List>): String = + buildString { + for (step in steps) { + when (step) { + is LocalWorkflowStep -> renderLocalStepCanonical(this, step) + is AiWorkflowStep<*, *, *> -> renderAiStepCanonical(this, step) + is HttpWorkflowStep<*> -> renderHttpStepCanonical(this, step) + is ShellWorkflowStep<*> -> renderShellStepCanonical(this, step) + is HermesWorkflowStep<*> -> renderHermesStepCanonical(this, step) + is CodexWorkflowStep<*> -> renderCodexStepCanonical(this, step) + is McpWorkflowStep<*> -> renderMcpStepCanonical(this, step) + is PluginWorkflowStep<*> -> renderPluginStepCanonical(this, step) + is GateWorkflowStep -> renderGateStepCanonical(this, step) + is DelayWorkflowStep -> renderDelayStepCanonical(this, step) + is ParallelWorkflowStep<*, *, *> -> renderParallelStepCanonical(this, step) + is BranchWorkflowStep -> renderBranchStepCanonical(this, step) + } } } -} private fun renderLocalStepCanonical( sb: StringBuilder, @@ -97,7 +97,11 @@ private fun renderHttpStepCanonical( sb.append(':') sb.append(step.config.maxRetries) sb.append(':') - sb.append(step.config.retryOnStatus.sorted().joinToString(",")) + sb.append( + step.config.retryOnStatus + .sorted() + .joinToString(","), + ) sb.append('\n') } @@ -116,15 +120,27 @@ private fun renderShellStepCanonical( sb.append(':') sb.append(step.config.failOnStderr) sb.append(':') - sb.append(step.config.allowedCommands.sorted().joinToString(",")) + sb.append( + step.config.allowedCommands + .sorted() + .joinToString(","), + ) sb.append(':') - sb.append(step.config.deniedCommands.sorted().joinToString(",")) + sb.append( + step.config.deniedCommands + .sorted() + .joinToString(","), + ) sb.append(':') sb.append(step.definition.executable) sb.append(':') sb.append(step.definition.hasWorkdir) sb.append(':') - sb.append(step.definition.envKeys.sorted().joinToString(",")) + sb.append( + step.definition.envKeys + .sorted() + .joinToString(","), + ) sb.append('\n') } @@ -175,25 +191,44 @@ private fun renderMcpStepCanonical( sb.append(':') sb.append(step.config.reconnect) sb.append(':') - sb.append(step.config.toolAllowlist?.sorted()?.joinToString(",") ?: "*") + sb.append( + step.config.toolAllowlist + ?.sorted() + ?.joinToString(",") ?: "*", + ) sb.append(':') sb.append( if (step.config.enforceCommandAllowlist) { - step.config.allowedCommands.sorted().joinToString(",") + step.config.allowedCommands + .sorted() + .joinToString(",") } else { "*" }, ) sb.append(':') - sb.append(step.config.deniedCommands.sorted().joinToString(",")) + sb.append( + step.config.deniedCommands + .sorted() + .joinToString(","), + ) sb.append(':') sb.append(step.definition.serverCommand.joinToString(",")) sb.append(':') - sb.append(step.definition.serverEnv.map { (k, v) -> "$k=$v" }.sorted().joinToString(",")) + sb.append( + step.definition.serverEnv + .map { (k, v) -> "$k=$v" } + .sorted() + .joinToString(","), + ) sb.append(':') sb.append(step.definition.toolName) sb.append(':') - sb.append(step.definition.argumentKeys.sorted().joinToString(",")) + sb.append( + step.definition.argumentKeys + .sorted() + .joinToString(","), + ) sb.append('\n') } @@ -263,59 +298,111 @@ private fun renderBranchStepCanonical( } } -private fun renderPluginValueCanonical(value: Any?): String = when (value) { - null -> "null" - is String -> "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" - is Number, is Boolean -> value.toString() - is Map<*, *> -> value.entries - .sortedBy { it.key?.toString() ?: "" } - .joinToString(prefix = "{", postfix = "}") { entry -> - "${renderPluginValueCanonical(entry.key?.toString())}:${renderPluginValueCanonical(entry.value)}" +private fun renderPluginValueCanonical(value: Any?): String = + when (value) { + null -> { + "null" + } + + is String -> { + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + } + + is Number, is Boolean -> { + value.toString() + } + + is Map<*, *> -> { + value.entries + .sortedBy { it.key?.toString() ?: "" } + .joinToString(prefix = "{", postfix = "}") { entry -> + "${renderPluginValueCanonical(entry.key?.toString())}:${renderPluginValueCanonical(entry.value)}" + } + } + + is Iterable<*> -> { + value.joinToString(prefix = "[", postfix = "]") { element -> + renderPluginValueCanonical(element) + } + } + + is Array<*> -> { + value.joinToString(prefix = "[", postfix = "]") { element -> + renderPluginValueCanonical(element) + } } - is Iterable<*> -> value.joinToString(prefix = "[", postfix = "]") { element -> - renderPluginValueCanonical(element) - } - is Array<*> -> value.joinToString(prefix = "[", postfix = "]") { element -> - renderPluginValueCanonical(element) - } - else -> renderPluginValueCanonical(value.toString()) -} -private fun sha256Hex(value: String): String = MessageDigest - .getInstance(WORKFLOW_DEFINITION_DIGEST_ALGORITHM) - .digest(value.toByteArray()) - .joinToString(separator = "") { byte -> - byte.toInt().and(0xff).toString(16).padStart(2, '0') + else -> { + renderPluginValueCanonical(value.toString()) + } } -internal fun WorkflowDefinitionCompatibility.toCheckpointMetadata(): Map = mapOf( - WORKFLOW_DEFINITION_VERSION_METADATA_KEY to version, - WORKFLOW_DEFINITION_DIGEST_METADATA_KEY to digest, - WORKFLOW_DEFINITION_DIGEST_ALGORITHM_METADATA_KEY to digestAlgorithm, -) +private fun sha256Hex(value: String): String = + MessageDigest + .getInstance(WORKFLOW_DEFINITION_DIGEST_ALGORITHM) + .digest(value.toByteArray()) + .joinToString(separator = "") { byte -> + byte + .toInt() + .and(0xff) + .toString(16) + .padStart(2, '0') + } + +internal fun WorkflowDefinitionCompatibility.toCheckpointMetadata(): Map = + mapOf( + WORKFLOW_DEFINITION_VERSION_METADATA_KEY to version, + WORKFLOW_DEFINITION_DIGEST_METADATA_KEY to digest, + WORKFLOW_DEFINITION_DIGEST_ALGORITHM_METADATA_KEY to digestAlgorithm, + ) + +/** + * Resume preconditions owned by the checkpoint itself: recovery state and step-index range. + * Kept next to the definition-compatibility check so a resume failure surface is auditable + * in one place. + */ +internal fun WorkflowCheckpoint.requireResumePreconditions( + workflowName: String, + stepCount: Int, +) { + if (recoveryState is WorkflowRecoveryState.Required) { + throw WorkflowRecoveryStateException( + "Workflow '$workflowName'/'$workflowId' is in Required recovery state and cannot be resumed", + ) + } + if (nextStepIndex < 0 || nextStepIndex > stepCount) { + throw WorkflowResumeException( + "Checkpoint for workflow '$workflowName' and workflowId='$workflowId' has invalid " + + "nextStepIndex=$nextStepIndex; valid range is 0..$stepCount", + ) + } +} internal fun WorkflowCheckpoint.requireWorkflowDefinitionCompatibility( workflowName: String, workflowId: String, ): WorkflowDefinitionCompatibility { - val version = metadata[WORKFLOW_DEFINITION_VERSION_METADATA_KEY] - ?: throw missingDefinitionMetadataException( - workflowName = workflowName, - workflowId = workflowId, - missingKey = WORKFLOW_DEFINITION_VERSION_METADATA_KEY, - ) - val digest = metadata[WORKFLOW_DEFINITION_DIGEST_METADATA_KEY] - ?: throw missingDefinitionMetadataException( - workflowName = workflowName, - workflowId = workflowId, - missingKey = WORKFLOW_DEFINITION_DIGEST_METADATA_KEY, - ) - val digestAlgorithm = metadata[WORKFLOW_DEFINITION_DIGEST_ALGORITHM_METADATA_KEY] - ?: throw missingDefinitionMetadataException( - workflowName = workflowName, - workflowId = workflowId, - missingKey = WORKFLOW_DEFINITION_DIGEST_ALGORITHM_METADATA_KEY, - ) + val version = + metadata[WORKFLOW_DEFINITION_VERSION_METADATA_KEY] + ?: throw missingDefinitionMetadataException( + workflowName = workflowName, + workflowId = workflowId, + missingKey = WORKFLOW_DEFINITION_VERSION_METADATA_KEY, + ) + val digest = + metadata[WORKFLOW_DEFINITION_DIGEST_METADATA_KEY] + ?: throw missingDefinitionMetadataException( + workflowName = workflowName, + workflowId = workflowId, + missingKey = WORKFLOW_DEFINITION_DIGEST_METADATA_KEY, + ) + val digestAlgorithm = + metadata[WORKFLOW_DEFINITION_DIGEST_ALGORITHM_METADATA_KEY] + ?: throw missingDefinitionMetadataException( + workflowName = workflowName, + workflowId = workflowId, + missingKey = WORKFLOW_DEFINITION_DIGEST_ALGORITHM_METADATA_KEY, + ) return WorkflowDefinitionCompatibility( version = version, digest = digest, @@ -350,6 +437,7 @@ internal fun missingDefinitionMetadataException( workflowName: String, workflowId: String, missingKey: String, -): WorkflowResumeException = WorkflowResumeException( - "Checkpoint for workflow '$workflowName' and workflowId='$workflowId' is missing required workflow definition metadata '$missingKey'. Checkpoints created before the stable resume-compatibility contract cannot be resumed.", -) +): WorkflowResumeException = + WorkflowResumeException( + "Checkpoint for workflow '$workflowName' and workflowId='$workflowId' is missing required workflow definition metadata '$missingKey'. Checkpoints created before the stable resume-compatibility contract cannot be resumed.", + ) diff --git a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowExecutionSupervisor.kt b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowExecutionSupervisor.kt index e0e24f5f8..4d2a21f3c 100644 --- a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowExecutionSupervisor.kt +++ b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowExecutionSupervisor.kt @@ -14,8 +14,8 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.time.Clock import java.security.MessageDigest +import java.time.Clock import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference @@ -74,11 +74,12 @@ internal class WorkflowExecutionSupervisor( lease: WorkflowLease, ) { val scope = workerScope ?: return - val handle = ActiveExecution( - workflowName = checkpoint.workflowName, - workflowId = checkpoint.workflowId, - lease = AtomicReference(lease), - ) + val handle = + ActiveExecution( + workflowName = checkpoint.workflowName, + workflowId = checkpoint.workflowId, + lease = AtomicReference(lease), + ) val previous = activeExecutions.putIfAbsent(checkpoint.workflowId, handle) if (previous != null) { scope.launch { @@ -86,68 +87,85 @@ internal class WorkflowExecutionSupervisor( } return } - handle.executionJob = scope.launch { - try { - executeClaimedWorkflow(checkpoint, handle) - } finally { - handle.tracker?.close() - handle.renewalJob?.cancel() - activeExecutions.remove(checkpoint.workflowId, handle) + handle.executionJob = + scope.launch { + try { + executeClaimedWorkflow(checkpoint, handle) + } finally { + handle.tracker?.close() + handle.renewalJob?.cancel() + activeExecutions.remove(checkpoint.workflowId, handle) + } + } + handle.renewalJob = + scope.launch { + leaseRenewalLoop.renew(handle) } - } - handle.renewalJob = scope.launch { - leaseRenewalLoop.renew(handle) - } + } + + /** + * Absent definition metadata means no worker can ever route this checkpoint. Unlike an + * unbound version (which another worker may implement), it must surface as a visible + * failure rather than a silent skip: record it so the stranded checkpoint stays + * diagnosable, release the lease, and fail. Never returns. + */ + private suspend fun failMissingDefinitionMetadata( + checkpoint: WorkflowCheckpoint, + handle: ActiveExecution, + ): Nothing { + val error = + missingDefinitionMetadataException( + workflowName = checkpoint.workflowName, + workflowId = checkpoint.workflowId, + missingKey = WORKFLOW_DEFINITION_VERSION_METADATA_KEY, + ) + executionFailures[checkpoint.workflowId] = error + releaseLease(handle) + throw error } private suspend fun executeClaimedWorkflow( checkpoint: WorkflowCheckpoint, handle: ActiveExecution, ) { - val definitionVersion = checkpoint.metadata[WORKFLOW_DEFINITION_VERSION_METADATA_KEY] - ?: run { - // Absent definition metadata means no worker can ever route this - // checkpoint. Unlike an unbound version (which another worker may - // implement), this must surface as a visible failure instead of a - // silent skip: release the lease and record latestFailure so the - // stranded checkpoint is diagnosable. - val error = missingDefinitionMetadataException( - workflowName = checkpoint.workflowName, - workflowId = checkpoint.workflowId, - missingKey = WORKFLOW_DEFINITION_VERSION_METADATA_KEY, - ) - executionFailures[checkpoint.workflowId] = error + val definitionVersion = + checkpoint.metadata[WORKFLOW_DEFINITION_VERSION_METADATA_KEY] + ?: failMissingDefinitionMetadata(checkpoint, handle) + val binding = + workflowBindings.resolve(checkpoint.workflowName, definitionVersion) ?: run { releaseLease(handle) - throw error + return } - val binding = workflowBindings.resolve(checkpoint.workflowName, definitionVersion) ?: run { - releaseLease(handle) - return - } val typedWorkflow = binding.erased.workflow val context = WorkflowContext(workflowId = checkpoint.workflowId) - val tracker = ExecutionTracker( - workerId = config.workerId, - workflow = typedWorkflow, - binding = binding.erased, - context = context, - stepAttemptStore = stepAttemptStore, - observability = observability, - clock = typedWorkflow.clock, - leaseProvider = { handle.lease.get() }, - stepAttemptIdentitySource = stepAttemptIdentitySource, - ) + // Worker/recovery reconstruction (0.7.1d): attribution is restored from the + // durable checkpoint and never re-derived, so a resumed governed run keeps the + // exact identity it was created with. + val governedRunIdentity = decodeGovernedRunAttribution(checkpoint.workflowId, checkpoint.metadata) + val tracker = + ExecutionTracker( + workerId = config.workerId, + workflow = typedWorkflow, + binding = binding.erased, + context = context, + stepAttemptStore = stepAttemptStore, + observability = observability, + clock = typedWorkflow.clock, + leaseProvider = { handle.lease.get() }, + stepAttemptIdentitySource = stepAttemptIdentitySource, + ) handle.tracker = tracker tracker.prepareForCheckpoint(checkpoint) handle.lastRevision.set(checkpoint.revision) - val fencedCheckpointStore = LeaseFencedCheckpointStore( - delegate = checkpointStore, - leaseStore = leaseStore, - leaseProvider = { handle.lease.get() }, - tracker = tracker, - revisionSink = { handle.lastRevision.set(it) }, - ) + val fencedCheckpointStore = + LeaseFencedCheckpointStore( + delegate = checkpointStore, + leaseStore = leaseStore, + leaseProvider = { handle.lease.get() }, + tracker = tracker, + revisionSink = { handle.lastRevision.set(it) }, + ) val unknownAttempt = tracker.recoverAttemptIfNeeded(checkpoint) if (unknownAttempt != null) { @@ -159,17 +177,31 @@ internal class WorkflowExecutionSupervisor( ) try { when (unknownAttempt.resolutionAction) { - StepAttemptResolutionAction.RETRY_APPROVED -> recoveryCoordinator.consumeRetryApproval( - checkpoint = checkpoint, - expectedLease = handle.lease.get(), - tracker = tracker, - fencedCheckpointStore = fencedCheckpointStore, - attempt = unknownAttempt, - ) - StepAttemptResolutionAction.WORKFLOW_FAILED -> throw WorkflowRecoveryStateException( - "Attempt '${unknownAttempt.attemptId}' is resolved as WORKFLOW_FAILED and cannot be executed", - ) - null -> recoveryCoordinator.recoverUnknownAttempt(checkpoint, tracker, fencedCheckpointStore, unknownAttempt) + StepAttemptResolutionAction.RETRY_APPROVED -> { + recoveryCoordinator.consumeRetryApproval( + checkpoint = checkpoint, + expectedLease = handle.lease.get(), + tracker = tracker, + fencedCheckpointStore = fencedCheckpointStore, + attempt = unknownAttempt, + ) + } + + StepAttemptResolutionAction.WORKFLOW_FAILED -> { + throw WorkflowRecoveryStateException( + "Attempt '${unknownAttempt.attemptId}' is resolved as WORKFLOW_FAILED and cannot be " + + "executed", + ) + } + + null -> { + recoveryCoordinator.recoverUnknownAttempt( + checkpoint, + tracker, + fencedCheckpointStore, + unknownAttempt, + ) + } } } catch (error: Throwable) { // The recovery persistence paths throw NonReplayableStepStateUnknownException @@ -184,23 +216,37 @@ internal class WorkflowExecutionSupervisor( } } - val persistence = WorkflowPersistence( - checkpointStore = fencedCheckpointStore, - stateCodec = binding.erased.stateCodec, - delayWakeupScheduler = binding.erased.delayWakeupScheduler, - deleteCheckpointOnCompletion = binding.erased.deleteCheckpointOnCompletion, - ) - val observer = WorkerExecutionObserver( - workflowName = checkpoint.workflowName, - tracker = tracker, - ) + val persistence = + WorkflowPersistence( + checkpointStore = fencedCheckpointStore, + stateCodec = binding.erased.stateCodec, + delayWakeupScheduler = binding.erased.delayWakeupScheduler, + deleteCheckpointOnCompletion = binding.erased.deleteCheckpointOnCompletion, + ) + val observer = + WorkerExecutionObserver( + workflowName = checkpoint.workflowName, + tracker = tracker, + ) try { - typedWorkflow.resume( - context = context, - observer = observer, - persistence = persistence, - ) + // Worker/recovery reconstruction (0.7.1d): the canonical identity is restored + // from the durable checkpoint and never re-derived, so a resumed governed run + // keeps the exact identity it was created with. Ungoverned runs resume + // through the unchanged context-only path. + if (governedRunIdentity == null) { + typedWorkflow.resume( + context = context, + observer = observer, + persistence = persistence, + ) + } else { + typedWorkflow.resume( + run = GovernedRun(context = context, identity = governedRunIdentity), + observer = observer, + persistence = persistence, + ) + } executionFailures.remove(checkpoint.workflowId) releaseLease(handle) } catch (suspended: WorkflowSuspendedException) { @@ -309,9 +355,10 @@ internal class ExecutionTracker( ) { private val monitor = Any() private val observerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val completedObserverTransition = CompletableDeferred>().also { - it.complete(Result.success(Unit)) - } + private val completedObserverTransition = + CompletableDeferred>().also { + it.complete(Result.success(Unit)) + } private var observerTransitionTail: Deferred> = completedObserverTransition private val trackedStepNames = workflow.topLevelStepNames() private var inputFingerprint: String? = null @@ -337,11 +384,14 @@ internal class ExecutionTracker( return latest } if (latest.status == StepAttemptStatus.STARTED) { - val unknown = latest.copy( - status = StepAttemptStatus.UNKNOWN, - completedAt = clock.millis(), - outputSummary = latest.outputSummary ?: "Lease expired before the step reached a durable checkpoint", - ) + val unknown = + latest.copy( + status = StepAttemptStatus.UNKNOWN, + completedAt = clock.millis(), + outputSummary = + latest.outputSummary ?: "Lease expired before the step reached a durable " + + "checkpoint", + ) stepAttemptStore.updateStepAttempt(unknown) observability.onUnknownAttempt( runId = unknown.runId, @@ -371,22 +421,24 @@ internal class ExecutionTracker( ) fingerprint = inputFingerprint } - val attempt = StepAttemptRecord( - runId = context.workflowId, - stepName = stepName, - attemptId = stepAttemptIdentitySource.newAttemptId().also { generated -> - require(generated.isNotBlank()) { - "Step-attempt attemptId must not be blank (workflow '${context.workflowId}', step '$stepName', worker '$workerId')" - } - }, - workerId = workerId, - leaseToken = leaseProvider()?.leaseId ?: "unknown", - status = StepAttemptStatus.STARTED, - startedAt = clock.millis(), - idempotencyKey = descriptor.idempotencyKey, - replayPolicy = descriptor.toPersistedReplayPolicy(), - inputFingerprint = fingerprint, - ) + val attempt = + StepAttemptRecord( + runId = context.workflowId, + stepName = stepName, + attemptId = + stepAttemptIdentitySource.newAttemptId().also { generated -> + require(generated.isNotBlank()) { + "Step-attempt attemptId must not be blank (workflow '${context.workflowId}', step '$stepName', worker '$workerId')" + } + }, + workerId = workerId, + leaseToken = leaseProvider()?.leaseId ?: "unknown", + status = StepAttemptStatus.STARTED, + startedAt = clock.millis(), + idempotencyKey = descriptor.idempotencyKey, + replayPolicy = descriptor.toPersistedReplayPolicy(), + inputFingerprint = fingerprint, + ) stepAttemptStore.recordStepAttempt(attempt) synchronized(monitor) { activeAttempt = attempt @@ -400,10 +452,11 @@ internal class ExecutionTracker( "Workflow '${workflow.name}' and workflowId='${context.workflowId}' has no active lease for retry-approval consumption", ) } - val consumed = attempt.copy( - status = StepAttemptStatus.FAILED, - completedAt = attempt.completedAt ?: clock.millis(), - ) + val consumed = + attempt.copy( + status = StepAttemptStatus.FAILED, + completedAt = attempt.completedAt ?: clock.millis(), + ) // Atomic CAS: the approval is consumed exactly once. A concurrent writer (e.g. a // stale-approval void) between the recovery read and here must not be overwritten; // on failure the caller fails closed and the next worker re-evaluates. @@ -420,21 +473,21 @@ internal class ExecutionTracker( } } - suspend fun completeAttempt( - persistedCheckpoint: WorkflowCheckpoint, - ) { + suspend fun completeAttempt(persistedCheckpoint: WorkflowCheckpoint) { awaitObserverTransitions() - val attempt = synchronized(monitor) { - activeAttempt?.takeIf { it.stepName == persistedCheckpoint.lastCompletedStepName } - } ?: run { - prepareForCheckpoint(persistedCheckpoint) - return - } - val completed = attempt.copy( - status = StepAttemptStatus.COMPLETED, - completedAt = clock.millis(), - outputSummary = "Checkpoint revision ${persistedCheckpoint.revision}", - ) + val attempt = + synchronized(monitor) { + activeAttempt?.takeIf { it.stepName == persistedCheckpoint.lastCompletedStepName } + } ?: run { + prepareForCheckpoint(persistedCheckpoint) + return + } + val completed = + attempt.copy( + status = StepAttemptStatus.COMPLETED, + completedAt = clock.millis(), + outputSummary = "Checkpoint revision ${persistedCheckpoint.revision}", + ) stepAttemptStore.updateStepAttempt(completed) synchronized(monitor) { activeAttempt = null @@ -447,22 +500,34 @@ internal class ExecutionTracker( stepName: String, error: Throwable, ) { - val attempt = synchronized(monitor) { - activeAttempt?.takeIf { it.stepName == stepName } - } ?: return + val attempt = + synchronized(monitor) { + activeAttempt?.takeIf { it.stepName == stepName } + } ?: return // The persisted outputSummary must not carry raw persistence internals // (paths, SQL, payloads). Sanitize persistence-family failures; user // step-execution errors are not persistence internals and keep their // real message so the durable record stays diagnostically useful. - val observableFailure = when { - !error.isPersistenceFamilyFailure() -> error - else -> safeWorkerObservableFailure(PersistenceResourceKind.STEP_ATTEMPT, PersistenceOperation.SAVE, error) - } - val failed = attempt.copy( - status = StepAttemptStatus.FAILED, - completedAt = clock.millis(), - outputSummary = summarize(observableFailure), - ) + val observableFailure = + when { + !error.isPersistenceFamilyFailure() -> { + error + } + + else -> { + safeWorkerObservableFailure( + PersistenceResourceKind.STEP_ATTEMPT, + PersistenceOperation.SAVE, + error, + ) + } + } + val failed = + attempt.copy( + status = StepAttemptStatus.FAILED, + completedAt = clock.millis(), + outputSummary = summarize(observableFailure), + ) stepAttemptStore.updateStepAttempt(failed) synchronized(monitor) { activeAttempt = null @@ -485,11 +550,12 @@ internal class ExecutionTracker( suspend fun cancelActiveAttempt(summary: String) { awaitObserverTransitions() val attempt = synchronized(monitor) { activeAttempt } ?: return - val cancelled = attempt.copy( - status = StepAttemptStatus.CANCELLED, - completedAt = clock.millis(), - outputSummary = summary, - ) + val cancelled = + attempt.copy( + status = StepAttemptStatus.CANCELLED, + completedAt = clock.millis(), + outputSummary = summary, + ) stepAttemptStore.updateStepAttempt(cancelled) synchronized(monitor) { activeAttempt = null @@ -509,21 +575,20 @@ internal class ExecutionTracker( observerScope.coroutineContext[Job]?.cancel() } - private fun enqueueObserverTransition( - block: suspend ExecutionTracker.() -> Unit, - ) { + private fun enqueueObserverTransition(block: suspend ExecutionTracker.() -> Unit) { synchronized(monitor) { val previous = observerTransitionTail - observerTransitionTail = observerScope.async { - previous.await().getOrThrow() - try { - this@ExecutionTracker.block() - Result.success(Unit) - } catch (error: Throwable) { - error.rethrowIfCancellation() - Result.failure(error) + observerTransitionTail = + observerScope.async { + previous.await().getOrThrow() + try { + this@ExecutionTracker.block() + Result.success(Unit) + } catch (error: Throwable) { + error.rethrowIfCancellation() + Result.failure(error) + } } - } } } @@ -550,22 +615,24 @@ private class LeaseFencedCheckpointStore( private val tracker: ExecutionTracker, private val revisionSink: (Long?) -> Unit, ) : WorkflowCheckpointStore by delegate { - private val leaseFence = leaseStore as? WorkflowLeaseCheckpointFence - ?: throw IllegalArgumentException( - "TramaiWorker requires a WorkflowLeaseStore that can atomically fence checkpoint mutations", - ) + private val leaseFence = + leaseStore as? WorkflowLeaseCheckpointFence + ?: throw IllegalArgumentException( + "TramaiWorker requires a WorkflowLeaseStore that can atomically fence checkpoint mutations", + ) override suspend fun save( checkpoint: WorkflowCheckpoint, expectedRevision: Long?, ): WorkflowCheckpoint { val expectedLease = expectedLease(checkpoint.workflowName, checkpoint.workflowId) - val persisted = leaseFence.saveCheckpointIfLeaseOwner( - checkpointStore = delegate, - checkpoint = checkpoint, - expectedRevision = expectedRevision, - expectedLease = expectedLease, - ) + val persisted = + leaseFence.saveCheckpointIfLeaseOwner( + checkpointStore = delegate, + checkpoint = checkpoint, + expectedRevision = expectedRevision, + expectedLease = expectedLease, + ) revisionSink(persisted.revision) tracker.completeAttempt(persisted) return persisted @@ -596,31 +663,39 @@ private class LeaseFencedCheckpointStore( expectedGeneration: String?, ): WorkflowCheckpoint { val expectedLease = expectedLease(workflowName, workflowId) - val current = delegate.load(workflowName, workflowId) - ?: throw WorkflowCheckpointConflictException("Cannot require recovery for '$workflowName'/'$workflowId': checkpoint does not exist") - return leaseFence.saveCheckpointIfLeaseOwner( - checkpointStore = delegate, - checkpoint = current.copy( - recoveryState = WorkflowRecoveryState.Required(record), - checkpointGeneration = expectedGeneration, - ), - expectedRevision = expectedRevision, - expectedLease = expectedLease, - ).also { revisionSink(it.revision) } + val current = + delegate.load(workflowName, workflowId) + ?: throw WorkflowCheckpointConflictException( + "Cannot require recovery for '$workflowName'/'$workflowId': checkpoint does not exist", + ) + return leaseFence + .saveCheckpointIfLeaseOwner( + checkpointStore = delegate, + checkpoint = + current.copy( + recoveryState = WorkflowRecoveryState.Required(record), + checkpointGeneration = expectedGeneration, + ), + expectedRevision = expectedRevision, + expectedLease = expectedLease, + ).also { revisionSink(it.revision) } } private fun expectedLease( workflowName: String, workflowId: String, - ): WorkflowLease = leaseProvider() - ?: throw StaleWorkflowLeaseException( - "Workflow '$workflowName' and workflowId='$workflowId' has no active lease for checkpoint mutation", - ) + ): WorkflowLease = + leaseProvider() + ?: throw StaleWorkflowLeaseException( + "Workflow '$workflowName' and workflowId='$workflowId' has no active lease for checkpoint mutation", + ) } -private fun sha256Hex(value: String): String = MessageDigest.getInstance("SHA-256") - .digest(value.toByteArray(Charsets.UTF_8)) - .joinToString("") { byte -> "%02x".format(byte) } +private fun sha256Hex(value: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } private suspend fun runCleanupPreservingCancellation( cancellation: CancellationException, @@ -639,7 +714,6 @@ private suspend fun runCleanupPreservingCancellation( } } - /** * Snapshot iteration that is safe under concurrent growth/shrinkage of a * thread-safe collection. Deliberately NOT [Collection.toList]: its singleton diff --git a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowPersistenceSession.kt b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowPersistenceSession.kt index 1e7d7970d..f1c26426d 100644 --- a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowPersistenceSession.kt +++ b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowPersistenceSession.kt @@ -1,5 +1,6 @@ package dev.tramai.orchestration +import dev.tramai.core.identity.GovernedRunIdentity import dev.tramai.core.observation.event.RuntimeAttributes import dev.tramai.core.observation.event.RuntimeEvent import dev.tramai.core.observation.event.RuntimeEvents @@ -15,15 +16,17 @@ import java.time.Instant */ internal class WorkflowPersistenceSession( private val persistence: WorkflowPersistence, - private val workflowName: String, - private val context: WorkflowContext, - private val observer: WorkflowObserver, - private val workflowDefinitionCompatibility: WorkflowDefinitionCompatibility, - private val clock: Clock, + private val inputs: WorkflowSessionInputs, private var lease: WorkflowLease?, initialRevision: Long?, initialGeneration: String?, ) { + private val workflowName = inputs.workflowName + private val context = inputs.context + private val observer = inputs.observer + private val workflowDefinitionCompatibility = inputs.workflowDefinitionCompatibility + private val clock = inputs.clock + private val governedRunIdentity = inputs.governedRunIdentity private var currentRevision: Long? = initialRevision private var currentGeneration: String? = initialGeneration @@ -34,34 +37,42 @@ internal class WorkflowPersistenceSession( stepExecutions: Int, extraMetadata: Map = emptyMap(), ) { - val persisted = persistence.checkpointStore.save( - checkpoint = WorkflowCheckpoint( - workflowName = workflowName, - workflowId = context.workflowId, - nextStepIndex = nextStepIndex, - stepExecutions = stepExecutions, - lastCompletedStepName = lastCompletedStepName, - statePayload = persistence.stateCodec.encode(state), - metadata = workflowDefinitionCompatibility.toCheckpointMetadata() + extraMetadata, - savedAtEpochMillis = clock.millis(), - checkpointGeneration = currentGeneration, - ), - expectedRevision = currentRevision, - ) + val persisted = + persistence.checkpointStore.save( + checkpoint = + WorkflowCheckpoint( + workflowName = workflowName, + workflowId = context.workflowId, + nextStepIndex = nextStepIndex, + stepExecutions = stepExecutions, + lastCompletedStepName = lastCompletedStepName, + statePayload = persistence.stateCodec.encode(state), + // Framework attribution is written LAST so application/step metadata can + // never override the reserved identity keys. + metadata = + workflowDefinitionCompatibility.toCheckpointMetadata() + + extraMetadata + + encodeGovernedRunAttribution(governedRunIdentity), + savedAtEpochMillis = clock.millis(), + checkpointGeneration = currentGeneration, + ), + expectedRevision = currentRevision, + ) currentRevision = persisted.revision currentGeneration = persisted.checkpointGeneration observer.emitWorkflowEvent( workflowName = workflowName, context = context, - event = RuntimeEvent.of(RuntimeEvents.WORKFLOW_CHECKPOINT_SAVED) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, persisted.workflowId) - set(RuntimeAttributes.NEXT_STEP_INDEX, persisted.nextStepIndex.toLong()) - set(RuntimeAttributes.STEP_EXECUTIONS, persisted.stepExecutions.toLong()) - set(RuntimeAttributes.REVISION, persisted.revision) - set(RuntimeAttributes.HAS_LAST_COMPLETED_STEP, persisted.lastCompletedStepName != null) - set(RuntimeAttributes.DEFINITION_VERSION, workflowDefinitionCompatibility.version) - set(RuntimeAttributes.DEFINITION_DIGEST_ALGORITHM, workflowDefinitionCompatibility.digestAlgorithm) - }, + event = + RuntimeEvent.of(RuntimeEvents.WORKFLOW_CHECKPOINT_SAVED) { + set(RuntimeAttributes.WORKFLOW_ID_BARE, persisted.workflowId) + set(RuntimeAttributes.NEXT_STEP_INDEX, persisted.nextStepIndex.toLong()) + set(RuntimeAttributes.STEP_EXECUTIONS, persisted.stepExecutions.toLong()) + set(RuntimeAttributes.REVISION, persisted.revision) + set(RuntimeAttributes.HAS_LAST_COMPLETED_STEP, persisted.lastCompletedStepName != null) + set(RuntimeAttributes.DEFINITION_VERSION, workflowDefinitionCompatibility.version) + set(RuntimeAttributes.DEFINITION_DIGEST_ALGORITHM, workflowDefinitionCompatibility.digestAlgorithm) + }, ) renewLeaseIfPresent() } @@ -100,11 +111,12 @@ internal class WorkflowPersistenceSession( val currentLease = lease ?: return val policy = persistence.leasePolicy ?: return val store = persistence.leaseStore ?: return - lease = store.renew( - lease = currentLease, - checkpointRevision = currentRevision, - leaseDurationMillis = policy.leaseDurationMillis, - ) + lease = + store.renew( + lease = currentLease, + checkpointRevision = currentRevision, + leaseDurationMillis = policy.leaseDurationMillis, + ) val renewedLease = lease ?: return observer.emitWorkflowEvent( workflowName = workflowName, @@ -129,41 +141,36 @@ internal class WorkflowPersistenceSession( private fun leaseAttributes( definition: dev.tramai.core.observation.event.RuntimeEventDefinition, lease: WorkflowLease, - ): RuntimeEvent = RuntimeEvent.of(definition) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, lease.workflowId) - set(RuntimeAttributes.LEASE_ID, lease.leaseId) - set(RuntimeAttributes.OWNER_ID, lease.ownerId) - set(RuntimeAttributes.CHECKPOINT_REVISION, lease.checkpointRevision ?: 0L) - set(RuntimeAttributes.ACQUIRED_AT_EPOCH_MILLIS, lease.acquiredAtEpochMillis) - set(RuntimeAttributes.EXPIRES_AT_EPOCH_MILLIS, lease.expiresAtEpochMillis) - } + ): RuntimeEvent = + RuntimeEvent.of(definition) { + set(RuntimeAttributes.WORKFLOW_ID_BARE, lease.workflowId) + set(RuntimeAttributes.LEASE_ID, lease.leaseId) + set(RuntimeAttributes.OWNER_ID, lease.ownerId) + set(RuntimeAttributes.CHECKPOINT_REVISION, lease.checkpointRevision ?: 0L) + set(RuntimeAttributes.ACQUIRED_AT_EPOCH_MILLIS, lease.acquiredAtEpochMillis) + set(RuntimeAttributes.EXPIRES_AT_EPOCH_MILLIS, lease.expiresAtEpochMillis) + } } internal suspend fun WorkflowPersistence.session( - workflowName: String, - context: WorkflowContext, - observer: WorkflowObserver, - workflowDefinitionCompatibility: WorkflowDefinitionCompatibility, - clock: Clock, + inputs: WorkflowSessionInputs, initialRevision: Long? = null, initialGeneration: String? = null, -): WorkflowPersistenceSession = WorkflowPersistenceSession( - persistence = this, - workflowName = workflowName, - context = context, - observer = observer, - workflowDefinitionCompatibility = workflowDefinitionCompatibility, - clock = clock, - lease = acquireLeaseIfConfigured( - workflowName = workflowName, - workflowId = context.workflowId, - observer = observer, - context = context, - checkpointRevision = initialRevision, - ), - initialRevision = initialRevision, - initialGeneration = initialGeneration, -) +): WorkflowPersistenceSession = + WorkflowPersistenceSession( + persistence = this, + inputs = inputs, + lease = + acquireLeaseIfConfigured( + workflowName = inputs.workflowName, + workflowId = inputs.context.workflowId, + observer = inputs.observer, + context = inputs.context, + checkpointRevision = initialRevision, + ), + initialRevision = initialRevision, + initialGeneration = initialGeneration, + ) internal suspend fun WorkflowPersistence.acquireLeaseIfConfigured( workflowName: String, @@ -175,44 +182,60 @@ internal suspend fun WorkflowPersistence.acquireLeaseIfConfigured( val store = leaseStore ?: return null val policy = leasePolicy ?: return null return try { - store.claim( - workflowName = workflowName, - workflowId = workflowId, - ownerId = policy.ownerId, - checkpointRevision = checkpointRevision, - leaseDurationMillis = policy.leaseDurationMillis, - ).also { lease -> - observer.emitWorkflowEvent( + store + .claim( workflowName = workflowName, - context = context, - event = RuntimeEvent.of(RuntimeEvents.WORKFLOW_LEASE_CLAIMED) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, lease.workflowId) - set(RuntimeAttributes.LEASE_ID, lease.leaseId) - set(RuntimeAttributes.OWNER_ID, lease.ownerId) - set(RuntimeAttributes.CHECKPOINT_REVISION, lease.checkpointRevision ?: 0L) - set(RuntimeAttributes.ACQUIRED_AT_EPOCH_MILLIS, lease.acquiredAtEpochMillis) - set(RuntimeAttributes.EXPIRES_AT_EPOCH_MILLIS, lease.expiresAtEpochMillis) - }, - ) - } + workflowId = workflowId, + ownerId = policy.ownerId, + checkpointRevision = checkpointRevision, + leaseDurationMillis = policy.leaseDurationMillis, + ).also { lease -> + observer.emitWorkflowEvent( + workflowName = workflowName, + context = context, + event = + RuntimeEvent.of(RuntimeEvents.WORKFLOW_LEASE_CLAIMED) { + set(RuntimeAttributes.WORKFLOW_ID_BARE, lease.workflowId) + set(RuntimeAttributes.LEASE_ID, lease.leaseId) + set(RuntimeAttributes.OWNER_ID, lease.ownerId) + set(RuntimeAttributes.CHECKPOINT_REVISION, lease.checkpointRevision ?: 0L) + set(RuntimeAttributes.ACQUIRED_AT_EPOCH_MILLIS, lease.acquiredAtEpochMillis) + set(RuntimeAttributes.EXPIRES_AT_EPOCH_MILLIS, lease.expiresAtEpochMillis) + }, + ) + } } catch (error: WorkflowLeaseConflictException) { observer.emitWorkflowEvent( workflowName = workflowName, context = context, - event = RuntimeEvent.of(RuntimeEvents.WORKFLOW_LEASE_CONFLICT) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, workflowId) - set(RuntimeAttributes.OWNER_ID, policy.ownerId) - set(RuntimeAttributes.CHECKPOINT_REVISION, checkpointRevision ?: 0L) - set(RuntimeAttributes.ERROR_TYPE, error::class.simpleName ?: "Throwable") - }, + event = + RuntimeEvent.of(RuntimeEvents.WORKFLOW_LEASE_CONFLICT) { + set(RuntimeAttributes.WORKFLOW_ID_BARE, workflowId) + set(RuntimeAttributes.OWNER_ID, policy.ownerId) + set(RuntimeAttributes.CHECKPOINT_REVISION, checkpointRevision ?: 0L) + set(RuntimeAttributes.ERROR_TYPE, error::class.simpleName ?: "Throwable") + }, ) throw error } } -internal suspend fun WorkflowPersistenceSession.runCatchingAbort( - error: Throwable, -) { +internal suspend fun WorkflowPersistenceSession.runCatchingAbort(error: Throwable) { runCatching { abort() } .onFailure { error.addSuppressed(it) } } + +/** + * Immutable inputs of one run's persistence session: which run it is, who observes it, + * the clock and definition compatibility it was built with, and its governed attribution + * when it has one. Bundled rather than threaded field by field so a new input cannot be + * added at one construction site and silently missed at another. + */ +internal data class WorkflowSessionInputs( + val workflowName: String, + val context: WorkflowContext, + val observer: WorkflowObserver, + val workflowDefinitionCompatibility: WorkflowDefinitionCompatibility, + val clock: Clock, + val governedRunIdentity: GovernedRunIdentity?, +) diff --git a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowRunner.kt b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowRunner.kt index 8ca74936f..7f97297c6 100644 --- a/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowRunner.kt +++ b/tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowRunner.kt @@ -1,12 +1,14 @@ @file:OptIn(ExperimentalTramaiInternalApi::class) -package dev.tramai.orchestration +package dev.tramai.orchestration -import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.core.coroutines.rethrowIfCancellation +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope import dev.tramai.core.observation.event.RuntimeAttributes import dev.tramai.core.observation.event.RuntimeEvent import dev.tramai.core.observation.event.RuntimeEvents -import dev.tramai.core.coroutines.rethrowIfCancellation +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext @@ -72,6 +74,7 @@ internal class WorkflowRunner( context: WorkflowContext, observer: WorkflowObserver, persistence: WorkflowPersistence?, + governedRunIdentity: GovernedRunIdentity? = null, ): R { // Epic 5.3: the isolated observer is the single failure boundary for // every workflow telemetry callback. A throwing observer can never @@ -81,41 +84,42 @@ internal class WorkflowRunner( var persistenceSession: WorkflowPersistenceSession? = null return try { val stepCounter = StepCounter(stopPolicy) - persistenceSession = persistence?.session( - workflowName = name, - context = context, - observer = isolatedObserver, - workflowDefinitionCompatibility = definitionCompatibility, - clock = clock, - ) + persistenceSession = + persistence?.session( + WorkflowSessionInputs( + workflowName = name, + context = context, + observer = isolatedObserver, + workflowDefinitionCompatibility = definitionCompatibility, + clock = clock, + governedRunIdentity = governedRunIdentity, + ), + ) persistenceSession?.saveCheckpoint( state = initialState, nextStepIndex = 0, lastCompletedStepName = null, stepExecutions = stepCounter.stepExecutions, ) - val finalState = executeTopLevelSteps( - startIndex = 0, - state = initialState, - context = context, - observer = isolatedObserver, - stepCounter = stepCounter, - persistenceSession = persistenceSession, - resumedCheckpointMetadata = null, - ) + val finalState = + executeTopLevelSteps( + startIndex = 0, + state = initialState, + stepCounter = stepCounter, + frame = + WorkflowExecutionFrame( + context = context, + observer = isolatedObserver, + persistenceSession = persistenceSession, + resumedCheckpointMetadata = null, + governedRunIdentity = governedRunIdentity, + ), + ) persistenceSession?.complete(workflowName = name, context = context) isolatedObserver.onWorkflowCompleted(name, context) resultSelector(finalState) } catch (suspended: WorkflowSuspendedException) { - persistenceSession?.abort() - isolatedObserver.emitWorkflowEvent( - workflowName = name, - context = context, - event = RuntimeEvent.of(RuntimeEvents.WORKFLOW_SUSPENDED) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, context.workflowId) - }, - ) - throw suspended + failSuspended(suspended, persistenceSession, isolatedObserver, context) } catch (error: CancellationException) { withContext(NonCancellable) { persistenceSession?.runCatchingAbort(error) @@ -123,9 +127,7 @@ internal class WorkflowRunner( throw error } catch (error: Throwable) { error.rethrowIfCancellation() - persistenceSession?.runCatchingAbort(error) - isolatedObserver.onWorkflowFailed(name, error, context) - throw error + failRun(error, persistenceSession, isolatedObserver, context) } } @@ -133,82 +135,58 @@ internal class WorkflowRunner( context: WorkflowContext, observer: WorkflowObserver, persistence: WorkflowPersistence, + governedRunIdentity: GovernedRunIdentity? = null, ): R { val isolatedObserver = FailureIsolatingWorkflowObserver(observer) - val checkpoint = persistence.checkpointStore.load(name, context.workflowId) - ?: throw WorkflowResumeException( - "No checkpoint exists for workflow '$name' and workflowId='${context.workflowId}'", - ) - if (checkpoint.recoveryState is WorkflowRecoveryState.Required) { - throw WorkflowRecoveryStateException( - "Workflow '$name'/'${context.workflowId}' is in Required recovery state and cannot be resumed", + // Phases 1-2: load the run's own checkpoint and validate every continuity + // precondition. Read-only — a failure here has mutated nothing. + val (checkpoint, persistedDefinitionCompatibility) = + loadResumeCheckpoint( + context = context, + persistence = persistence, + governedRunIdentity = governedRunIdentity, ) - } - if (checkpoint.nextStepIndex < 0 || checkpoint.nextStepIndex > steps.size) { - throw WorkflowResumeException( - "Checkpoint for workflow '$name' and workflowId='${context.workflowId}' has invalid nextStepIndex=${checkpoint.nextStepIndex}; valid range is 0..${steps.size}", + emitResumeStarted(context, isolatedObserver, checkpoint, persistedDefinitionCompatibility) + // Phase 3: establish the session that will checkpoint the resumed run. + val persistenceSession: WorkflowPersistenceSession = + persistence.session( + WorkflowSessionInputs( + workflowName = name, + context = context, + observer = isolatedObserver, + workflowDefinitionCompatibility = definitionCompatibility, + clock = clock, + governedRunIdentity = governedRunIdentity, + ), + initialRevision = checkpoint.revision, + initialGeneration = checkpoint.checkpointGeneration, ) - } - val persistedDefinitionCompatibility = checkpoint.requireWorkflowDefinitionCompatibility( - workflowName = name, - workflowId = context.workflowId, - ) - requireCompatibleDefinition( - workflowName = name, - workflowId = context.workflowId, - persisted = persistedDefinitionCompatibility, - current = definitionCompatibility, - ) - isolatedObserver.onWorkflowStarted(name, context) - isolatedObserver.emitWorkflowEvent( - workflowName = name, - context = context, - event = RuntimeEvent.of(RuntimeEvents.WORKFLOW_CHECKPOINT_LOADED) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, checkpoint.workflowId) - set(RuntimeAttributes.NEXT_STEP_INDEX, checkpoint.nextStepIndex.toLong()) - set(RuntimeAttributes.STEP_EXECUTIONS, checkpoint.stepExecutions.toLong()) - set(RuntimeAttributes.REVISION, checkpoint.revision) - set(RuntimeAttributes.HAS_LAST_COMPLETED_STEP, checkpoint.lastCompletedStepName != null) - set(RuntimeAttributes.DEFINITION_VERSION, persistedDefinitionCompatibility.version) - set(RuntimeAttributes.DEFINITION_DIGEST_ALGORITHM, persistedDefinitionCompatibility.digestAlgorithm) - }, - ) - val persistenceSession: WorkflowPersistenceSession = persistence.session( - workflowName = name, - context = context, - observer = isolatedObserver, - clock = clock, - initialRevision = checkpoint.revision, - initialGeneration = checkpoint.checkpointGeneration, - workflowDefinitionCompatibility = definitionCompatibility, - ) + // Phase 4: execute the remaining steps. return try { val resumedState = persistence.stateCodec.decode(checkpoint.statePayload) - val finalState = executeTopLevelSteps( - startIndex = checkpoint.nextStepIndex, - state = resumedState, - context = context, - observer = isolatedObserver, - stepCounter = StepCounter( - stopPolicy = stopPolicy, - initialStepExecutions = checkpoint.stepExecutions, - ), - persistenceSession = persistenceSession, - resumedCheckpointMetadata = checkpoint.metadata, - ) + val finalState = + executeTopLevelSteps( + startIndex = checkpoint.nextStepIndex, + state = resumedState, + stepCounter = + StepCounter( + stopPolicy = stopPolicy, + initialStepExecutions = checkpoint.stepExecutions, + ), + frame = + WorkflowExecutionFrame( + context = context, + observer = isolatedObserver, + persistenceSession = persistenceSession, + resumedCheckpointMetadata = checkpoint.metadata, + governedRunIdentity = governedRunIdentity, + ), + ) persistenceSession.complete(workflowName = name, context = context) isolatedObserver.onWorkflowCompleted(name, context) resultSelector(finalState) } catch (suspended: WorkflowSuspendedException) { - persistenceSession.abort() - isolatedObserver.emitWorkflowEvent( - workflowName = name, - context = context, - event = RuntimeEvent.of(RuntimeEvents.WORKFLOW_SUSPENDED) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, context.workflowId) - }, - ) - throw suspended + failSuspended(suspended, persistenceSession, isolatedObserver, context) } catch (error: CancellationException) { withContext(NonCancellable) { persistenceSession.runCatchingAbort(error) @@ -216,53 +194,146 @@ internal class WorkflowRunner( throw error } catch (error: Throwable) { error.rethrowIfCancellation() - persistenceSession.runCatchingAbort(error) - isolatedObserver.onWorkflowFailed(name, error, context) - throw error + failRun(error, persistenceSession, isolatedObserver, context) } } + /** Resume telemetry: the run has started again and its checkpoint has been read. */ + private fun emitResumeStarted( + context: WorkflowContext, + observer: WorkflowObserver, + checkpoint: WorkflowCheckpoint, + persistedDefinitionCompatibility: WorkflowDefinitionCompatibility, + ) { + observer.onWorkflowStarted(name, context) + observer.emitWorkflowEvent( + workflowName = name, + context = context, + event = + RuntimeEvent.of(RuntimeEvents.WORKFLOW_CHECKPOINT_LOADED) { + set(RuntimeAttributes.WORKFLOW_ID_BARE, checkpoint.workflowId) + set(RuntimeAttributes.NEXT_STEP_INDEX, checkpoint.nextStepIndex.toLong()) + set(RuntimeAttributes.STEP_EXECUTIONS, checkpoint.stepExecutions.toLong()) + set(RuntimeAttributes.REVISION, checkpoint.revision) + set(RuntimeAttributes.HAS_LAST_COMPLETED_STEP, checkpoint.lastCompletedStepName != null) + set(RuntimeAttributes.DEFINITION_VERSION, persistedDefinitionCompatibility.version) + set(RuntimeAttributes.DEFINITION_DIGEST_ALGORITHM, persistedDefinitionCompatibility.digestAlgorithm) + }, + ) + } + + /** + * A suspended resume aborts the session and reports the suspension, then propagates it: + * suspension is an outcome, not a failure, so the session is not failed. + */ + private suspend fun failSuspended( + suspended: WorkflowSuspendedException, + persistenceSession: WorkflowPersistenceSession?, + observer: FailureIsolatingWorkflowObserver, + context: WorkflowContext, + ): Nothing { + persistenceSession?.abort() + observer.emitWorkflowEvent( + workflowName = name, + context = context, + event = + RuntimeEvent.of(RuntimeEvents.WORKFLOW_SUSPENDED) { + set(RuntimeAttributes.WORKFLOW_ID_BARE, context.workflowId) + }, + ) + throw suspended + } + + /** + * Resume phases 1-2: the checkpoint, the identity-continuity gate, resumability, and + * definition compatibility — everything that must hold before a single step runs. + */ + + private suspend fun loadResumeCheckpoint( + context: WorkflowContext, + persistence: WorkflowPersistence, + governedRunIdentity: GovernedRunIdentity?, + ): Pair { + val checkpoint = + persistence.checkpointStore.load(name, context.workflowId) + ?: throw WorkflowResumeException( + "No checkpoint exists for workflow '$name' and workflowId='${context.workflowId}'", + ) + // Identity gate before any other resume validation: whole-identity continuity + // (never run-id-only) and fail-closed decoding of partial attribution. + requireGovernedRunAttributionContinuity( + workflowName = name, + workflowId = context.workflowId, + persisted = decodeGovernedRunAttribution(checkpoint.workflowId, checkpoint.metadata), + requested = governedRunIdentity, + ) + checkpoint.requireResumePreconditions(name, steps.size) + val persistedDefinitionCompatibility = + checkpoint.requireWorkflowDefinitionCompatibility( + workflowName = name, + workflowId = context.workflowId, + ) + requireCompatibleDefinition( + workflowName = name, + workflowId = context.workflowId, + persisted = persistedDefinitionCompatibility, + current = definitionCompatibility, + ) + return checkpoint to persistedDefinitionCompatibility + } + private suspend fun executeTopLevelSteps( startIndex: Int, state: S, - context: WorkflowContext, - observer: WorkflowObserver, stepCounter: StepCounter, - persistenceSession: WorkflowPersistenceSession?, - resumedCheckpointMetadata: Map?, + frame: WorkflowExecutionFrame, ): S { var currentState = state val services = executionServices() for (index in startIndex until steps.size) { val step = steps[index] - val request = WorkflowStepExecutionRequest( - workflowName = name, - state = currentState, - context = context, - observer = observer, - stepCounter = stepCounter, - persistenceSession = persistenceSession, - topLevelStepIndex = index, - resumedCheckpointMetadata = if (index == startIndex) resumedCheckpointMetadata else null, - services = services, - executeNestedSteps = { nestedSteps, nestedState -> - executeSteps( - steps = nestedSteps, - state = nestedState, - context = context, - observer = observer, - stepCounter = stepCounter, - services = services, - ) - }, - ) - when (val result = stepExecutor.executeStep(step, request)) { + val request = + WorkflowStepExecutionRequest( + workflowName = name, + state = currentState, + context = frame.context, + observer = frame.observer, + stepCounter = stepCounter, + persistenceSession = frame.persistenceSession, + topLevelStepIndex = index, + resumedCheckpointMetadata = if (index == startIndex) frame.resumedCheckpointMetadata else null, + services = services, + executeNestedSteps = { nestedSteps, nestedState -> + executeSteps( + steps = nestedSteps, + state = nestedState, + context = frame.context, + observer = frame.observer, + stepCounter = stepCounter, + services = services, + ) + }, + ) + val stepResult = + if (frame.governedRunIdentity == null) { + stepExecutor.executeStep(step, request) + } else { + // 0.7.1d: the canonical governed identity is established for the whole + // step execution, so subsystems invoked from application step code (the + // engine, evidence emitters) read the same run identity instead of + // minting their own. Nested steps inherit this coroutine frame.context. + withContext(GovernedRunScope(frame.governedRunIdentity)) { + stepExecutor.executeStep(step, request) + } + } + when (val result = stepResult) { is WorkflowStepExecutionResult.Completed -> currentState = result.state + WorkflowStepExecutionResult.Suspended -> throw WorkflowSuspendedException( - "Workflow '$name' suspended at step '${step.name}' for workflowId='${context.workflowId}'", + "Workflow '$name' suspended at step '${step.name}' for workflowId='${frame.context.workflowId}'", ) } - persistenceSession?.saveCheckpoint( + frame.persistenceSession?.saveCheckpoint( state = currentState, nextStepIndex = index + 1, lastCompletedStepName = step.name, @@ -282,29 +353,31 @@ internal class WorkflowRunner( ): S { var currentState = state for (step in steps) { - val request = WorkflowStepExecutionRequest( - workflowName = name, - state = currentState, - context = context, - observer = observer, - stepCounter = stepCounter, - persistenceSession = null, - topLevelStepIndex = null, - resumedCheckpointMetadata = null, - services = services, - executeNestedSteps = { nestedSteps, nestedState -> - executeSteps( - steps = nestedSteps, - state = nestedState, - context = context, - observer = observer, - stepCounter = stepCounter, - services = services, - ) - }, - ) + val request = + WorkflowStepExecutionRequest( + workflowName = name, + state = currentState, + context = context, + observer = observer, + stepCounter = stepCounter, + persistenceSession = null, + topLevelStepIndex = null, + resumedCheckpointMetadata = null, + services = services, + executeNestedSteps = { nestedSteps, nestedState -> + executeSteps( + steps = nestedSteps, + state = nestedState, + context = context, + observer = observer, + stepCounter = stepCounter, + services = services, + ) + }, + ) when (val result = stepExecutor.executeStep(step, request)) { is WorkflowStepExecutionResult.Completed -> currentState = result.state + WorkflowStepExecutionResult.Suspended -> throw WorkflowSuspendedException( "Workflow '$name' suspended at nested step '${step.name}', but nested checkpoint suspension is not supported", ) @@ -313,11 +386,45 @@ internal class WorkflowRunner( return currentState } - private fun executionServices(): WorkflowStepExecutionServices = WorkflowStepExecutionServices( - clock = clock, - httpTransport = httpTransport ?: JdkHttpTransport(httpClient), - outboundNetworkPolicy = outboundNetworkPolicy, - externalStepExecutorResolver = externalStepExecutorResolver, - failureDiagnosticObserver = failureDiagnosticObserver, - ) + private fun executionServices(): WorkflowStepExecutionServices = + WorkflowStepExecutionServices( + clock = clock, + httpTransport = httpTransport ?: JdkHttpTransport(httpClient), + outboundNetworkPolicy = outboundNetworkPolicy, + externalStepExecutorResolver = externalStepExecutorResolver, + failureDiagnosticObserver = failureDiagnosticObserver, + ) + + /** + * Terminal failure handling for one run: a cancellation aborts under [NonCancellable] + * without notifying the observer as a failure, anything else aborts and is reported. + * Never returns — the failure is always rethrown to the caller. + */ + private suspend fun failRun( + error: Throwable, + persistenceSession: WorkflowPersistenceSession?, + observer: FailureIsolatingWorkflowObserver, + context: WorkflowContext, + ): Nothing { + persistenceSession?.runCatchingAbort(error) + observer.onWorkflowFailed(name, error, context) + throw error + } } + +/** + * Everything the top-level step loop needs to stay stable across one run: the run context, + * its observer, the session that checkpoints it, the metadata recovered on resume, and the + * governed attribution it must carry unchanged. Threaded as one value so the loop cannot be + * called with a half-updated shape. + * + * Operational per-step state (current state, step index, per-step metadata) is deliberately + * NOT here: that belongs to the loop, not to the frame. + */ +internal data class WorkflowExecutionFrame( + val context: WorkflowContext, + val observer: WorkflowObserver, + val persistenceSession: WorkflowPersistenceSession?, + val resumedCheckpointMetadata: Map?, + val governedRunIdentity: GovernedRunIdentity?, +) diff --git a/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/GovernedRunAttributionTest.kt b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/GovernedRunAttributionTest.kt new file mode 100644 index 000000000..4f4445598 --- /dev/null +++ b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/GovernedRunAttributionTest.kt @@ -0,0 +1,487 @@ +package dev.tramai.orchestration + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.h2.jdbcx.JdbcDataSource +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.nio.file.Files +import java.nio.file.Path +import javax.sql.DataSource + +/** + * 0.7.1d governed-run attribution continuity. + * + * One governed execution establishes exactly one [GovernedRunIdentity]; that same + * immutable identity must survive execution, checkpoint persistence, restart and + * resume. Every test here fails if an identity check is weakened to a run-id-only + * comparison, to a subset of the deployment identity, or if a resume regenerates + * the run id. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GovernedRunAttributionTest { + private companion object { + const val JDBC_URL = "jdbc:h2:mem:governed-attribution;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE" + const val WORKFLOW_NAME = "claims-triage" + } + + private val tempRoots = mutableListOf() + private val dataSource: DataSource = + JdbcDataSource().apply { + setURL(JDBC_URL) + user = "sa" + password = "" + } + + init { + dataSource.connection.use { conn -> + conn.createStatement().use { it.execute(JdbcWorkflowCheckpointStore(dataSource).createTableSql()) } + } + } + + @AfterAll + fun tearDownAll() { + runCatching { + dataSource.connection.use { conn -> + conn.createStatement().use { it.execute("DROP TABLE IF EXISTS tramai_workflow_checkpoint") } + } + } + tempRoots.forEach { root -> runCatching { root.toFile().deleteRecursively() } } + } + + // ── Fixtures ──────────────────────────────────────────────────── + + private fun deployment( + workload: String = "claims", + configurationId: String = "claims-prod", + configurationVersion: String = "17", + environment: String = "production", + deploymentId: String = "eu-west-amsterdam-01", + ): WorkloadDeploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workload), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configurationId), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId(environment), + deploymentId = DeploymentId(deploymentId), + ) + + private val stateCodec = + object : WorkflowStateCodec { + override fun encode(state: String): String = state + + override fun decode(payload: String): String = payload + } + + private fun checkpoint( + workflowId: String, + metadata: Map = emptyMap(), + ): WorkflowCheckpoint = + WorkflowCheckpoint( + workflowName = WORKFLOW_NAME, + workflowId = workflowId, + nextStepIndex = 1, + stepExecutions = 1, + lastCompletedStepName = "one", + statePayload = "state", + revision = 0, + metadata = metadata, + savedAtEpochMillis = 1_000L, + ) + + private fun stores(): List> { + val fileRoot = Files.createTempDirectory("governed-file-").toAbsolutePath() + val markdownRoot = Files.createTempDirectory("governed-md-").toAbsolutePath() + tempRoots.addAll(listOf(fileRoot, markdownRoot)) + dataSource.connection.use { conn -> + conn.createStatement().use { it.execute("DELETE FROM tramai_workflow_checkpoint") } + } + return listOf( + "inMemory" to InMemoryWorkflowCheckpointStore(), + "file" to FileWorkflowCheckpointStore(fileRoot), + "markdown" to MarkdownWorkflowCheckpointStore(markdownRoot), + "jdbc" to JdbcWorkflowCheckpointStore(dataSource), + ) + } + + // ── The envelope is the boundary ──────────────────────────────── + + @Test + fun `a governed envelope refuses a run id that is not its context workflow id`() { + assertThatThrownBy { + GovernedRun( + context = WorkflowContext(workflowId = "run-a"), + identity = GovernedRunIdentity(deployment = deployment(), runId = RunId("run-b")), + ) + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("must equal") + } + + @Test + fun `start establishes one identity whose run id is the context workflow id`() { + val run = GovernedRun.start(deployment()) + + assertThat(run.identity.runId.value).isEqualTo(run.context.workflowId) + } + + @Test + fun `resume never regenerates the run id`() { + val run = GovernedRun.resume(deployment(), "run-7") + + assertThat(run.identity.runId.value).isEqualTo("run-7") + assertThat(run.context.workflowId).isEqualTo("run-7") + } + + // ── Persisted representation ──────────────────────────────────── + + @Test + fun `attribution encodes to reserved keys and decodes unchanged`() { + val identity = GovernedRunIdentity(deployment = deployment(), runId = RunId("run-1")) + + val encoded = encodeGovernedRunAttribution(identity) + + assertThat(encoded.keys).containsExactlyInAnyOrderElementsOf( + listOf( + GOVERNED_RUN_WORKLOAD_KEY, + GOVERNED_RUN_CONFIGURATION_KEY, + GOVERNED_RUN_CONFIGURATION_VERSION_KEY, + GOVERNED_RUN_ENVIRONMENT_KEY, + GOVERNED_RUN_DEPLOYMENT_KEY, + ), + ) + assertThat(decodeGovernedRunAttribution("run-1", encoded)).isEqualTo(identity) + } + + @Test + fun `absent attribution decodes as ungoverned`() { + assertThat(decodeGovernedRunAttribution("run-1", emptyMap())).isNull() + // Blank values are absent too — never a partially-attributed identity. + assertThat( + decodeGovernedRunAttribution( + "run-1", + mapOf( + GOVERNED_RUN_WORKLOAD_KEY to " ", + GOVERNED_RUN_CONFIGURATION_KEY to "", + GOVERNED_RUN_CONFIGURATION_VERSION_KEY to "", + GOVERNED_RUN_ENVIRONMENT_KEY to "", + GOVERNED_RUN_DEPLOYMENT_KEY to "", + ), + ), + ).isNull() + } + + @Test + fun `partial attribution fails closed instead of resuming un-attributed`() { + val partial = + encodeGovernedRunAttribution( + GovernedRunIdentity( + deployment(), + RunId("run-2"), + ), + ).minus(GOVERNED_RUN_DEPLOYMENT_KEY) + + assertThatThrownBy { decodeGovernedRunAttribution("run-2", partial) } + .isInstanceOf(WorkflowCheckpointCorruptionException::class.java) + .hasMessageContaining("incomplete") + } + + @Test + fun `invalid attribution values fail closed`() { + val identity = GovernedRunIdentity(deployment(), RunId("run-3")) + + assertThatThrownBy { + decodeGovernedRunAttribution( + "run-3", + encodeGovernedRunAttribution(identity) + (GOVERNED_RUN_WORKLOAD_KEY to " padded "), + ) + }.isInstanceOf(WorkflowCheckpointCorruptionException::class.java) + .hasMessageContaining("invalid") + } + + @Test + fun `framework attribution wins over application metadata using the same reserved key`() { + val identity = GovernedRunIdentity(deployment(), RunId("run-4")) + val applicationMetadata = mapOf(GOVERNED_RUN_DEPLOYMENT_KEY to "attacker-deployment") + + // The exact composition the persistence session performs: application/step + // metadata first, framework attribution last. + val composed = applicationMetadata + encodeGovernedRunAttribution(identity) + + assertThat(decodeGovernedRunAttribution("run-4", composed)).isEqualTo(identity) + } + + // ── Continuity gate ───────────────────────────────────────────── + + @Test + fun `continuity holds for intentionally ungoverned runs and for the identical identity`() { + requireGovernedRunAttributionContinuity(WORKFLOW_NAME, "run-5", persisted = null, requested = null) + val identity = GovernedRunIdentity(deployment(), RunId("run-5")) + requireGovernedRunAttributionContinuity(WORKFLOW_NAME, "run-5", persisted = identity, requested = identity) + } + + @Test + fun `a governed checkpoint cannot be resumed without attribution`() { + val persisted = GovernedRunIdentity(deployment(), RunId("run-6")) + + assertThatThrownBy { + requireGovernedRunAttributionContinuity(WORKFLOW_NAME, "run-6", persisted = persisted, requested = null) + }.isInstanceOf(WorkflowResumeException::class.java) + .hasMessageContaining("resume carries no governed attribution") + } + + @Test + fun `an ungoverned checkpoint cannot be resumed as governed`() { + assertThatThrownBy { + requireGovernedRunAttributionContinuity( + WORKFLOW_NAME, + "run-6", + persisted = null, + requested = GovernedRunIdentity(deployment(), RunId("run-6")), + ) + }.isInstanceOf(WorkflowResumeException::class.java) + .hasMessageContaining("checkpoint carries no governed attribution") + } + + @Test + fun `the same run id pointing at another deployment is rejected`() { + val persisted = GovernedRunIdentity(deployment(), RunId("run-7")) + val substituted = GovernedRunIdentity(deployment(deploymentId = "eu-central-frankfurt-01"), RunId("run-7")) + + assertThatThrownBy { + requireGovernedRunAttributionContinuity( + WORKFLOW_NAME, + "run-7", + persisted = persisted, + requested = substituted, + ) + }.isInstanceOf(WorkflowResumeException::class.java) + .hasMessageContaining("deploymentId 'eu-west-amsterdam-01' != 'eu-central-frankfurt-01'") + } + + @Test + fun `every substituted identity component is rejected and named`() { + val persisted = GovernedRunIdentity(deployment(), RunId("run-8")) + val substitutions = + mapOf( + "workloadId" to deployment(workload = "payments"), + "configurationId" to deployment(configurationId = "claims-staging"), + "configurationVersion" to deployment(configurationVersion = "18"), + "environmentId" to deployment(environment = "staging"), + "deploymentId" to deployment(deploymentId = "eu-central-frankfurt-01"), + ) + + substitutions.forEach { (component, substitutedDeployment) -> + assertThatThrownBy { + requireGovernedRunAttributionContinuity( + WORKFLOW_NAME, + "run-8", + persisted = persisted, + requested = GovernedRunIdentity(substitutedDeployment, RunId("run-8")), + ) + }.isInstanceOf(WorkflowResumeException::class.java) + .withFailMessage("substituting $component must be rejected") + .hasMessageContaining(component) + } + } + + @Test + fun `two deployments of the same workflow keep distinct identities`() { + val west = GovernedRun.start(deployment(deploymentId = "eu-west-amsterdam-01")) + val central = GovernedRun.start(deployment(deploymentId = "eu-central-frankfurt-01")) + + assertThat(west.identity).isNotEqualTo(central.identity) + assertThat(west.identity.deployment.workloadId).isEqualTo(central.identity.deployment.workloadId) + } + + // ── Durable round-trip in every supported store ───────────────── + + @Test + fun `governed attribution round-trips unchanged through every checkpoint store`() { + runBlocking { + stores().forEach { (label, store) -> + val identity = GovernedRunIdentity(deployment(), RunId("$label-run")) + val saved = store.save(checkpoint("$label-run", encodeGovernedRunAttribution(identity))) + + val loaded = store.load(WORKFLOW_NAME, "$label-run") + + assertThat(loaded).withFailMessage("$label: checkpoint must load").isNotNull() + assertThat(decodeGovernedRunAttribution(loaded!!.workflowId, loaded.metadata)) + .withFailMessage("$label: attribution must survive the round-trip") + .isEqualTo(identity) + assertThat(saved.workflowId).isEqualTo(identity.runId.value) + } + } + } + + @Test + fun `ungoverned checkpoints stay un-attributed on every store`() { + runBlocking { + stores().forEach { (label, store) -> + store.save(checkpoint("$label-legacy")) + + val loaded = store.load(WORKFLOW_NAME, "$label-legacy") + + assertThat(decodeGovernedRunAttribution(loaded!!.workflowId, loaded.metadata)) + .withFailMessage("$label: ungoverned checkpoint must stay un-attributed") + .isNull() + } + } + } + + // ── Execution continuity through the runner ───────────────────── + + private fun workflow(): Workflow = + workflow(WORKFLOW_NAME) { + localStep("one") { state, _ -> "$state-one" } + localStep("two") { state, _ -> "$state-two" } + }.build { it } + + /** Rewinds a completed run so the next step can execute again. */ + private suspend fun rewind( + store: InMemoryWorkflowCheckpointStore, + workflowId: String, + nextStepIndex: Int, + ) { + val current = store.load(WORKFLOW_NAME, workflowId)!! + store.save( + current.copy( + nextStepIndex = nextStepIndex, + stepExecutions = nextStepIndex, + lastCompletedStepName = "one", + statePayload = "seed-one", + ), + expectedRevision = current.revision, + ) + } + + @Test + fun `a governed run persists its identity and resumes with it unchanged`() { + runBlocking { + val store = InMemoryWorkflowCheckpointStore() + val persistence = + WorkflowPersistence( + checkpointStore = store, + stateCodec = stateCodec, + deleteCheckpointOnCompletion = false, + ) + val workflow = workflow() + val run = GovernedRun.start(deployment()) + + workflow.run(initialState = "seed", run = run, persistence = persistence) + + val persisted = store.load(WORKFLOW_NAME, run.context.workflowId)!! + assertThat(decodeGovernedRunAttribution(persisted.workflowId, persisted.metadata)).isEqualTo(run.identity) + + rewind(store, run.context.workflowId, nextStepIndex = 1) + val resumed = + workflow.resume( + run = GovernedRun.resume(deployment(), run.context.workflowId), + persistence = persistence, + ) + + assertThat(resumed).isEqualTo("seed-one-two") + assertThat(store.load(WORKFLOW_NAME, run.context.workflowId)!!.metadata) + .containsEntry(GOVERNED_RUN_DEPLOYMENT_KEY, "eu-west-amsterdam-01") + } + } + + @Test + fun `resuming a governed run through a substituted deployment is rejected`() { + runBlocking { + val store = InMemoryWorkflowCheckpointStore() + val persistence = + WorkflowPersistence( + checkpointStore = store, + stateCodec = stateCodec, + deleteCheckpointOnCompletion = false, + ) + val workflow = workflow() + val run = GovernedRun.start(deployment()) + workflow.run(initialState = "seed", run = run, persistence = persistence) + rewind(store, run.context.workflowId, nextStepIndex = 1) + + assertThatThrownBy { + runBlocking { + workflow.resume( + run = GovernedRun.resume(deployment(environment = "staging"), run.context.workflowId), + persistence = persistence, + ) + } + }.isInstanceOf(WorkflowResumeException::class.java) + .hasMessageContaining("environmentId") + } + } + + @Test + fun `resuming a governed run through an ungoverned context is rejected`() { + runBlocking { + val store = InMemoryWorkflowCheckpointStore() + val persistence = + WorkflowPersistence( + checkpointStore = store, + stateCodec = stateCodec, + deleteCheckpointOnCompletion = false, + ) + val workflow = workflow() + val run = GovernedRun.start(deployment()) + workflow.run(initialState = "seed", run = run, persistence = persistence) + rewind(store, run.context.workflowId, nextStepIndex = 1) + + assertThatThrownBy { + runBlocking { + workflow.resume( + context = WorkflowContext(workflowId = run.context.workflowId), + persistence = persistence, + ) + } + }.isInstanceOf(WorkflowResumeException::class.java) + .hasMessageContaining("resume carries no governed attribution") + } + } + + @Test + fun `legacy ungoverned run and resume stay compatible`() { + runBlocking { + val store = InMemoryWorkflowCheckpointStore() + val persistence = + WorkflowPersistence( + checkpointStore = store, + stateCodec = stateCodec, + deleteCheckpointOnCompletion = false, + ) + val workflow = workflow() + val context = WorkflowContext(workflowId = "legacy-run") + + workflow.run(initialState = "seed", context = context, persistence = persistence) + + assertThat( + decodeGovernedRunAttribution( + "legacy-run", + store + .load( + WORKFLOW_NAME, + "legacy-run", + )!! + .metadata, + ), + ).isNull() + rewind(store, "legacy-run", nextStepIndex = 1) + + assertThat(workflow.resume(context = context, persistence = persistence)).isEqualTo("seed-one-two") + } + } +} diff --git a/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkerShutdownCoordinatorTest.kt b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkerShutdownCoordinatorTest.kt index 36640558a..a6bbe3941 100644 --- a/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkerShutdownCoordinatorTest.kt +++ b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkerShutdownCoordinatorTest.kt @@ -162,6 +162,29 @@ class WorkerShutdownCoordinatorTest { } } + /** While the step is gated the drain cannot report and cannot have completed. */ + private fun assertNoDrainProgressYet(observer: RecordingObserver) { + assertThat(observer.events.none { it.startsWith("drainProgress(") }).isTrue() + assertThat(observer.events).doesNotContain("shutdownComplete") + } + + /** + * The drain reports exactly once with nothing pending, strictly between the attempt + * completing and shutdown finishing. `done` is not deterministic: the snapshot is taken + * after the poll job is cancelled, so an execution completing in that window is + * legitimately not captured (0) as well as captured (1). Both are drained to + * completion — the waiting property is asserted while the step is still gated. + */ + private fun assertSingleDrainBeforeCompletion(events: List) { + val drainEvents = events.filter { it.startsWith("drainProgress(") } + assertThat(drainEvents).hasSize(1) + assertThat(drainEvents.single()).isIn("drainProgress(0,0)", "drainProgress(1,0)") + val attemptCompletedAt = events.indexOf("attemptCompleted") + val drainProgressAt = events.indexOfFirst { it.startsWith("drainProgress(") } + val shutdownCompleteAt = events.indexOf("shutdownComplete") + assertThat(drainProgressAt).isGreaterThan(attemptCompletedAt).isLessThan(shutdownCompleteAt) + } + @Test fun `shutdown drains a running execution to completion before finishing`() { val gate = CompletableDeferred() @@ -202,8 +225,13 @@ class WorkerShutdownCoordinatorTest { withTimeout(10_000) { while (!h.observer.events.contains("shutdownStarted")) delay(5) } - // The drain must not have given up while the execution is still running. - assertThat(h.observer.events).doesNotContain("drainProgress(1,0)", "shutdownComplete") + // The step is still gated, so its job cannot complete and the drain cannot report. + assertNoDrainProgressYet(h.observer) + // And it must still be waiting rather than have given up: the drain timeout is + // 60s, so a drain that finishes here skipped the execution. + delay(300) + assertThat(shutdownJob.isCompleted).isFalse() + gate.complete(Unit) withTimeout(10_000) { shutdownJob.join() } } @@ -213,10 +241,10 @@ class WorkerShutdownCoordinatorTest { assertThat(h.observer.events).containsSubsequence( "shutdownStarted", "attemptCompleted", - "drainProgress(1,0)", "shutdownComplete", "workerStopped", ) + assertSingleDrainBeforeCompletion(h.observer.events) assertThat(h.pollJob.isCancelled).isTrue() assertThat(h.heartbeatJob.isCancelled).isTrue() assertThat(h.rootSupervisor.isCancelled).isTrue() diff --git a/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowMcpStepTest.kt b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowMcpStepTest.kt index 98e13c42b..d17adebee 100644 --- a/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowMcpStepTest.kt +++ b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowMcpStepTest.kt @@ -2,22 +2,24 @@ package dev.tramai.orchestration import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.ServerSession import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.Tool +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.supervisorScope import kotlinx.io.asSink import kotlinx.io.asSource @@ -34,43 +36,68 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.attribute.PosixFilePermission import java.time.Clock +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference import kotlin.reflect.typeOf import kotlin.test.AfterTest import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlin.time.Duration.Companion.seconds class WorkflowMcpStepTest { + /** Workflow type this suite builds; the explicit form exceeds the line-length limit in signatures. */ + private typealias McpWorkflow = Workflow + private val servers = mutableListOf() @AfterTest fun tearDown() { - servers.forEach { runCatching { it.close() } } + // Every fixture is attempted, and a fixture that cannot shut down fails the test with its own + // diagnostic instead of being swallowed (or hanging the test worker). + val failures = mutableListOf() + servers.asReversed().forEach { fixture -> + try { + fixture.close() + } catch (failure: Throwable) { + failures += failure + } + } servers.clear() + failures.firstOrNull()?.let { primary -> + failures.drop(1).forEach(primary::addSuppressed) + throw primary + } } @Test fun `mcp step calls a tool and returns result`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "echo", - argumentKeys = setOf("message"), - ), - config = McpStepConfig.unrestricted(), - toolCallBuilder = { _, _ -> - McpToolCall( - serverCommand = listOf("unused"), - toolName = "echo", - arguments = mapOf("message" to "hello-world"), - ) - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + argumentKeys = setOf("message"), + ), + config = McpStepConfig.unrestricted(), + toolCallBuilder = { _, _ -> + McpToolCall( + serverCommand = listOf("unused"), + toolName = "echo", + arguments = mapOf("message" to "hello-world"), + ) + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) val result = runBlocking { workflow.run(McpState()) } @@ -83,19 +110,21 @@ class WorkflowMcpStepTest { fun `mcp step returns error result when tool not found`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "missing", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "nonexistent_tool", - ), - config = McpStepConfig.unrestricted().copy(reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("unused"), toolName = "nonexistent_tool") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "missing", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "nonexistent_tool", + ), + config = McpStepConfig.unrestricted().copy(reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("unused"), toolName = "nonexistent_tool") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) val result = runBlocking { workflow.run(McpState()) } @@ -107,19 +136,21 @@ class WorkflowMcpStepTest { fun `mcp step times out if tool exceeds timeout`() { val (transportProvider, _) = createSlowServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "slow", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "delay", - ), - config = McpStepConfig.unrestricted().copy(timeoutSeconds = 1, reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("unused"), toolName = "delay") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "slow", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "delay", + ), + config = McpStepConfig.unrestricted().copy(timeoutSeconds = 1, reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("unused"), toolName = "delay") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) @@ -133,27 +164,30 @@ class WorkflowMcpStepTest { fun `mcp step propagates CancellationException on mid-call coroutine cancellation`() { val (transportProvider, _) = createSlowServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "slow", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "delay", - ), - config = McpStepConfig.unrestricted().copy(timeoutSeconds = 10, reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("unused"), toolName = "delay") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "slow", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "delay", + ), + config = McpStepConfig.unrestricted().copy(timeoutSeconds = 10, reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("unused"), toolName = "delay") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) runBlocking { supervisorScope { - val deferred = async { - workflow.run(McpState()) - } + val deferred = + async { + workflow.run(McpState()) + } delay(500) deferred.cancel() val result = runCatching { deferred.await() } @@ -169,36 +203,40 @@ class WorkflowMcpStepTest { try { withExecutableScript( name = "slow-mcp-descendants", - content = """ + content = + """ |#!/bin/sh |echo $$ > '${parentPidFile.toAbsolutePath()}' |sleep 30 & |child=$! |echo ${'$'}child > '${childPidFile.toAbsolutePath()}' |wait ${'$'}child - """.trimMargin(), + """.trimMargin(), ) { serverScript -> - val step: InternalWorkflowStep = McpWorkflowStep( - name = "slow-subprocess", - definition = McpToolCallDefinition( - serverCommand = listOf(serverScript.toString()), - toolName = "delay", - ), - config = McpStepConfig.unrestricted().copy(timeoutSeconds = 30, reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf(serverScript.toString()), toolName = "delay") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = SubprocessMcpTransportProvider(), - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "slow-subprocess", + definition = + McpToolCallDefinition( + serverCommand = listOf(serverScript.toString()), + toolName = "delay", + ), + config = McpStepConfig.unrestricted().copy(timeoutSeconds = 30, reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf(serverScript.toString()), toolName = "delay") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = SubprocessMcpTransportProvider(), + ) val workflow = buildWorkflow(listOf(step)) runBlocking { supervisorScope { - val deferred = async { - workflow.run(McpState()) - } + val deferred = + async { + workflow.run(McpState()) + } val parentProcess = checkNotNull(awaitMcpProcessHandle(parentPidFile)) val childProcess = checkNotNull(awaitMcpProcessHandle(childPidFile)) assertThat(parentProcess.isAlive).isTrue() @@ -232,28 +270,31 @@ class WorkflowMcpStepTest { try { withExecutableScript( name = "slow-mcp-descendants", - content = """ + content = + """ |#!/bin/sh |echo $$ > '${parentPidFile.toAbsolutePath()}' |sleep 30 & |child=$! |echo ${'$'}child > '${childPidFile.toAbsolutePath()}' |wait ${'$'}child - """.trimMargin(), + """.trimMargin(), ) { serverScript -> - val step: InternalWorkflowStep = McpWorkflowStep( - name = "slow-subprocess", - definition = McpToolCallDefinition( - serverCommand = listOf(serverScript.toString()), - toolName = "delay", - ), - config = McpStepConfig.unrestricted().copy(timeoutSeconds = 1, reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf(serverScript.toString()), toolName = "delay") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = SubprocessMcpTransportProvider(), - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "slow-subprocess", + definition = + McpToolCallDefinition( + serverCommand = listOf(serverScript.toString()), + toolName = "delay", + ), + config = McpStepConfig.unrestricted().copy(timeoutSeconds = 1, reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf(serverScript.toString()), toolName = "delay") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = SubprocessMcpTransportProvider(), + ) val workflow = buildWorkflow(listOf(step)) @@ -264,7 +305,8 @@ class WorkflowMcpStepTest { val childProcess = awaitMcpProcessHandle(childPidFile) val failure = runCatching { execution.await() }.exceptionOrNull() - assertThat(failure).isInstanceOf(WorkflowMcpException::class.java) + assertThat(failure) + .isInstanceOf(WorkflowMcpException::class.java) .hasMessageContaining("timed out") awaitMcpProcessExit(parentProcess) @@ -285,24 +327,26 @@ class WorkflowMcpStepTest { val (transportProvider, _) = createEchoServer() val observer = RecordingMcpObserver() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "echo", - argumentKeys = setOf("message"), - ), - config = McpStepConfig.unrestricted(), - toolCallBuilder = { _, _ -> - McpToolCall( - serverCommand = listOf("unused"), - toolName = "echo", - arguments = mapOf("message" to "emit"), - ) - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + argumentKeys = setOf("message"), + ), + config = McpStepConfig.unrestricted(), + toolCallBuilder = { _, _ -> + McpToolCall( + serverCommand = listOf("unused"), + toolName = "echo", + arguments = mapOf("message" to "emit"), + ) + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) runBlocking { workflow.run(McpState(), observer = observer) } @@ -320,29 +364,33 @@ class WorkflowMcpStepTest { fun `mcp step validates tool allowlist`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "echo", - ), - config = McpStepConfig.unrestricted().copy( - toolAllowlist = setOf("approved_tool"), - reconnect = false, - ), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("unused"), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + ), + config = + McpStepConfig.unrestricted().copy( + toolAllowlist = setOf("approved_tool"), + reconnect = false, + ), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("unused"), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val diagnostics = RecordingDiagnosticObserver() val workflow = buildWorkflow(listOf(step), diagnostics) - val error = runCatching { - runBlocking { workflow.run(McpState()) } - }.exceptionOrNull() + val error = + runCatching { + runBlocking { workflow.run(McpState()) } + }.exceptionOrNull() assertThat(error).isInstanceOf(WorkflowMcpException::class.java) assertThat(error).hasMessage("Workflow mcp step was rejected by policy") @@ -355,19 +403,21 @@ class WorkflowMcpStepTest { fun `mcp step handles structured content`() { val (transportProvider, _) = createJsonServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "json", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "get_data", - ), - config = McpStepConfig.unrestricted(), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("unused"), toolName = "get_data") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "json", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "get_data", + ), + config = McpStepConfig.unrestricted(), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("unused"), toolName = "get_data") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) val result = runBlocking { workflow.run(McpState()) } @@ -381,26 +431,28 @@ class WorkflowMcpStepTest { fun `mcp typed overload decodes the tool result before merge`() { val (transportProvider, _) = createEchoServer() - val workflow = workflow("typed-mcp-success") { - mcpStep( - name = "echo", - config = McpStepConfig.unrestricted(), - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "echo", - argumentKeys = setOf("message"), - ), - toolCall = { _, _ -> - McpToolCall( - serverCommand = listOf("unused"), - toolName = "echo", - arguments = mapOf("message" to "typed"), - ) - }, - decode = { result -> result.content ?: error("missing content") }, - merge = { state, result, _ -> state.copy(decoded = result) }, - ) - }.build { it }.withFirstMcpTransport(transportProvider) + val workflow = + workflow("typed-mcp-success") { + mcpStep( + name = "echo", + config = McpStepConfig.unrestricted(), + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + argumentKeys = setOf("message"), + ), + toolCall = { _, _ -> + McpToolCall( + serverCommand = listOf("unused"), + toolName = "echo", + arguments = mapOf("message" to "typed"), + ) + }, + decode = { result -> result.content ?: error("missing content") }, + merge = { state, result, _ -> state.copy(decoded = result) }, + ) + }.build { it }.withFirstMcpTransport(transportProvider) val result = runBlocking { workflow.run(McpState(), observer = RecordingMcpObserver()) } @@ -412,26 +464,28 @@ class WorkflowMcpStepTest { val (transportProvider, _) = createEchoServer() val diagnostics = RecordingDiagnosticObserver() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "echo", - argumentKeys = setOf("message"), - ), - config = McpStepConfig.unrestricted(), - toolCallBuilder = { _, _ -> - McpToolCall( - serverCommand = listOf("unused"), - toolName = "echo", - arguments = mapOf("message" to "typed"), - ) - }, - merge = { state, result, _ -> - error("decode failed for '${result.content}'") - }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + argumentKeys = setOf("message"), + ), + config = McpStepConfig.unrestricted(), + toolCallBuilder = { _, _ -> + McpToolCall( + serverCommand = listOf("unused"), + toolName = "echo", + arguments = mapOf("message" to "typed"), + ) + }, + merge = { state, result, _ -> + error("decode failed for '${result.content}'") + }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step), diagnostics) assertThatThrownBy { @@ -450,26 +504,29 @@ class WorkflowMcpStepTest { fun `mcp step rejects state-influenced command changes`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("node", "server.js"), - toolName = "echo", - ), - config = McpStepConfig.unrestricted().copy(reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("evil", "command"), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("node", "server.js"), + toolName = "echo", + ), + config = McpStepConfig.unrestricted().copy(reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("evil", "command"), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val diagnostics = RecordingDiagnosticObserver() val workflow = buildWorkflow(listOf(step), diagnostics) - val error = runCatching { - runBlocking { workflow.run(McpState()) } - }.exceptionOrNull() + val error = + runCatching { + runBlocking { workflow.run(McpState()) } + }.exceptionOrNull() assertThat(error).isInstanceOf(WorkflowMcpException::class.java) assertThat(error).hasMessage("Workflow mcp step validation failed") @@ -482,30 +539,34 @@ class WorkflowMcpStepTest { fun `mcp step enforces command denylist`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("dangerous-cmd"), - toolName = "echo", - ), - config = McpStepConfig( - reconnect = false, - allowedCommands = setOf("dangerous-cmd"), - deniedCommands = setOf("dangerous-cmd"), - ), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("dangerous-cmd"), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("dangerous-cmd"), + toolName = "echo", + ), + config = + McpStepConfig( + reconnect = false, + allowedCommands = setOf("dangerous-cmd"), + deniedCommands = setOf("dangerous-cmd"), + ), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("dangerous-cmd"), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val diagnostics = RecordingDiagnosticObserver() val workflow = buildWorkflow(listOf(step), diagnostics) - val error = runCatching { - runBlocking { workflow.run(McpState()) } - }.exceptionOrNull() + val error = + runCatching { + runBlocking { workflow.run(McpState()) } + }.exceptionOrNull() assertThat(error).isInstanceOf(WorkflowMcpException::class.java) assertThat(error).hasMessage("Workflow mcp step was rejected by policy") @@ -518,24 +579,26 @@ class WorkflowMcpStepTest { fun `mcp step config allows only node commands`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("node", "server.js"), - toolName = "echo", - argumentKeys = setOf("message"), - ), - config = McpStepConfig(allowedCommands = setOf("node")), - toolCallBuilder = { _, _ -> - McpToolCall( - serverCommand = listOf("node", "server.js"), - toolName = "echo", - arguments = mapOf("message" to "hello-node"), - ) - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("node", "server.js"), + toolName = "echo", + argumentKeys = setOf("message"), + ), + config = McpStepConfig(allowedCommands = setOf("node")), + toolCallBuilder = { _, _ -> + McpToolCall( + serverCommand = listOf("node", "server.js"), + toolName = "echo", + arguments = mapOf("message" to "hello-node"), + ) + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) val result = runBlocking { workflow.run(McpState()) } @@ -547,19 +610,21 @@ class WorkflowMcpStepTest { fun `mcp step unrestricted factory preserves old behavior`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "echo", - ), - config = McpStepConfig.unrestricted(), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("unused"), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + ), + config = McpStepConfig.unrestricted(), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("unused"), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) val result = runBlocking { workflow.run(McpState()) } @@ -574,10 +639,11 @@ class WorkflowMcpStepTest { mcpStep( name = "echo", config = McpStepConfig(allowedCommands = setOf("node")), - definition = McpToolCallDefinition( - serverCommand = listOf("python", "server.py"), - toolName = "echo", - ), + definition = + McpToolCallDefinition( + serverCommand = listOf("python", "server.py"), + toolName = "echo", + ), toolCall = { _, _ -> McpToolCall(serverCommand = listOf("python", "server.py"), toolName = "echo") }, @@ -592,19 +658,21 @@ class WorkflowMcpStepTest { fun `dynamic mcp commands violating allowlist fail at execution time`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("python", "server.py"), - toolName = "echo", - ), - config = McpStepConfig(allowedCommands = setOf("node"), reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("python", "server.py"), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("python", "server.py"), + toolName = "echo", + ), + config = McpStepConfig(allowedCommands = setOf("node"), reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("python", "server.py"), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val diagnostics = RecordingDiagnosticObserver() val workflow = buildWorkflow(listOf(step), diagnostics) @@ -623,19 +691,21 @@ class WorkflowMcpStepTest { @Test fun `mcp step redacts server command identifiers in failure errors`() { val secretExecutable = "my-secret-mcp-server" - val step: InternalWorkflowStep = McpWorkflowStep( - name = "secret-server", - definition = McpToolCallDefinition( - serverCommand = listOf(secretExecutable), - toolName = "echo", - ), - config = McpStepConfig.unrestricted().copy(reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf(secretExecutable), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = SubprocessMcpTransportProvider(), - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "secret-server", + definition = + McpToolCallDefinition( + serverCommand = listOf(secretExecutable), + toolName = "echo", + ), + config = McpStepConfig.unrestricted().copy(reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf(secretExecutable), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = SubprocessMcpTransportProvider(), + ) val diagnostics = RecordingDiagnosticObserver() val workflow = buildWorkflow(listOf(step), diagnostics) @@ -657,19 +727,21 @@ class WorkflowMcpStepTest { fun `mcp step with default config denies all dynamic commands at execution time`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("python", "server.py"), - toolName = "echo", - ), - config = McpStepConfig(reconnect = false), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("python", "server.py"), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("python", "server.py"), + toolName = "echo", + ), + config = McpStepConfig(reconnect = false), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("python", "server.py"), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val diagnostics = RecordingDiagnosticObserver() val workflow = buildWorkflow(listOf(step), diagnostics) @@ -689,19 +761,21 @@ class WorkflowMcpStepTest { fun `mcp step unrestricted factory still allows commands`() { val (transportProvider, _) = createEchoServer() - val step: InternalWorkflowStep = McpWorkflowStep( - name = "echo", - definition = McpToolCallDefinition( - serverCommand = listOf("unused"), - toolName = "echo", - ), - config = McpStepConfig.unrestricted(), - toolCallBuilder = { _, _ -> - McpToolCall(serverCommand = listOf("unused"), toolName = "echo") - }, - merge = { state, result, _ -> state.copy(result = result) }, - transportProvider = transportProvider, - ) + val step: InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + ), + config = McpStepConfig.unrestricted(), + toolCallBuilder = { _, _ -> + McpToolCall(serverCommand = listOf("unused"), toolName = "echo") + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) val workflow = buildWorkflow(listOf(step)) val result = runBlocking { workflow.run(McpState()) } @@ -719,27 +793,29 @@ class WorkflowMcpStepTest { private fun buildWorkflow( steps: List>, diagnosticObserver: WorkflowStepFailureDiagnosticObserver = NoOpWorkflowStepFailureDiagnosticObserver, - ): Workflow = Workflow( - name = "test-mcp-workflow", - definitionVersion = "1", - stateType = typeOf(), - resultType = typeOf(), - schedule = null, - steps = steps, - resultSelector = { it }, - stopPolicy = StopPolicy(), - clock = Clock.systemUTC(), - externalStepExecutorResolver = NoOpExternalStepExecutorResolver, - failureDiagnosticObserver = diagnosticObserver, - ) + ): Workflow = + Workflow( + name = "test-mcp-workflow", + definitionVersion = "1", + stateType = typeOf(), + resultType = typeOf(), + schedule = null, + steps = steps, + resultSelector = { it }, + stopPolicy = StopPolicy(), + clock = Clock.systemUTC(), + externalStepExecutorResolver = NoOpExternalStepExecutorResolver, + failureDiagnosticObserver = diagnosticObserver, + ) - private fun sha256(value: String): String = java.security.MessageDigest.getInstance("SHA-256") - .digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + private fun sha256(value: String): String = + java.security.MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } @Suppress("UNCHECKED_CAST") - private fun Workflow.withFirstMcpTransport( - transportProvider: McpTransportProvider, - ): Workflow { + private fun McpWorkflow.withFirstMcpTransport(transportProvider: McpTransportProvider): McpWorkflow { val steps = readPrivate>>("steps").toMutableList() val existing = steps.first() as McpWorkflowStep steps[0] = existing.copy(transportProvider = transportProvider) @@ -781,65 +857,164 @@ class WorkflowMcpStepTest { } } - private fun createEchoServer(): Pair { - val (clientInput, serverToClient, clientToServer, serverInput, pipeCloseable) = createPipes() - val scope = CoroutineScope(Job() + Dispatchers.IO) + @Test + fun `a failing release step does not strand the resources behind it`() { + val attempted = mutableListOf() + val failure = + assertFailsWith { + runBlocking { + releaseAllBestEffort( + { + attempted += "session" + error("session close failed") + }, + { attempted += "job" }, + { + attempted += "server" + error("server close failed") + }, + ) + } + } + + // Every stage ran despite the first failing, and both reasons survived to the report. + assertThat(attempted).containsExactly("session", "job", "server") + assertThat(failure.message).isEqualTo("session close failed") + assertThat(failure.suppressed.map { it.message }).containsExactly("server close failed") + } + + @Test + fun `repeated server cycles tear down without leaking fixtures`() { + // 100 create/connect/call/close cycles in-process. Each cycle must tear its fixture down + // completely: unbounded teardown leaks live servers here and eventually stalls the worker. + val cycles = 100 + servers.clear() + + repeat(cycles) { index -> + val (transportProvider, fixture) = createEchoServer() + val result = + runBlocking { + buildWorkflow(listOf(echoStep(transportProvider, "cycle-$index"))).run(McpState()) + } + + assertThat(result.result?.content).contains("cycle-$index") + fixture.close() + } - val server = Server( - serverInfo = Implementation(name = "test-echo", version = "1.0.0"), - options = ServerOptions( - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = false), + assertThat(TestMcpServerFixture.live.get()) + .describedAs("fixtures still open after $cycles create/call/close cycles") + .isZero() + } + + /** Minimal echo step bound to [transportProvider]; used by the repeated-teardown cycle test. */ + private fun echoStep( + transportProvider: McpTransportProvider, + message: String, + ): InternalWorkflowStep = + McpWorkflowStep( + name = "echo", + definition = + McpToolCallDefinition( + serverCommand = listOf("unused"), + toolName = "echo", + argumentKeys = setOf("message"), ), - ), - ) { - addTool( - Tool( - name = "echo", - description = "Echoes the message back", - inputSchema = io.modelcontextprotocol.kotlin.sdk.types.ToolSchema( - properties = buildJsonObject { - put("message", buildJsonObject { - put("type", JsonPrimitive("string")) - }) - }, - required = listOf("message"), + config = McpStepConfig.unrestricted(), + toolCallBuilder = { _, _ -> + McpToolCall( + serverCommand = listOf("unused"), + toolName = "echo", + arguments = mapOf("message" to message), + ) + }, + merge = { state, result, _ -> state.copy(result = result) }, + transportProvider = transportProvider, + ) + + /** + * Starts [server]'s session on [scope] and returns the fixture that owns the pipes, the session + * and the session job. Shared by every in-process server helper so the lifecycle lives in one + * place. + */ + private fun startMcpServerFixture( + server: Server, + scope: CoroutineScope, + pipeCloseable: AutoCloseable, + serverInput: PipedInputStream, + serverToClient: PipedOutputStream, + ): TestMcpServerFixture { + val session = AtomicReference(null) + val sessionJob = + scope.launch { + session.set( + server.createSession( + StdioServerTransport( + serverInput.asSource().buffered(), + serverToClient.asSink().buffered(), + ), ), - ), - ) { request -> - val message = request.params.arguments?.get("message")?.jsonPrimitive?.content ?: "" - val payload = buildJsonObject { put("echoed", JsonPrimitive(message)) } - CallToolResult( - content = listOf(TextContent("echo: $message")), - structuredContent = payload, ) } - } + return TestMcpServerFixture(server, pipeCloseable, session, sessionJob) + } - scope.launch { - server.createSession( - StdioServerTransport( - serverInput.asSource().buffered(), - serverToClient.asSink().buffered(), - ), - ) - } + private fun createEchoServer(): Pair { + val (clientInput, serverToClient, clientToServer, serverInput, pipeCloseable) = createPipes() + val scope = CoroutineScope(Job() + Dispatchers.IO) - val closeable = AutoCloseable { - runCatching { pipeCloseable.close() } - scope.cancel() - runBlocking { server.close() } - } - servers += closeable + val server = + Server( + serverInfo = Implementation(name = "test-echo", version = "1.0.0"), + options = + ServerOptions( + capabilities = + ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = false), + ), + ), + ) { + addTool( + Tool( + name = "echo", + description = "Echoes the message back", + inputSchema = + io.modelcontextprotocol.kotlin.sdk.types.ToolSchema( + properties = + buildJsonObject { + put( + "message", + buildJsonObject { + put("type", JsonPrimitive("string")) + }, + ) + }, + required = listOf("message"), + ), + ), + ) { request -> + val message = + request.params.arguments + ?.get("message") + ?.jsonPrimitive + ?.content ?: "" + val payload = buildJsonObject { put("echoed", JsonPrimitive(message)) } + CallToolResult( + content = listOf(TextContent("echo: $message")), + structuredContent = payload, + ) + } + } - val transportProvider = object : McpTransportProvider { - override suspend fun connect(toolCall: McpToolCall): McpTransportConnection { - return McpTransportConnection( - input = clientInput.asSource().buffered(), - output = clientToServer.asSink().buffered(), - ) + val closeable = startMcpServerFixture(server, scope, pipeCloseable, serverInput, serverToClient) + servers += closeable + val transportProvider = + object : McpTransportProvider { + override suspend fun connect(toolCall: McpToolCall): McpTransportConnection = + McpTransportConnection( + input = clientInput.asSource().buffered(), + output = clientToServer.asSink().buffered(), + ) } - } return transportProvider to closeable } @@ -848,53 +1023,43 @@ class WorkflowMcpStepTest { val (clientInput, serverToClient, clientToServer, serverInput, pipeCloseable) = createPipes() val scope = CoroutineScope(Job() + Dispatchers.IO) - val server = Server( - serverInfo = Implementation(name = "test-slow", version = "1.0.0"), - options = ServerOptions( - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = false), - ), - ), - ) { - addTool( - Tool( - name = "delay", - description = "Delays for a long time", - inputSchema = io.modelcontextprotocol.kotlin.sdk.types.ToolSchema( - properties = buildJsonObject {}, - required = emptyList(), + val server = + Server( + serverInfo = Implementation(name = "test-slow", version = "1.0.0"), + options = + ServerOptions( + capabilities = + ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = false), + ), ), - ), ) { - delay(30.seconds) - CallToolResult(content = listOf(TextContent("done"))) + addTool( + Tool( + name = "delay", + description = "Delays for a long time", + inputSchema = + io.modelcontextprotocol.kotlin.sdk.types.ToolSchema( + properties = buildJsonObject {}, + required = emptyList(), + ), + ), + ) { + delay(30.seconds) + CallToolResult(content = listOf(TextContent("done"))) + } } - } - - scope.launch { - server.createSession( - StdioServerTransport( - serverInput.asSource().buffered(), - serverToClient.asSink().buffered(), - ), - ) - } - val closeable = AutoCloseable { - runCatching { pipeCloseable.close() } - scope.cancel() - runBlocking { server.close() } - } + val closeable = startMcpServerFixture(server, scope, pipeCloseable, serverInput, serverToClient) servers += closeable - - val transportProvider = object : McpTransportProvider { - override suspend fun connect(toolCall: McpToolCall): McpTransportConnection { - return McpTransportConnection( - input = clientInput.asSource().buffered(), - output = clientToServer.asSink().buffered(), - ) + val transportProvider = + object : McpTransportProvider { + override suspend fun connect(toolCall: McpToolCall): McpTransportConnection = + McpTransportConnection( + input = clientInput.asSource().buffered(), + output = clientToServer.asSink().buffered(), + ) } - } return transportProvider to closeable } @@ -903,56 +1068,46 @@ class WorkflowMcpStepTest { val (clientInput, serverToClient, clientToServer, serverInput, pipeCloseable) = createPipes() val scope = CoroutineScope(Job() + Dispatchers.IO) - val server = Server( - serverInfo = Implementation(name = "test-json", version = "1.0.0"), - options = ServerOptions( - capabilities = ServerCapabilities( - tools = ServerCapabilities.Tools(listChanged = false), - ), - ), - ) { - addTool( - Tool( - name = "get_data", - description = "Returns structured data", - inputSchema = io.modelcontextprotocol.kotlin.sdk.types.ToolSchema( - properties = buildJsonObject {}, - required = emptyList(), + val server = + Server( + serverInfo = Implementation(name = "test-json", version = "1.0.0"), + options = + ServerOptions( + capabilities = + ServerCapabilities( + tools = ServerCapabilities.Tools(listChanged = false), + ), ), - ), ) { - val payload = buildJsonObject { put("key", JsonPrimitive("value")) } - CallToolResult( - content = listOf(TextContent("""{"key":"value"}""")), - structuredContent = payload, - ) + addTool( + Tool( + name = "get_data", + description = "Returns structured data", + inputSchema = + io.modelcontextprotocol.kotlin.sdk.types.ToolSchema( + properties = buildJsonObject {}, + required = emptyList(), + ), + ), + ) { + val payload = buildJsonObject { put("key", JsonPrimitive("value")) } + CallToolResult( + content = listOf(TextContent("""{"key":"value"}""")), + structuredContent = payload, + ) + } } - } - scope.launch { - server.createSession( - StdioServerTransport( - serverInput.asSource().buffered(), - serverToClient.asSink().buffered(), - ), - ) - } - - val closeable = AutoCloseable { - runCatching { pipeCloseable.close() } - scope.cancel() - runBlocking { server.close() } - } + val closeable = startMcpServerFixture(server, scope, pipeCloseable, serverInput, serverToClient) servers += closeable - - val transportProvider = object : McpTransportProvider { - override suspend fun connect(toolCall: McpToolCall): McpTransportConnection { - return McpTransportConnection( - input = clientInput.asSource().buffered(), - output = clientToServer.asSink().buffered(), - ) + val transportProvider = + object : McpTransportProvider { + override suspend fun connect(toolCall: McpToolCall): McpTransportConnection = + McpTransportConnection( + input = clientInput.asSource().buffered(), + output = clientToServer.asSink().buffered(), + ) } - } return transportProvider to closeable } @@ -962,12 +1117,13 @@ class WorkflowMcpStepTest { val clientInput = PipedInputStream(serverToClient) val clientToServer = PipedOutputStream() val serverInput = PipedInputStream(clientToServer) - val closeable = AutoCloseable { - runCatching { clientInput.close() } - runCatching { clientToServer.close() } - runCatching { serverToClient.close() } - runCatching { serverInput.close() } - } + val closeable = + AutoCloseable { + runCatching { clientInput.close() } + runCatching { clientToServer.close() } + runCatching { serverToClient.close() } + runCatching { serverInput.close() } + } return PipeSet(clientInput, serverToClient, clientToServer, serverInput, closeable) } @@ -1025,3 +1181,96 @@ private fun awaitMcpProcessExit(process: ProcessHandle?) { } process.onExit().get(20, TimeUnit.SECONDS) } + +/** + * Runs every release step in [steps], in order, even when an earlier one fails, then throws the first + * failure with the rest attached as suppressed. Closing one owned resource must never strand the + * resources behind it, and the reason the teardown failed must survive to the report. + */ +private suspend fun releaseAllBestEffort(vararg steps: suspend () -> Unit) { + var primaryFailure: Throwable? = null + for (step in steps) { + try { + step() + } catch (failure: Throwable) { + val primary = primaryFailure + if (primary == null) { + primaryFailure = failure + } else { + primary.addSuppressed(failure) + } + } + } + primaryFailure?.let { throw it } +} + +/** + * Lifecycle owner for an in-process MCP test server. + * + * The SDK's stdio transport reads blocking Java streams, which coroutine cancellation cannot + * interrupt. Cancelling the scope before the pipes are closed can therefore leave `Server.close()` + * waiting forever on a session whose reader is parked in `PipedInputStream.read()` — the teardown hang + * that stalled a CI test worker. Closing the pipes first ends those reads with EOF, and the remaining + * shutdown steps run on a daemon thread under a hard bound, so a fixture that cannot shut down fails + * its owning test with a diagnostic instead of stalling the worker. [live] exposes the leak that the + * repeated-cycle test asserts on. + */ +private class TestMcpServerFixture( + private val server: Server, + private val pipes: AutoCloseable, + private val session: AtomicReference, + private val sessionJob: Job, +) : AutoCloseable { + private val closed = AtomicBoolean(false) + + init { + live.incrementAndGet() + } + + override fun close() { + if (!closed.compareAndSet(false, true)) { + return + } + + // 1. EOF for the blocking stdio reads. Without this, everything below can wait forever. + pipes.close() + + // 2-4. Close the exact session we own, stop the server coroutine, then the server itself. + // Bounded because a non-cancellable blocking call must not own the test worker's fate, and + // best-effort across the three stages: one stage failing to release must not strand the + // resources behind it. The first failure is preserved and the later ones are attached to it. + val shutdown = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "mcp-fixture-shutdown").apply { isDaemon = true } + } + try { + shutdown + .submit { + runBlocking { + releaseAllBestEffort( + { session.get()?.close() }, + { sessionJob.cancelAndJoin() }, + { server.close() }, + ) + } + }.get(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch (failure: ExecutionException) { + throw failure.cause ?: failure + } catch (timeout: TimeoutException) { + throw IllegalStateException( + "MCP test server failed to terminate within ${SHUTDOWN_TIMEOUT_SECONDS}s", + timeout, + ) + } finally { + shutdown.shutdownNow() + } + live.decrementAndGet() + } + + companion object { + /** Fixtures created and not yet closed. */ + val live = AtomicInteger() + + private const val SHUTDOWN_TIMEOUT_SECONDS = 5L + } +} diff --git a/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowTerminalFailureTest.kt b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowTerminalFailureTest.kt new file mode 100644 index 000000000..5821779cc --- /dev/null +++ b/tramai-orchestration/src/test/kotlin/dev/tramai/orchestration/WorkflowTerminalFailureTest.kt @@ -0,0 +1,141 @@ +package dev.tramai.orchestration + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * Every terminal failure of a run — initial or resumed — is handled through the same + * choke point in the runner. These tests pin that contract on BOTH entry points: a + * cancellation is rethrown to the caller and is never reported as a failure. + * + * The point of testing both paths is that an edit at the shared handler could otherwise + * regress cancellation semantics everywhere at once, and a cancelled run silently reported + * as FAILED is exactly the kind of misattribution this task exists to prevent. + */ +class WorkflowTerminalFailureTest { + private val stateCodec = + object : WorkflowStateCodec { + override fun encode(state: String): String = state + + override fun decode(payload: String): String = payload + } + + @Test + fun `a cancelled run rethrows the cancellation and reports no failure`() = + runBlocking { + val observer = FailureRecordingObserver() + val reachedStep = CompletableDeferred() + val workflow = + workflow(RUN_WORKFLOW) { + localStep("hold") { _, _ -> + reachedStep.complete(Unit) + awaitCancellation() + } + }.build { it } + + val run = + launch { + workflow.run(initialState = "seed", observer = observer) + } + reachedStep.await() + run.cancelAndJoin() + + // The run really executed before being cancelled, the cancellation propagated + // (a swallowed one would leave the job completed, not cancelled), and the + // observer was never told the run failed. + assertThat(observer.startedWorkflow).isTrue() + assertThat(run.isCancelled).isTrue() + assertThat(observer.failureCount).isZero() + } + + @Test + fun `a cancelled resume rethrows the cancellation and reports no failure`() = + runBlocking { + val store = InMemoryWorkflowCheckpointStore() + val persistence = + WorkflowPersistence( + checkpointStore = store, + stateCodec = stateCodec, + deleteCheckpointOnCompletion = false, + ) + val observer = FailureRecordingObserver() + val reachedStep = CompletableDeferred() + var blockOnSecondExecution = false + val workflow = + workflow(RESUME_WORKFLOW) { + localStep("one") { state, _ -> "$state-one" } + localStep("two") { state, _ -> + if (blockOnSecondExecution) { + reachedStep.complete(Unit) + awaitCancellation() + } else { + "$state-two" + } + } + }.build { it } + val context = WorkflowContext(workflowId = "cancel-resume-1") + + workflow.run( + initialState = "seed", + context = context, + observer = observer, + persistence = persistence, + ) + // Rewind so the delayed step runs again — this time it blocks long enough to cancel. + val current = store.load(RESUME_WORKFLOW, context.workflowId)!! + store.save( + current.copy( + nextStepIndex = 1, + stepExecutions = 1, + lastCompletedStepName = "one", + statePayload = "seed-one", + ), + expectedRevision = current.revision, + ) + blockOnSecondExecution = true + + val resume = + launch { + workflow.resume(context = context, observer = observer, persistence = persistence) + } + reachedStep.await() + resume.cancelAndJoin() + + assertThat(resume.isCancelled).isTrue() + assertThat(observer.failureCount).isZero() + } + + private companion object { + const val RUN_WORKFLOW = "cancel-terminal-run" + const val RESUME_WORKFLOW = "cancel-terminal-resume" + } +} + +/** Records the two things the terminal-failure contract is about, and ignores the rest. */ +private class FailureRecordingObserver : WorkflowObserver by NoOpWorkflowObserver { + var failureCount = 0 + private set + + var startedWorkflow = false + private set + + override fun onWorkflowStarted( + workflowName: String, + context: WorkflowContext, + ) { + startedWorkflow = true + } + + override fun onWorkflowFailed( + workflowName: String, + error: Throwable, + context: WorkflowContext, + ) { + failureCount++ + } +} diff --git a/tramai-persistence-file/api/tramai-persistence-file.api b/tramai-persistence-file/api/tramai-persistence-file.api index 7bce61ddf..de78627da 100644 --- a/tramai-persistence-file/api/tramai-persistence-file.api +++ b/tramai-persistence-file/api/tramai-persistence-file.api @@ -165,6 +165,58 @@ public final class dev/tramai/persistence/file/FileSuspendedInvocationStore : de public final class dev/tramai/persistence/file/FileSuspendedInvocationStore$Companion { } +public final class dev/tramai/persistence/file/GovernedFileApprovalStore : dev/tramai/core/approval/ApprovalStore, dev/tramai/engine/approval/GovernedApprovalStore { + public fun attributionOf (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun consumeApprovedOrReplay-LJPQ52o (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun create (Ldev/tramai/core/approval/ApprovalRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createGovernedApproval (Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution$Governed;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun transition (Ljava/lang/String;JLdev/tramai/core/approval/ApprovalTransition;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/tramai/persistence/file/GovernedFileApprovalStoreKt { + public static final fun asGovernedApprovalStore (Ldev/tramai/core/approval/ApprovalStore;)Ldev/tramai/engine/approval/GovernedApprovalStore; +} + +public final class dev/tramai/persistence/file/GovernedFileSuspendedInvocationStore : dev/tramai/engine/GovernedSuspendedInvocationStore, dev/tramai/engine/SuspendedInvocationStore { + public fun create (Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createGoverned (Ldev/tramai/engine/GovernedSuspendedInvocation;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun governedRunIdentity (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun remove (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun revealReplayEnvelope (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun revealSensitiveContext (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/tramai/persistence/file/PersistedApprovalAttributionV1 { + public static final field Companion Ldev/tramai/persistence/file/PersistedApprovalAttributionV1$Companion; + public fun (ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()I + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedApprovalAttributionV1; + public static synthetic fun copy$default (Ldev/tramai/persistence/file/PersistedApprovalAttributionV1;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/persistence/file/PersistedApprovalAttributionV1; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfigurationId ()Ljava/lang/String; + public final fun getConfigurationVersion ()Ljava/lang/String; + public final fun getDeploymentId ()Ljava/lang/String; + public final fun getEnvironmentId ()Ljava/lang/String; + public final fun getSchemaVersion ()I + public final fun getWorkloadId ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Ljava/lang/String; + public final fun toReservedKeys ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/persistence/file/PersistedApprovalAttributionV1$Companion { + public final fun from (Ldev/tramai/core/identity/GovernedRunIdentity;)Ldev/tramai/persistence/file/PersistedApprovalAttributionV1; + public final fun fromJson (Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedApprovalAttributionV1; +} + public final class dev/tramai/persistence/file/PersistedApprovalBindingV1 { public static final field Companion Ldev/tramai/persistence/file/PersistedApprovalBindingV1$Companion; public fun (ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V @@ -312,6 +364,27 @@ public final class dev/tramai/persistence/file/PersistedApprovalRequestV1$Compan public final fun fromJson (Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedApprovalRequestV1; } +public final class dev/tramai/persistence/file/PersistedApprovalRequestV2 { + public static final field Companion Ldev/tramai/persistence/file/PersistedApprovalRequestV2$Companion; + public fun (ILdev/tramai/persistence/file/PersistedApprovalRequestV1;Ldev/tramai/persistence/file/PersistedApprovalAttributionV1;)V + public final fun component1 ()I + public final fun component2 ()Ldev/tramai/persistence/file/PersistedApprovalRequestV1; + public final fun component3 ()Ldev/tramai/persistence/file/PersistedApprovalAttributionV1; + public final fun copy (ILdev/tramai/persistence/file/PersistedApprovalRequestV1;Ldev/tramai/persistence/file/PersistedApprovalAttributionV1;)Ldev/tramai/persistence/file/PersistedApprovalRequestV2; + public static synthetic fun copy$default (Ldev/tramai/persistence/file/PersistedApprovalRequestV2;ILdev/tramai/persistence/file/PersistedApprovalRequestV1;Ldev/tramai/persistence/file/PersistedApprovalAttributionV1;ILjava/lang/Object;)Ldev/tramai/persistence/file/PersistedApprovalRequestV2; + public fun equals (Ljava/lang/Object;)Z + public final fun getAttribution ()Ldev/tramai/persistence/file/PersistedApprovalAttributionV1; + public final fun getRequest ()Ldev/tramai/persistence/file/PersistedApprovalRequestV1; + public final fun getSchemaVersion ()I + public fun hashCode ()I + public final fun toJson ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/persistence/file/PersistedApprovalRequestV2$Companion { + public final fun fromJson (Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedApprovalRequestV2; +} + public final class dev/tramai/persistence/file/PersistedAuditEventV1 { public static final field Companion Ldev/tramai/persistence/file/PersistedAuditEventV1$Companion; public fun (ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V @@ -449,6 +522,29 @@ public final class dev/tramai/persistence/file/PersistedExecutionSecurityContext public final fun fromJson (Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedExecutionSecurityContextV1; } +public final class dev/tramai/persistence/file/PersistedGovernedRunIdentityV1 { + public fun (ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()I + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1; + public static synthetic fun copy$default (Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfigurationId ()Ljava/lang/String; + public final fun getConfigurationVersion ()Ljava/lang/String; + public final fun getDeploymentId ()Ljava/lang/String; + public final fun getEnvironmentId ()Ljava/lang/String; + public final fun getRunId ()Ljava/lang/String; + public final fun getSchemaVersion ()I + public final fun getWorkloadId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/tramai/persistence/file/PersistedMessageV1 { public static final field Companion Ldev/tramai/persistence/file/PersistedMessageV1$Companion; public fun (ILjava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/util/List;)V @@ -607,6 +703,29 @@ public final class dev/tramai/persistence/file/PersistedSuspendedInvocationRecor public final fun fromJson (Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedSuspendedInvocationRecordV1; } +public final class dev/tramai/persistence/file/PersistedSuspendedInvocationRecordV2 { + public static final field Companion Ldev/tramai/persistence/file/PersistedSuspendedInvocationRecordV2$Companion; + public fun (ILdev/tramai/persistence/file/PersistedSuspendedInvocationMetadataV1;Ldev/tramai/persistence/file/PersistedReplayEnvelopeV1;Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1;)V + public final fun component1 ()I + public final fun component2 ()Ldev/tramai/persistence/file/PersistedSuspendedInvocationMetadataV1; + public final fun component3 ()Ldev/tramai/persistence/file/PersistedReplayEnvelopeV1; + public final fun component4 ()Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1; + public final fun copy (ILdev/tramai/persistence/file/PersistedSuspendedInvocationMetadataV1;Ldev/tramai/persistence/file/PersistedReplayEnvelopeV1;Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1;)Ldev/tramai/persistence/file/PersistedSuspendedInvocationRecordV2; + public static synthetic fun copy$default (Ldev/tramai/persistence/file/PersistedSuspendedInvocationRecordV2;ILdev/tramai/persistence/file/PersistedSuspendedInvocationMetadataV1;Ldev/tramai/persistence/file/PersistedReplayEnvelopeV1;Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1;ILjava/lang/Object;)Ldev/tramai/persistence/file/PersistedSuspendedInvocationRecordV2; + public fun equals (Ljava/lang/Object;)Z + public final fun getGovernedRunIdentity ()Ldev/tramai/persistence/file/PersistedGovernedRunIdentityV1; + public final fun getMetadata ()Ldev/tramai/persistence/file/PersistedSuspendedInvocationMetadataV1; + public final fun getReplayEnvelope ()Ldev/tramai/persistence/file/PersistedReplayEnvelopeV1; + public final fun getSchemaVersion ()I + public fun hashCode ()I + public final fun toJson ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/persistence/file/PersistedSuspendedInvocationRecordV2$Companion { + public final fun fromJson (Ljava/lang/String;)Ldev/tramai/persistence/file/PersistedSuspendedInvocationRecordV2; +} + public final class dev/tramai/persistence/file/PersistedTokenBudgetSnapshotV1 { public static final field Companion Ldev/tramai/persistence/file/PersistedTokenBudgetSnapshotV1$Companion; public fun (IJJDDZ)V diff --git a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileApprovalStore.kt b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileApprovalStore.kt index c3418e461..38811f919 100644 --- a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileApprovalStore.kt +++ b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileApprovalStore.kt @@ -12,6 +12,11 @@ import dev.tramai.core.exception.ApprovalStoreNotConsumableException import dev.tramai.core.exception.ApprovalStoreNotFoundException import dev.tramai.core.exception.ApprovalStoreTokenRejectedException import dev.tramai.core.exception.IllegalApprovalTransitionException +import dev.tramai.engine.approval.ApprovalAttributionCorruptionException +import dev.tramai.engine.approval.ApprovalRunAttribution +import dev.tramai.engine.approval.ApprovalRunAttribution.Ungoverned +import dev.tramai.engine.approval.decodeApprovalAttribution +import dev.tramai.engine.approval.requireAttributionMatchesBinding import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.LinkOption @@ -43,7 +48,6 @@ class FileApprovalStore internal constructor( private val lease: FileStoreLease, private val clock: Clock = Clock.systemUTC(), ) : ApprovalStore { - companion object { private const val RECORD_TYPE = "approval-request" private const val APPROVALS_DIR = "approvals" @@ -66,44 +70,107 @@ class FileApprovalStore internal constructor( return approvalsDir.resolve("$digest$FILE_EXTENSION") } - private fun recordKeyDigest(approvalId: String): String = - FileStoreSha256.digest(RECORD_TYPE, approvalId) + private fun recordKeyDigest(approvalId: String): String = FileStoreSha256.digest(RECORD_TYPE, approvalId) - private fun getLock(approvalId: String): ReentrantLock = - locks.computeIfAbsent(approvalId) { ReentrantLock() } + private fun getLock(approvalId: String): ReentrantLock = locks.computeIfAbsent(approvalId) { ReentrantLock() } // ── Read / write helpers ────────────────────────────────────── - private fun readCurrent(approvalId: String): PersistedApprovalRequestV1? { + /** + * A persisted approval together with its governed attribution snapshot. + * + * [attribution] is null exactly when the record is a V1 (legacy, un-attributed) record. Lifecycle + * rewrites start from this value and only replace [request], so a rewrite site cannot drop + * attribution by forgetting to pass it. + */ + private data class PersistedApprovalRecord( + val request: PersistedApprovalRequestV1, + val attribution: PersistedApprovalAttributionV1?, + ) + + private fun readRecord(approvalId: String): PersistedApprovalRecord? { val path = storePath(approvalId) if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return null FileStoreUtil.validateRegularFile(path, "approval") val rkd = recordKeyDigest(approvalId) - val plaintext: ByteArray = try { - FileStoreUtil.readAndDecrypt(path, RECORD_TYPE, rkd, encryptionKey, keyId) - } catch (e: FileStoreCorruptionException) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } - val json = String(plaintext, Charsets.UTF_8) - val dto = try { - PersistedApprovalRequestV1.fromJson(json) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } - // Enforce schema version on every decode - require(dto.schemaVersion == 1) { "unsupported-approval-schema-version" } - // Enforce binding schema version - require(dto.binding.schemaVersion == 1) { "unsupported-approval-binding-schema-version" } + val plaintext: ByteArray = + try { + FileStoreUtil.readAndDecrypt(path, RECORD_TYPE, rkd, encryptionKey, keyId) + } catch (e: FileStoreCorruptionException) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } catch (e: Exception) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } + val record = decodeRecord(String(plaintext, Charsets.UTF_8)) // Bind decoded ID back to filename digest - val expectedDigest = FileStoreSha256.digest(RECORD_TYPE, dto.approvalId) + val expectedDigest = FileStoreSha256.digest(RECORD_TYPE, record.request.approvalId) require(expectedDigest == rkd) { "approval-id-filename-digest-mismatch" } - return dto + return record } - private fun writeCurrent(approvalId: String, dto: PersistedApprovalRequestV1) { - val json = dto.toJson() + /** + * Decodes a persisted approval of either schema version. + * + * V1 is an un-attributed (legacy) approval, V2 carries governed attribution. The version comes + * from the document itself, so a malformed V2 fails closed here instead of being reinterpreted as + * V1 — and the attribution is validated against the canonical run id on every decode, through the + * same codec the JDBC store uses. + */ + private fun decodeRecord(json: String): PersistedApprovalRecord { + val schemaVersion = + try { + FILE_STORE_JSON.readTree(json).get("schemaVersion")?.asInt() + } catch (e: Exception) { + corrupted(e) + } + val record = + try { + when (schemaVersion) { + 1 -> { + PersistedApprovalRecord(PersistedApprovalRequestV1.fromJson(json), attribution = null) + } + + 2 -> { + val v2 = PersistedApprovalRequestV2.fromJson(json) + PersistedApprovalRecord(v2.request, v2.attribution) + } + + else -> { + null + } + } + } catch (e: Exception) { + corrupted(e) + } + require(record != null) { "unsupported-approval-schema-version: $schemaVersion" } + require(record.request.schemaVersion == 1) { "unsupported-approval-schema-version" } + require(record.request.binding.schemaVersion == 1) { "unsupported-approval-binding-schema-version" } + record.attribution?.let { attribution -> + try { + decodeApprovalAttribution(record.request.binding.workflowRunId, attribution.toReservedKeys()) + } catch (e: ApprovalAttributionCorruptionException) { + // A V2 record whose attribution is unusable is corruption, not an un-attributed + // approval: normalise it into this store's durable-state error vocabulary. + corrupted(e) + } + } + return record + } + + private fun writeRecord( + approvalId: String, + record: PersistedApprovalRecord, + ) { + val json = + if (record.attribution == null) { + record.request.toJson() + } else { + PersistedApprovalRequestV2( + schemaVersion = 2, + request = record.request, + attribution = record.attribution, + ).toJson() + } val path = storePath(approvalId) val rkd = recordKeyDigest(approvalId) val jsonBytes = json.toByteArray(Charsets.UTF_8) @@ -128,13 +195,14 @@ class FileApprovalStore internal constructor( * * @throws FileStoreCorruptionException if any record fails integrity verification. */ - fun verifyAll() = lease.withOpenOperation { - if (!approvalsDir.exists()) return@withOpenOperation - FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") - for (entry in FileStoreUtil.strictCommittedEntries(approvalsDir, COMMITTED_FILENAME, "approval")) { - verifyCommittedApprovalEntry(entry) + fun verifyAll() = + lease.withOpenOperation { + if (!approvalsDir.exists()) return@withOpenOperation + FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") + for (entry in FileStoreUtil.strictCommittedEntries(approvalsDir, COMMITTED_FILENAME, "approval")) { + verifyCommittedApprovalEntry(entry) + } } - } private fun verifyCommittedApprovalEntry(entry: Path) { val digestHex = entry.fileName.toString().removeSuffix(FILE_EXTENSION) @@ -143,52 +211,63 @@ class FileApprovalStore internal constructor( } FileStoreUtil.validateRegularFile(entry, "approval") - val dto = readApprovalEntry(entry, digestHex) - validateApprovalRecordSchema(dto) - validateApprovalFilenameBinding(dto, digestHex) - validateApprovalDomain(dto) + val record = readApprovalEntry(entry, digestHex) + validateApprovalRecordSchema(record) + validateApprovalFilenameBinding(record, digestHex) + validateApprovalDomain(record) } private fun readApprovalEntry( entry: Path, digestHex: String, - ): PersistedApprovalRequestV1 { - val plaintext = try { - FileStoreUtil.readAndDecrypt(entry, RECORD_TYPE, digestHex, encryptionKey, keyId) - } catch (e: FileStoreCorruptionException) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } - return try { - PersistedApprovalRequestV1.fromJson(String(plaintext, Charsets.UTF_8)) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } + ): PersistedApprovalRecord { + val plaintext = + try { + FileStoreUtil.readAndDecrypt(entry, RECORD_TYPE, digestHex, encryptionKey, keyId) + } catch (e: FileStoreCorruptionException) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } catch (e: Exception) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } + return decodeRecord(String(plaintext, Charsets.UTF_8)) } - private fun validateApprovalRecordSchema(dto: PersistedApprovalRequestV1) { - if (dto.schemaVersion != 1) { - throw FileStoreUnsupportedFormatException("unsupported-approval-schema-version: ${dto.schemaVersion}") - } - if (dto.binding.schemaVersion != 1) { - throw FileStoreUnsupportedFormatException("unsupported-approval-binding-schema-version: ${dto.binding.schemaVersion}") - } + private fun validateApprovalRecordSchema(record: PersistedApprovalRecord) { + val attribution = record.attribution + val unsupported = + when { + record.request.schemaVersion != 1 -> { + "unsupported-approval-schema-version: ${record.request.schemaVersion}" + } + + record.request.binding.schemaVersion != 1 -> { + "unsupported-approval-binding-schema-version: ${record.request.binding.schemaVersion}" + } + + attribution != null && attribution.schemaVersion != 1 -> { + "unsupported-approval-attribution-schema-version: ${attribution.schemaVersion}" + } + + else -> { + null + } + } + if (unsupported != null) throw FileStoreUnsupportedFormatException(unsupported) } private fun validateApprovalFilenameBinding( - dto: PersistedApprovalRequestV1, + record: PersistedApprovalRecord, digestHex: String, ) { - val expectedDigest = FileStoreSha256.digest(RECORD_TYPE, dto.approvalId) + val expectedDigest = FileStoreSha256.digest(RECORD_TYPE, record.request.approvalId) if (expectedDigest != digestHex) { throw FileStoreCorruptionException("approval-id-filename-digest-mismatch") } } - private fun validateApprovalDomain(dto: PersistedApprovalRequestV1) { + private fun validateApprovalDomain(record: PersistedApprovalRecord) { try { - dto.toDomain() + record.request.toDomain() } catch (e: Exception) { throw FileStoreCorruptionException("approval-domain-conversion-failed", e) } @@ -196,169 +275,215 @@ class FileApprovalStore internal constructor( // ── ApprovalStore SPI ───────────────────────────────────────── - override suspend fun create(request: ApprovalRequest): ApprovalRequest = lease.withOpenOperation { - FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") - // Version - require(request.version == 0L) { "Initial approval version must be 0, got ${request.version}" } - - // Status - require(request.status == ApprovalStatus.PENDING) { "Initial approval status must be PENDING, got ${request.status}" } - - // No decision fields set - require(request.decidedBy == null) { "Initial approval must not have decidedBy set" } - require(request.decidedAt == null) { "Initial approval must not have decidedAt set" } - require(request.decisionComment == null) { "Initial approval must not have decisionComment set" } - - // Approval ID - validateIdField(request.approvalId, "approvalId", MAX_ID_LENGTH) - - // Requested by - validateIdField(request.requestedBy, "requestedBy", MAX_ID_LENGTH) - SafeActorIdPolicy.validateActorId(request.requestedBy, "requestedBy") - - // Binding fields - val binding = request.binding - validateIdField(binding.workflowRunId, "workflowRunId", MAX_ID_LENGTH) - validateIdField(binding.toolName, "toolName", MAX_ID_LENGTH) - validateIdField(binding.policyVersion, "policyVersion", MAX_ID_LENGTH) + override suspend fun create(request: ApprovalRequest): ApprovalRequest = createApproval(request, Ungoverned) + + internal suspend fun createGovernedApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution.Governed, + ): ApprovalRequest = createApproval(request, attribution) + + private suspend fun createApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution, + ): ApprovalRequest = + lease.withOpenOperation { + requireAttributionMatchesBinding(request, attribution) + FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") + // Version + require(request.version == 0L) { "Initial approval version must be 0, got ${request.version}" } + + // Status + require(request.status == ApprovalStatus.PENDING) { "Expected PENDING, got ${request.status}" } + + // No decision fields set + require(request.decidedBy == null) { "Initial approval must not have decidedBy set" } + require(request.decidedAt == null) { "Initial approval must not have decidedAt set" } + require(request.decisionComment == null) { "Initial approval must not have decisionComment set" } + + // Approval ID + validateIdField(request.approvalId, "approvalId", MAX_ID_LENGTH) + + // Requested by + validateIdField(request.requestedBy, "requestedBy", MAX_ID_LENGTH) + SafeActorIdPolicy.validateActorId(request.requestedBy, "requestedBy") + + // Binding fields + val binding = request.binding + validateIdField(binding.workflowRunId, "workflowRunId", MAX_ID_LENGTH) + validateIdField(binding.toolName, "toolName", MAX_ID_LENGTH) + validateIdField(binding.policyVersion, "policyVersion", MAX_ID_LENGTH) + + // Expiry: must be in the future + val now = clock.instant() + require(request.expiresAt > now) { "expiresAt must be in the future: $now vs ${request.expiresAt}" } + require(request.expiresAt > request.requestedAt) { "expiresAt must be after requestedAt" } + + // requestedAt must not be in the future + require(request.requestedAt <= now) { "requestedAt is in the future: ${request.requestedAt} vs $now" } + require(request.consumedBy == null) { "Initial approval must not have consumedBy set" } + require(request.consumedAt == null) { "Initial approval must not have consumedAt set" } + + // Bounded TTL + val ttl = Duration.between(request.requestedAt, request.expiresAt) + require(ttl <= maxCreationTtl) { + "expiresAt exceeds maximum creation TTL of $maxCreationTtl" + } - // Expiry: must be in the future - val now = clock.instant() - require(request.expiresAt > now) { "expiresAt must be in the future, got $now for expiry ${request.expiresAt}" } - require(request.expiresAt > request.requestedAt) { "expiresAt must be after requestedAt" } - - // requestedAt must not be in the future - require(request.requestedAt <= now) { "requestedAt must not be in the future, got ${request.requestedAt} for now $now" } - require(request.consumedBy == null) { "Initial approval must not have consumedBy set" } - require(request.consumedAt == null) { "Initial approval must not have consumedAt set" } - - // Bounded TTL - val ttl = Duration.between(request.requestedAt, request.expiresAt) - require(ttl <= maxCreationTtl) { - "expiresAt exceeds maximum creation TTL of $maxCreationTtl" + val path = storePath(request.approvalId) + val lock = getLock(request.approvalId) + lock.lock() + try { + if (path.exists()) throw ApprovalStoreConflictException(request.approvalId) + + writeRecord( + request.approvalId, + PersistedApprovalRecord( + request = request.toPersistedV1(), + attribution = + (attribution as? ApprovalRunAttribution.Governed) + ?.let { PersistedApprovalAttributionV1.from(it.identity) }, + ), + ) + return request + } finally { + lock.unlock() + } } - val path = storePath(request.approvalId) - val lock = getLock(request.approvalId) - lock.lock() - try { - if (path.exists()) throw ApprovalStoreConflictException(request.approvalId) - - val dto = request.toPersistedV1() - writeCurrent(request.approvalId, dto) - return request - } finally { - lock.unlock() + override suspend fun get(approvalId: String): ApprovalRequest? = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + val lock = getLock(approvalId) + lock.lock() + try { + val record = readRecord(approvalId) ?: return null + return record.request.toDomain() + } finally { + lock.unlock() + } } - } - override suspend fun get(approvalId: String): ApprovalRequest? = lease.withOpenOperation { - FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") - validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) - val lock = getLock(approvalId) - lock.lock() - try { - val dto = readCurrent(approvalId) ?: return null - return dto.toDomain() - } finally { - lock.unlock() + internal suspend fun attributionOf(approvalId: String): ApprovalRunAttribution = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + val lock = getLock(approvalId) + lock.lock() + try { + val record = readRecord(approvalId) ?: throw ApprovalStoreNotFoundException(approvalId) + // One codec, shared with JDBC: V2 components are validated against the canonical run id + // that the binding owns, and a legacy record decodes as un-attributed. + decodeApprovalAttribution( + record.request.binding.workflowRunId, + record.attribution?.toReservedKeys().orEmpty(), + ) + } finally { + lock.unlock() + } } - } override suspend fun transition( approvalId: String, expectedVersion: Long, transition: ApprovalTransition, - ): ApprovalRequest = lease.withOpenOperation { - FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") - validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + ): ApprovalRequest = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) - transition.commentOrNull()?.let { - require(it.length <= MAX_COMMENT_LENGTH) { "Comment exceeds maximum length of $MAX_COMMENT_LENGTH" } - } + transition.commentOrNull()?.let { + require(it.length <= MAX_COMMENT_LENGTH) { "Comment exceeds maximum length of $MAX_COMMENT_LENGTH" } + } - transition.decidedByOrNull()?.let { decidedBy -> - validateIdField(decidedBy, "decidedBy", MAX_ID_LENGTH) - SafeActorIdPolicy.validateActorId(decidedBy, "decidedBy") - } + transition.decidedByOrNull()?.let { decidedBy -> + validateIdField(decidedBy, "decidedBy", MAX_ID_LENGTH) + SafeActorIdPolicy.validateActorId(decidedBy, "decidedBy") + } - val lock = getLock(approvalId) - lock.lock() - try { - val dto = readCurrent(approvalId) ?: throw ApprovalStoreNotFoundException(approvalId) - val req = dto.toDomain() + val lock = getLock(approvalId) + lock.lock() + try { + val record = readRecord(approvalId) ?: throw ApprovalStoreNotFoundException(approvalId) + val req = record.request.toDomain() - if (req.version != expectedVersion) throw ApprovalStoreConflictException(approvalId) + if (req.version != expectedVersion) throw ApprovalStoreConflictException(approvalId) - val now = clock.instant() - val nextStatus = resolveNextStatus(req, transition, now) + val now = clock.instant() + val nextStatus = resolveNextStatus(req, transition, now) - val updated = req.copy( - status = nextStatus, - version = incrementVersion(approvalId, req.version), - decidedAt = now, - decidedBy = transition.decidedByOrNull(), - decisionComment = transition.commentOrNull(), - ) + val updated = + req.copy( + status = nextStatus, + version = incrementVersion(approvalId, req.version), + decidedAt = now, + decidedBy = transition.decidedByOrNull(), + decisionComment = transition.commentOrNull(), + ) - writeCurrent(approvalId, updated.toPersistedV1()) - return updated - } finally { - lock.unlock() + // Attribution travels with the record, so this rewrite cannot drop it. + writeRecord(approvalId, record.copy(request = updated.toPersistedV1())) + return updated + } finally { + lock.unlock() + } } - } override suspend fun consumeApprovedOrReplay( approvalId: String, expectedVersion: Long, presentedTokenDigest: Sha256Digest, consumedBy: String, - ): ApprovalConsumptionReceipt = lease.withOpenOperation { - FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") - validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) - validateIdField(consumedBy, "consumedBy", MAX_ID_LENGTH) - SafeActorIdPolicy.validateActorId(consumedBy, "consumedBy") - - val lock = getLock(approvalId) - lock.lock() - try { - val dto = readCurrent(approvalId) ?: throw ApprovalStoreNotFoundException(approvalId) - val req = dto.toDomain() - - if (req.status != ApprovalStatus.APPROVED) throw ApprovalStoreNotConsumableException(approvalId) + ): ApprovalConsumptionReceipt = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(approvalsDir, "approvals") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + validateIdField(consumedBy, "consumedBy", MAX_ID_LENGTH) + SafeActorIdPolicy.validateActorId(consumedBy, "consumedBy") + + val lock = getLock(approvalId) + lock.lock() + try { + val record = readRecord(approvalId) ?: throw ApprovalStoreNotFoundException(approvalId) + val req = record.request.toDomain() + + if (req.status != ApprovalStatus.APPROVED) throw ApprovalStoreNotConsumableException(approvalId) + + if (!tokenDigestsMatch(presentedTokenDigest, req.binding.approvalTokenDigest)) { + throw ApprovalStoreTokenRejectedException(approvalId) + } - if (!tokenDigestsMatch(presentedTokenDigest, req.binding.approvalTokenDigest)) { - throw ApprovalStoreTokenRejectedException(approvalId) - } + if (req.consumedAt == null && req.consumedBy == null) { + return consumeFreshApproval(approvalId, expectedVersion, consumedBy, record) + } - if (req.consumedAt == null && req.consumedBy == null) { - return consumeFreshApproval(approvalId, expectedVersion, consumedBy, req) + return consumeReplayApproval(approvalId, expectedVersion, consumedBy, req) + } finally { + lock.unlock() } - - return consumeReplayApproval(approvalId, expectedVersion, consumedBy, req) - } finally { - lock.unlock() } - } private fun consumeFreshApproval( approvalId: String, expectedVersion: Long, consumedBy: String, - req: ApprovalRequest, + record: PersistedApprovalRecord, ): ApprovalConsumptionReceipt { + val req = record.request.toDomain() if (req.version != expectedVersion) throw ApprovalStoreConflictException(approvalId) val now = clock.instant() if (now >= req.expiresAt) throw ApprovalStoreNotConsumableException(approvalId) - val updated = req.copy( - consumedBy = consumedBy, - consumedAt = now, - version = incrementVersion(approvalId, req.version), - ) + val updated = + req.copy( + consumedBy = consumedBy, + consumedAt = now, + version = incrementVersion(approvalId, req.version), + ) - writeCurrent(approvalId, updated.toPersistedV1()) + // Same rewrite rule as transition: the governed snapshot is carried forward, not rebuilt. + writeRecord(approvalId, record.copy(request = updated.toPersistedV1())) return ApprovalConsumptionReceipt(request = updated, replayed = false) } @@ -373,11 +498,12 @@ class FileApprovalStore internal constructor( } if (req.consumedBy != consumedBy) throw ApprovalStoreNotConsumableException(approvalId) - val replayVersion = try { - Math.addExact(expectedVersion, 1L) - } catch (_: ArithmeticException) { - throw ApprovalStoreConflictException(approvalId) - } + val replayVersion = + try { + Math.addExact(expectedVersion, 1L) + } catch (_: ArithmeticException) { + throw ApprovalStoreConflictException(approvalId) + } if (req.version != replayVersion) throw ApprovalStoreConflictException(approvalId) return ApprovalConsumptionReceipt(request = req, replayed = true) @@ -385,34 +511,6 @@ class FileApprovalStore internal constructor( // ── Internal helpers ────────────────────────────────────────── - private fun incrementVersion(approvalId: String, version: Long): Long = - try { - Math.addExact(version, 1L) - } catch (_: ArithmeticException) { - throw ApprovalStoreConflictException(approvalId) - } - - private fun tokenDigestsMatch( - presentedTokenDigest: Sha256Digest, - storedTokenDigest: Sha256Digest, - ): Boolean = - MessageDigest.isEqual( - presentedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), - storedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), - ) - - private fun ApprovalTransition.decidedByOrNull(): String? = when (this) { - is ApprovalTransition.Approve -> decidedBy - is ApprovalTransition.Deny -> decidedBy - is ApprovalTransition.Timeout -> null - } - - private fun ApprovalTransition.commentOrNull(): String? = when (this) { - is ApprovalTransition.Approve -> comment - is ApprovalTransition.Deny -> comment - is ApprovalTransition.Timeout -> null - } - private fun resolveNextStatus( current: ApprovalRequest, transition: ApprovalTransition, @@ -425,48 +523,112 @@ class FileApprovalStore internal constructor( return ApprovalStatus.TIMED_OUT } throw IllegalApprovalTransitionException( - current.approvalId, current.status, transition.targetStatus(), + current.approvalId, + current.status, + transition.targetStatus(), "approval has expired at ${current.expiresAt}", ) } when (transition) { - is ApprovalTransition.Approve -> ApprovalStatus.APPROVED - is ApprovalTransition.Deny -> ApprovalStatus.DENIED + is ApprovalTransition.Approve -> { + ApprovalStatus.APPROVED + } + + is ApprovalTransition.Deny -> { + ApprovalStatus.DENIED + } + is ApprovalTransition.Timeout -> { throw IllegalApprovalTransitionException( - current.approvalId, current.status, transition.targetStatus(), + current.approvalId, + current.status, + transition.targetStatus(), "Cannot time out approval before expiry at ${current.expiresAt}", ) } } } - ApprovalStatus.APPROVED -> throw IllegalApprovalTransitionException( - current.approvalId, current.status, - transition.targetStatus(), - "approval already granted", - ) - ApprovalStatus.DENIED -> throw IllegalApprovalTransitionException( - current.approvalId, current.status, - transition.targetStatus(), - "approval already denied", - ) - ApprovalStatus.TIMED_OUT -> throw IllegalApprovalTransitionException( - current.approvalId, current.status, - transition.targetStatus(), - "approval already timed out", - ) - } - } - private fun validateIdField(value: String, fieldName: String, maxLength: Int): String { - val trimmed = value.trim() - require(trimmed.isNotBlank()) { "$fieldName must not be blank" } - require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } - require(trimmed.length <= maxLength) { "$fieldName exceeds maximum length of $maxLength" } - require(trimmed == value) { "$fieldName must not contain surrounding whitespace" } - return trimmed + ApprovalStatus.APPROVED -> { + throw IllegalApprovalTransitionException( + current.approvalId, + current.status, + transition.targetStatus(), + "approval already granted", + ) + } + + ApprovalStatus.DENIED -> { + throw IllegalApprovalTransitionException( + current.approvalId, + current.status, + transition.targetStatus(), + "approval already denied", + ) + } + + ApprovalStatus.TIMED_OUT -> { + throw IllegalApprovalTransitionException( + current.approvalId, + current.status, + transition.targetStatus(), + "approval already timed out", + ) + } + } } } /** @see FileApprovalStore */ private const val ERROR_CORRUPTED_RECORD = "approval-record-corrupted" + +/** The one durable-state failure shape for this store; keeps call sites throw-free. */ +private fun corrupted(cause: Exception): Nothing = throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, cause) + +// ── File-level helpers (outside the class so its function budget is unchanged) ── + +private fun incrementVersion( + approvalId: String, + version: Long, +): Long = + try { + Math.addExact(version, 1L) + } catch (_: ArithmeticException) { + throw ApprovalStoreConflictException(approvalId) + } + +private fun tokenDigestsMatch( + presentedTokenDigest: Sha256Digest, + storedTokenDigest: Sha256Digest, +): Boolean = + MessageDigest.isEqual( + presentedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), + storedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), + ) + +private fun ApprovalTransition.decidedByOrNull(): String? = + when (this) { + is ApprovalTransition.Approve -> decidedBy + is ApprovalTransition.Deny -> decidedBy + is ApprovalTransition.Timeout -> null + } + +private fun ApprovalTransition.commentOrNull(): String? = + when (this) { + is ApprovalTransition.Approve -> comment + is ApprovalTransition.Deny -> comment + is ApprovalTransition.Timeout -> null + } + +private fun validateIdField( + value: String, + fieldName: String, + maxLength: Int, +): String { + val trimmed = value.trim() + require(trimmed.isNotBlank()) { "$fieldName must not be blank" } + require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } + require(trimmed.length <= maxLength) { "$fieldName exceeds maximum length of $maxLength" } + require(trimmed == value) { "$fieldName must not contain surrounding whitespace" } + return trimmed +} diff --git a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileBackedSovereignStores.kt b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileBackedSovereignStores.kt index 7fd6a1678..964a19613 100644 --- a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileBackedSovereignStores.kt +++ b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileBackedSovereignStores.kt @@ -52,7 +52,6 @@ class FileBackedSovereignStores private constructor( private val rootDir: Path, private val lease: FileStoreLease, ) : AutoCloseable { - companion object { private const val MAX_KEY_ID_LENGTH = 128 private val SAFE_KEY_ID = Regex("[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}") @@ -88,7 +87,7 @@ class FileBackedSovereignStores private constructor( approvalStore = stores.approvalStore, approvalContinuationStore = stores.approvalContinuationStore, auditStore = stores.auditStore, - suspendedInvocationStore = stores.suspendedInvocationStore, + suspendedInvocationStore = stores.governedSuspendedInvocationStore, lockFile = lockHandle.file, lock = lockHandle.lock, rootDir = root, @@ -116,7 +115,11 @@ class FileBackedSovereignStores private constructor( val auditStore: FileAuditStore, val suspendedInvocationStore: FileSuspendedInvocationStore, val lease: FileStoreLease, - ) + ) { + /** The app-facing view: same store, plus the governed suspension capability. */ + val governedSuspendedInvocationStore: SuspendedInvocationStore = + GovernedFileSuspendedInvocationStore(suspendedInvocationStore) + } private fun prepareRootDirectory(root: Path) { if (root.notExists()) { @@ -144,8 +147,9 @@ class FileBackedSovereignStores private constructor( return try { StoreLockHandle( file = lockFile, - lock = lockFile.channel.tryLock() - ?: throw FileStoreLockUnavailableException("tramai-lock-unavailable"), + lock = + lockFile.channel.tryLock() + ?: throw FileStoreLockUnavailableException("tramai-lock-unavailable"), ) } catch (e: Exception) { closeQuietly(lockFile) @@ -171,7 +175,10 @@ class FileBackedSovereignStores private constructor( } } - private fun prepareStoreSubdirectory(path: Path, name: String) { + private fun prepareStoreSubdirectory( + path: Path, + name: String, + ) { if (path.notExists()) { Files.createDirectories(path, PosixFilePermissions.asFileAttribute(DIR_PERMS_0700)) } @@ -190,26 +197,28 @@ class FileBackedSovereignStores private constructor( return } - val manifest = StoreManifestV1( - formatVersion = 1, - module = "tramai-persistence-file", - createdAt = Instant.now().toString(), - ) - FileChannel.open( - manifestPath, - setOf( - StandardOpenOption.CREATE_NEW, - StandardOpenOption.WRITE, - StandardOpenOption.DSYNC, - ), - PosixFilePermissions.asFileAttribute(FILE_PERMS_0600), - ).use { channel -> - val buffer = java.nio.ByteBuffer.wrap(manifest.toJson().toByteArray(Charsets.UTF_8)) - while (buffer.hasRemaining()) { - channel.write(buffer) + val manifest = + StoreManifestV1( + formatVersion = 1, + module = "tramai-persistence-file", + createdAt = Instant.now().toString(), + ) + FileChannel + .open( + manifestPath, + setOf( + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + StandardOpenOption.DSYNC, + ), + PosixFilePermissions.asFileAttribute(FILE_PERMS_0600), + ).use { channel -> + val buffer = java.nio.ByteBuffer.wrap(manifest.toJson().toByteArray(Charsets.UTF_8)) + while (buffer.hasRemaining()) { + channel.write(buffer) + } + channel.force(true) } - channel.force(true) - } } private fun createStores( diff --git a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStore.kt b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStore.kt index 223aaf547..eb793927f 100644 --- a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStore.kt +++ b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStore.kt @@ -1,5 +1,8 @@ package dev.tramai.persistence.file +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore import dev.tramai.engine.SensitiveReplayEnvelope import dev.tramai.engine.SuspendedInvocationMetadata import dev.tramai.engine.SuspendedInvocationStore @@ -18,10 +21,10 @@ class FileSuspendedInvocationStore internal constructor( configuration: FileBackedStoreConfiguration, private val lease: FileStoreLease, ) : SuspendedInvocationStore { - internal data class ValidatedSuspendedInvocationRecord( val metadata: SuspendedInvocationMetadata, val envelope: SensitiveReplayEnvelope, + val runIdentity: GovernedRunIdentity? = null, ) companion object { @@ -42,78 +45,106 @@ class FileSuspendedInvocationStore internal constructor( return suspendedDir.resolve("$digest$FILE_EXTENSION") } - private fun recordKeyDigest(approvalId: String): String = - FileStoreSha256.digest(RECORD_TYPE, approvalId) + private fun recordKeyDigest(approvalId: String): String = FileStoreSha256.digest(RECORD_TYPE, approvalId) + + private fun getLock(approvalId: String): ReentrantLock = locks.computeIfAbsent(approvalId) { ReentrantLock() } + + /** Corruption has one classification and one outer message, whatever wrapped it. */ + private fun failCorrupted(cause: Throwable): Nothing { + val failure = FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, cause) + throw failure + } + + private fun failCorrupted( + message: String, + cause: Throwable, + ): Nothing = throw FileStoreCorruptionException(message, cause) - private fun getLock(approvalId: String): ReentrantLock = - locks.computeIfAbsent(approvalId) { ReentrantLock() } + private fun failCorrupted(message: String): Nothing = throw FileStoreCorruptionException(message) - private fun readCurrent(approvalId: String): PersistedSuspendedInvocationRecordV1? { + /** An already-classified failure is preserved; anything else becomes corruption. */ + private fun failPreservingClassification(cause: Throwable): Nothing = + throw if (cause is FileStoreUnsupportedFormatException || cause is FileStoreCorruptionException) { + cause + } else { + FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, cause) + } + + private fun readCurrent(approvalId: String): DecodedSuspendedInvocationRecord? { val path = storePath(approvalId) if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return null FileStoreUtil.validateRegularFile(path, STORAGE_NAME) val rkd = recordKeyDigest(approvalId) - val plaintext = try { - FileStoreUtil.readAndDecrypt(path, RECORD_TYPE, rkd, encryptionKey, keyId) - } catch (e: FileStoreCorruptionException) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } - val record = try { - PersistedSuspendedInvocationRecordV1.fromJson(String(plaintext, Charsets.UTF_8)) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } + val plaintext = + try { + FileStoreUtil.readAndDecrypt(path, RECORD_TYPE, rkd, encryptionKey, keyId) + } catch (e: FileStoreCorruptionException) { + failCorrupted(e) + } catch (e: Exception) { + failCorrupted(e) + } + val record = + try { + decodeSuspendedInvocationRecord(String(plaintext, Charsets.UTF_8)) + } catch (e: FileStoreUnsupportedFormatException) { + failPreservingClassification(e) + } catch (e: FileStoreCorruptionException) { + failPreservingClassification(e) + } catch (e: Exception) { + failCorrupted(e) + } validateRecordSchemas(record) val expectedDigest = FileStoreSha256.digest(RECORD_TYPE, record.metadata.approvalId) if (expectedDigest != rkd) { - throw FileStoreCorruptionException("suspended-invocation-id-filename-digest-mismatch") + failCorrupted("suspended-invocation-id-filename-digest-mismatch") } return record } - internal fun decodeAndValidateRecord( - record: PersistedSuspendedInvocationRecordV1, - ): ValidatedSuspendedInvocationRecord { + internal fun decodeAndValidateRecord(record: DecodedSuspendedInvocationRecord): ValidatedSuspendedInvocationRecord { try { validateRecordSchemas(record) } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + failCorrupted(e) } - val metadata = try { - record.metadata.toDomain() - } catch (e: Exception) { - throw FileStoreCorruptionException("suspended-invocation-domain-conversion-failed", e) - } + val metadata = + try { + record.metadata.toDomain() + } catch (e: Exception) { + failCorrupted("suspended-invocation-domain-conversion-failed", e) + } - val messages = try { - record.replayEnvelope.messages.map { it.toDomain() } - } catch (e: Exception) { - throw FileStoreCorruptionException("suspended-invocation-domain-conversion-failed", e) - } + val messages = + try { + record.replayEnvelope.messages.map { it.toDomain() } + } catch (e: Exception) { + failCorrupted("suspended-invocation-domain-conversion-failed", e) + } - val envelope = try { - ReplayEnvelopePersistenceCodec.restoreFromPersistence(metadata, messages) - } catch (e: Exception) { - throw FileStoreCorruptionException("suspended-invocation-replay-envelope-invalid", e) - } + val envelope = + try { + ReplayEnvelopePersistenceCodec.restoreFromPersistence(metadata, messages) + } catch (e: Exception) { + failCorrupted("suspended-invocation-replay-envelope-invalid", e) + } - return ValidatedSuspendedInvocationRecord(metadata, envelope) + return ValidatedSuspendedInvocationRecord(metadata, envelope, record.governedRunIdentity) } - fun verifyAll() = lease.withOpenOperation { - if (!suspendedDir.exists()) return@withOpenOperation - FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") - FileStoreUtil.strictCommittedEntries( - suspendedDir, - COMMITTED_FILENAME, - STORAGE_NAME, - ).forEach { entry -> - verifyCommittedEntry(entry) + fun verifyAll() = + lease.withOpenOperation { + if (!suspendedDir.exists()) return@withOpenOperation + FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") + FileStoreUtil + .strictCommittedEntries( + suspendedDir, + COMMITTED_FILENAME, + STORAGE_NAME, + ).forEach { entry -> + verifyCommittedEntry(entry) + } } - } private fun verifyCommittedEntry(entry: Path) { val digestHex = entry.fileName.toString().removeSuffix(FILE_EXTENSION) @@ -128,7 +159,7 @@ class FileSuspendedInvocationStore internal constructor( try { ReplayEnvelopePersistenceCodec.snapshotForPersistence(validated.metadata, validated.envelope) } catch (e: Exception) { - throw FileStoreCorruptionException("suspended-invocation-replay-envelope-invalid", e) + failCorrupted("suspended-invocation-replay-envelope-invalid", e) } } @@ -141,34 +172,68 @@ class FileSuspendedInvocationStore internal constructor( private fun readRecordFromEntry( entry: Path, digestHex: String, - ): PersistedSuspendedInvocationRecordV1 { - val plaintext = try { - FileStoreUtil.readAndDecrypt(entry, RECORD_TYPE, digestHex, encryptionKey, keyId) - } catch (e: FileStoreCorruptionException) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } + ): DecodedSuspendedInvocationRecord { + val plaintext = + try { + FileStoreUtil.readAndDecrypt(entry, RECORD_TYPE, digestHex, encryptionKey, keyId) + } catch (e: FileStoreCorruptionException) { + failCorrupted(e) + } catch (e: Exception) { + failCorrupted(e) + } return try { - PersistedSuspendedInvocationRecordV1.fromJson(String(plaintext, Charsets.UTF_8)) + decodeSuspendedInvocationRecord(String(plaintext, Charsets.UTF_8)) + } catch (e: FileStoreUnsupportedFormatException) { + failPreservingClassification(e) + } catch (e: FileStoreCorruptionException) { + failPreservingClassification(e) } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + failCorrupted(e) } } private fun validateRecordFilenameBinding( - record: PersistedSuspendedInvocationRecordV1, + record: DecodedSuspendedInvocationRecord, digestHex: String, ) { val expectedDigest = FileStoreSha256.digest(RECORD_TYPE, record.metadata.approvalId) if (expectedDigest != digestHex) { - throw FileStoreCorruptionException("suspended-invocation-id-filename-digest-mismatch") + failCorrupted("suspended-invocation-id-filename-digest-mismatch") } } override suspend fun create( metadata: SuspendedInvocationMetadata, replayEnvelope: SensitiveReplayEnvelope, + ) = writeRecord(metadata, replayEnvelope, runIdentity = null) + + internal suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) = writeRecord(suspended.metadata, replayEnvelope, suspended.runIdentity) + + internal suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + val lock = getLock(approvalId) + lock.lock() + try { + val record = readCurrent(approvalId) ?: return null + return decodeAndValidateRecord(record).runIdentity + } finally { + lock.unlock() + } + } + + /** + * ONE durable record either way — V1 when ungoverned, V2 when governed — through a single + * atomic encrypted create, so a crash can never leave a half-governed suspension behind. + */ + private suspend fun writeRecord( + metadata: SuspendedInvocationMetadata, + replayEnvelope: SensitiveReplayEnvelope, + runIdentity: GovernedRunIdentity?, ) = lease.withOpenOperation { FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") validateIdField(metadata.approvalId, "approvalId", MAX_ID_LENGTH) @@ -181,17 +246,29 @@ class FileSuspendedInvocationStore internal constructor( lock.lock() try { val messages = ReplayEnvelopePersistenceCodec.snapshotForPersistence(metadata, replayEnvelope) - val record = PersistedSuspendedInvocationRecordV1( - schemaVersion = 1, - metadata = metadata.toPersistedV1(), - replayEnvelope = PersistedReplayEnvelopeV1( + val persistedEnvelope = + PersistedReplayEnvelopeV1( schemaVersion = 1, messages = messages.map { it.toPersistedV1() }, - ), - ) + ) + val json = + if (runIdentity == null) { + PersistedSuspendedInvocationRecordV1( + schemaVersion = 1, + metadata = metadata.toPersistedV1(), + replayEnvelope = persistedEnvelope, + ).toJson() + } else { + PersistedSuspendedInvocationRecordV2( + schemaVersion = 2, + metadata = metadata.toPersistedV1(), + replayEnvelope = persistedEnvelope, + governedRunIdentity = runIdentity.toPersistedV1(), + ).toJson() + } val path = storePath(metadata.approvalId) val rkd = recordKeyDigest(metadata.approvalId) - val jsonBytes = record.toJson().toByteArray(Charsets.UTF_8) + val jsonBytes = json.toByteArray(Charsets.UTF_8) try { FileStoreUtil.atomicEncryptCreate( path, @@ -209,50 +286,55 @@ class FileSuspendedInvocationStore internal constructor( } } - override suspend fun get(approvalId: String): SuspendedInvocationMetadata? = lease.withOpenOperation { - FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") - validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) - val lock = getLock(approvalId) - lock.lock() - try { - val record = readCurrent(approvalId) ?: return null - return decodeAndValidateRecord(record).metadata - } finally { - lock.unlock() + override suspend fun get(approvalId: String): SuspendedInvocationMetadata? = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + val lock = getLock(approvalId) + lock.lock() + try { + val record = readCurrent(approvalId) ?: return null + return decodeAndValidateRecord(record).metadata + } finally { + lock.unlock() + } } - } - override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? = lease.withOpenOperation { - FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") - validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) - val lock = getLock(approvalId) - lock.lock() - try { - val record = readCurrent(approvalId) ?: return null - return decodeAndValidateRecord(record).envelope - } finally { - lock.unlock() + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + val lock = getLock(approvalId) + lock.lock() + try { + val record = readCurrent(approvalId) ?: return null + return decodeAndValidateRecord(record).envelope + } finally { + lock.unlock() + } } - } - override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? = lease.withOpenOperation { - FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") - validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) - val lock = getLock(approvalId) - lock.lock() - try { - val record = readCurrent(approvalId) ?: return null - val validated = decodeAndValidateRecord(record) - Files.delete(storePath(approvalId)) - FileStoreUtil.forceParentDirectory(suspendedDir) - return validated.metadata - } finally { - lock.unlock() + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? = + lease.withOpenOperation { + FileStoreUtil.validateManagedDirectory(suspendedDir, "suspended") + validateIdField(approvalId, "approvalId", MAX_ID_LENGTH) + val lock = getLock(approvalId) + lock.lock() + try { + val record = readCurrent(approvalId) ?: return null + val validated = decodeAndValidateRecord(record) + Files.delete(storePath(approvalId)) + FileStoreUtil.forceParentDirectory(suspendedDir) + return validated.metadata + } finally { + lock.unlock() + } } - } - private fun validateRecordSchemas(record: PersistedSuspendedInvocationRecordV1) { - requireSchemaVersion(record.schemaVersion, "unsupported-suspended-invocation-schema-version") + private fun validateRecordSchemas(record: DecodedSuspendedInvocationRecord) { + if (record.schemaVersion != 1 && record.schemaVersion != 2) { + throw FileStoreUnsupportedFormatException("unsupported-suspended-invocation-schema-version") + } requireSchemaVersion(record.metadata.schemaVersion, "unsupported-suspended-invocation-metadata-schema-version") requireSchemaVersion(record.metadata.identity.schemaVersion, "unsupported-engine-execution-identity-schema-version") requireSchemaVersion(record.metadata.securityContext.schemaVersion, "unsupported-execution-security-context-schema-version") @@ -278,13 +360,20 @@ class FileSuspendedInvocationStore internal constructor( } } - private fun requireSchemaVersion(version: Int, errorCode: String) { + private fun requireSchemaVersion( + version: Int, + errorCode: String, + ) { if (version != 1) { throw FileStoreUnsupportedFormatException(errorCode) } } - private fun validateIdField(value: String, fieldName: String, maxLength: Int): String { + private fun validateIdField( + value: String, + fieldName: String, + maxLength: Int, + ): String { val trimmed = value.trim() require(trimmed.isNotBlank()) { "$fieldName must not be blank" } require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } diff --git a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/GovernedFileApprovalStore.kt b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/GovernedFileApprovalStore.kt new file mode 100644 index 000000000..ded402ba2 --- /dev/null +++ b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/GovernedFileApprovalStore.kt @@ -0,0 +1,38 @@ +package dev.tramai.persistence.file + +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStore +import dev.tramai.engine.approval.ApprovalRunAttribution +import dev.tramai.engine.approval.GovernedApprovalStore + +/** + * Governed capability for the file-backed approval store (0.7.1d1). + * + * Composition, not a second durable authority: every method forwards to the same encrypted record + * store, so V1/V2 selection, atomicity and schema validation live in exactly one place. The governed + * operations are exposed here rather than by widening [FileApprovalStore] itself, because storing + * attribution does not change what an approval store IS — a store without this wrapper simply has no + * governed capability, and a governed run fails closed against it. + */ +class GovernedFileApprovalStore internal constructor( + private val delegate: FileApprovalStore, +) : ApprovalStore by delegate, + GovernedApprovalStore { + override suspend fun createGovernedApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution.Governed, + ): ApprovalRequest = delegate.createGovernedApproval(request, attribution) + + override suspend fun attributionOf(approvalId: String): ApprovalRunAttribution = delegate.attributionOf(approvalId) +} + +/** Wraps this store with the governed approval capability; see [GovernedFileApprovalStore]. */ +fun ApprovalStore.asGovernedApprovalStore(): GovernedApprovalStore = + when (this) { + is FileApprovalStore -> GovernedFileApprovalStore(this) + + else -> throw IllegalArgumentException( + "Approval store '${this::class.simpleName}' does not support governed attribution; " + + "a governed run must fail closed rather than persist an un-attributed approval", + ) + } diff --git a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/GovernedFileSuspendedInvocationStore.kt b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/GovernedFileSuspendedInvocationStore.kt new file mode 100644 index 000000000..b756c0078 --- /dev/null +++ b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/GovernedFileSuspendedInvocationStore.kt @@ -0,0 +1,35 @@ +package dev.tramai.persistence.file + +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationStore + +/** + * Governed suspension capability for the file-backed store (0.7.1d). + * + * Composition, not a second durable authority: [createGoverned] forwards into the same + * single atomic encrypted create the store already uses, so suspension metadata, the + * replay envelope and the whole [GovernedRunIdentity] are one write to one file. There is + * no interleaving window in which a suspension exists without its attribution, and no + * second file to reconcile. + * + * [FileSuspendedInvocationStore] deliberately does not declare this interface: widening a + * released type changes the analyzer identity it is baselined under, and unmasking + * unrelated legacy debt to satisfy a new feature is not a trade this module should make. + */ +class GovernedFileSuspendedInvocationStore internal constructor( + private val delegate: FileSuspendedInvocationStore, +) : SuspendedInvocationStore by delegate, + GovernedSuspendedInvocationStore { + override suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) = delegate.createGoverned(suspended, replayEnvelope) + + override suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? { + val identity = delegate.governedRunIdentity(approvalId) + return identity + } +} diff --git a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/PersistedDtos.kt b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/PersistedDtos.kt index 30bf7174b..e9da03458 100644 --- a/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/PersistedDtos.kt +++ b/tramai-persistence-file/src/main/kotlin/dev/tramai/persistence/file/PersistedDtos.kt @@ -1,16 +1,27 @@ package dev.tramai.persistence.file import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonProcessingException import dev.tramai.core.approval.ApprovalBinding import dev.tramai.core.approval.ApprovalContinuation import dev.tramai.core.approval.ApprovalContinuationStatus import dev.tramai.core.approval.ApprovalRequest import dev.tramai.core.approval.ApprovalStatus import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId import dev.tramai.core.model.ContentPart import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolCall +import dev.tramai.core.policy.ApprovalMode import dev.tramai.core.policy.AuditDetail import dev.tramai.core.policy.ClassificationSource import dev.tramai.core.policy.CompatibilityMode @@ -18,7 +29,6 @@ import dev.tramai.core.policy.DataClassification import dev.tramai.core.policy.ManagedNetworkEgress import dev.tramai.core.policy.RiskLevel import dev.tramai.core.policy.ToolSecurityMetadata -import dev.tramai.core.policy.ApprovalMode import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext import dev.tramai.engine.ResumeOperationReference @@ -26,6 +36,7 @@ import dev.tramai.engine.ResumeToolReference import dev.tramai.engine.SensitiveReplayEnvelope import dev.tramai.engine.SuspendedInvocationMetadata import dev.tramai.engine.TokenBudgetSnapshot +import dev.tramai.engine.approval.ApprovalAttributionKeys import dev.tramai.security.audit.AuditEvent import dev.tramai.security.audit.AuditHashAlgorithm import java.time.Instant @@ -81,6 +92,72 @@ data class PersistedApprovalRequestV1( } } +/** + * Persisted governed approval attribution (approval V2). + * + * The five identity components as flat fields, so a missing one fails strict decoding rather than + * silently defaulting. The workflow run id is deliberately ABSENT: for an approval, + * `ApprovalBinding.workflowRunId` is the canonical run id, so persisting it here would create a + * second source that could diverge from the run it describes. Decoding reconstructs and validates + * the full identity against that canonical run id. + */ +data class PersistedApprovalAttributionV1( + @get:JsonProperty("schemaVersion") val schemaVersion: Int, + @param:JsonProperty("workloadId") val workloadId: String, + @param:JsonProperty("configurationId") val configurationId: String, + @param:JsonProperty("configurationVersion") val configurationVersion: String, + @param:JsonProperty("environmentId") val environmentId: String, + @param:JsonProperty("deploymentId") val deploymentId: String, +) { + fun toJson(): String = FILE_STORE_JSON.writeValueAsString(this) + + /** + * The shared reserved-key vocabulary, so the file store validates through exactly the same + * codec path as JDBC rather than reinterpreting attribution locally. + */ + fun toReservedKeys(): Map = + mapOf( + ApprovalAttributionKeys.WORKLOAD to workloadId, + ApprovalAttributionKeys.CONFIGURATION to configurationId, + ApprovalAttributionKeys.CONFIGURATION_VERSION to configurationVersion, + ApprovalAttributionKeys.ENVIRONMENT to environmentId, + ApprovalAttributionKeys.DEPLOYMENT to deploymentId, + ) + + companion object { + fun fromJson(json: String): PersistedApprovalAttributionV1 = strictReadValue(json) + + fun from(identity: GovernedRunIdentity): PersistedApprovalAttributionV1 = + PersistedApprovalAttributionV1( + schemaVersion = 1, + workloadId = identity.deployment.workloadId.value, + configurationId = identity.deployment.configuration.id.value, + configurationVersion = identity.deployment.configuration.version.value, + environmentId = identity.deployment.environmentId.value, + deploymentId = identity.deployment.deploymentId.value, + ) + } +} + +/** + * V2 approval record: the V1 request payload plus governed attribution, persisted as ONE durable + * record in a single atomic encrypted write. + * + * V1 means un-attributed (legacy); V2 means governed. There is no third version, and the two are + * never conflated: a malformed V2 record fails decoding instead of being reinterpreted as V1. + */ +data class PersistedApprovalRequestV2( + @get:JsonProperty("schemaVersion") val schemaVersion: Int, + @param:JsonProperty("request") val request: PersistedApprovalRequestV1, + @param:JsonProperty("attribution") val attribution: PersistedApprovalAttributionV1, +) { + fun toJson(): String = FILE_STORE_JSON.writeValueAsString(this) + + companion object { + fun fromJson(json: String): PersistedApprovalRequestV2 = strictReadValue(json) + } +} + // ==================================================================== // Continuation Store DTOs // ==================================================================== @@ -175,84 +252,89 @@ data class PersistedAuditEventV1( // Domain conversions — ApprovalBinding // ==================================================================== -fun PersistedApprovalBindingV1.toDomain(): ApprovalBinding = ApprovalBinding( - workflowRunId = workflowRunId, - toolName = toolName, - argumentsDigest = Sha256Digest.of(argumentsDigest), - policyVersion = policyVersion, - workflowDigest = Sha256Digest.of(workflowDigest), - approvalTokenDigest = Sha256Digest.of(approvalTokenDigest), -) +fun PersistedApprovalBindingV1.toDomain(): ApprovalBinding = + ApprovalBinding( + workflowRunId = workflowRunId, + toolName = toolName, + argumentsDigest = Sha256Digest.of(argumentsDigest), + policyVersion = policyVersion, + workflowDigest = Sha256Digest.of(workflowDigest), + approvalTokenDigest = Sha256Digest.of(approvalTokenDigest), + ) -fun ApprovalBinding.toPersistedV1(): PersistedApprovalBindingV1 = PersistedApprovalBindingV1( - schemaVersion = 1, - workflowRunId = workflowRunId, - toolName = toolName, - argumentsDigest = argumentsDigest.value, - policyVersion = policyVersion, - workflowDigest = workflowDigest.value, - approvalTokenDigest = approvalTokenDigest.value, -) +fun ApprovalBinding.toPersistedV1(): PersistedApprovalBindingV1 = + PersistedApprovalBindingV1( + schemaVersion = 1, + workflowRunId = workflowRunId, + toolName = toolName, + argumentsDigest = argumentsDigest.value, + policyVersion = policyVersion, + workflowDigest = workflowDigest.value, + approvalTokenDigest = approvalTokenDigest.value, + ) // ==================================================================== // Domain conversions — ApprovalRequest // ==================================================================== -fun PersistedApprovalRequestV1.toDomain(): ApprovalRequest = ApprovalRequest( - approvalId = approvalId, - binding = binding.toDomain(), - status = ApprovalStatus.valueOf(status), - requestedBy = requestedBy, - requestedAt = Instant.parse(requestedAt), - expiresAt = Instant.parse(expiresAt), - decidedBy = decidedBy, - decidedAt = decidedAt?.let { Instant.parse(it) }, - decisionComment = decisionComment, - consumedBy = consumedBy, - consumedAt = consumedAt?.let { Instant.parse(it) }, - version = version, -) +fun PersistedApprovalRequestV1.toDomain(): ApprovalRequest = + ApprovalRequest( + approvalId = approvalId, + binding = binding.toDomain(), + status = ApprovalStatus.valueOf(status), + requestedBy = requestedBy, + requestedAt = Instant.parse(requestedAt), + expiresAt = Instant.parse(expiresAt), + decidedBy = decidedBy, + decidedAt = decidedAt?.let { Instant.parse(it) }, + decisionComment = decisionComment, + consumedBy = consumedBy, + consumedAt = consumedAt?.let { Instant.parse(it) }, + version = version, + ) -fun ApprovalRequest.toPersistedV1(): PersistedApprovalRequestV1 = PersistedApprovalRequestV1( - schemaVersion = 1, - approvalId = approvalId, - binding = binding.toPersistedV1(), - status = status.name, - requestedBy = requestedBy, - requestedAt = requestedAt.toString(), - expiresAt = expiresAt.toString(), - decidedBy = decidedBy, - decidedAt = decidedAt?.toString(), - decisionComment = decisionComment, - consumedBy = consumedBy, - consumedAt = consumedAt?.toString(), - version = version, -) +fun ApprovalRequest.toPersistedV1(): PersistedApprovalRequestV1 = + PersistedApprovalRequestV1( + schemaVersion = 1, + approvalId = approvalId, + binding = binding.toPersistedV1(), + status = status.name, + requestedBy = requestedBy, + requestedAt = requestedAt.toString(), + expiresAt = expiresAt.toString(), + decidedBy = decidedBy, + decidedAt = decidedAt?.toString(), + decisionComment = decisionComment, + consumedBy = consumedBy, + consumedAt = consumedAt?.toString(), + version = version, + ) // ==================================================================== // Domain conversions — ApprovalContinuation // ==================================================================== -fun PersistedApprovalContinuationV1.toDomain(): ApprovalContinuation = ApprovalContinuation( - approvalId = approvalId, - workflowRunId = workflowRunId, - correlationId = correlationId, - toolCallId = toolCallId, - toolName = toolName, - argumentsDigest = Sha256Digest.of(argumentsDigest), - policyVersion = policyVersion, - workflowDigest = Sha256Digest.of(workflowDigest), - status = ApprovalContinuationStatus.valueOf(status), - createdAt = Instant.parse(createdAt), - approvalExpiresAt = Instant.parse(approvalExpiresAt), - claimedBy = claimedBy, - claimedAt = claimedAt?.let { Instant.parse(it) }, - completedAt = completedAt?.let { Instant.parse(it) }, - recoveryResolvedBy = recoveryResolvedBy, - recoveryResolvedAt = recoveryResolvedAt?.let { Instant.parse(it) }, - recoveryReasonCode = recoveryReasonCode, - version = version, -) +fun PersistedApprovalContinuationV1.toDomain(): ApprovalContinuation = + ApprovalContinuation( + approvalId = approvalId, + workflowRunId = workflowRunId, + correlationId = correlationId, + toolCallId = toolCallId, + toolName = toolName, + argumentsDigest = Sha256Digest.of(argumentsDigest), + policyVersion = policyVersion, + workflowDigest = Sha256Digest.of(workflowDigest), + status = ApprovalContinuationStatus.valueOf(status), + createdAt = Instant.parse(createdAt), + approvalExpiresAt = Instant.parse(approvalExpiresAt), + claimedBy = claimedBy, + claimedAt = claimedAt?.let { Instant.parse(it) }, + completedAt = completedAt?.let { Instant.parse(it) }, + recoveryResolvedBy = recoveryResolvedBy, + recoveryResolvedAt = recoveryResolvedAt?.let { Instant.parse(it) }, + recoveryReasonCode = recoveryReasonCode, + version = version, + ) fun ApprovalContinuation.toPersistedV1(): PersistedApprovalContinuationV1 = PersistedApprovalContinuationV1( @@ -280,54 +362,56 @@ fun ApprovalContinuation.toPersistedV1(): PersistedApprovalContinuationV1 = /** * Extracts the [ApprovalContinuation] metadata from this record. */ -fun PersistedApprovalContinuationRecordV1.toDomain(): ApprovalContinuation = - continuation.toDomain() +fun PersistedApprovalContinuationRecordV1.toDomain(): ApprovalContinuation = continuation.toDomain() // ==================================================================== // Domain conversions — AuditEvent // ==================================================================== -fun PersistedAuditEventV1.toDomain(): AuditEvent = AuditEvent( - schemaVersion = schemaVersion, - hashAlgorithm = AuditHashAlgorithm.valueOf( - AuditHashAlgorithm.entries.first { it.wireName == hashAlgorithm }.name, - ), - auditStreamId = auditStreamId, - eventId = eventId, - sequenceNumber = sequenceNumber, - workflowRunId = workflowRunId, - correlationId = correlationId, - actor = actor, - enforcementPoint = enforcementPoint, - decision = decision, - policyVersion = policyVersion, - workflowDigest = workflowDigest, - previousEventHash = previousEventHash, - eventHash = eventHash, - timestamp = Instant.parse(timestamp), - reasonCode = reasonCode, - metadata = metadata, -) +fun PersistedAuditEventV1.toDomain(): AuditEvent = + AuditEvent( + schemaVersion = schemaVersion, + hashAlgorithm = + AuditHashAlgorithm.valueOf( + AuditHashAlgorithm.entries.first { it.wireName == hashAlgorithm }.name, + ), + auditStreamId = auditStreamId, + eventId = eventId, + sequenceNumber = sequenceNumber, + workflowRunId = workflowRunId, + correlationId = correlationId, + actor = actor, + enforcementPoint = enforcementPoint, + decision = decision, + policyVersion = policyVersion, + workflowDigest = workflowDigest, + previousEventHash = previousEventHash, + eventHash = eventHash, + timestamp = Instant.parse(timestamp), + reasonCode = reasonCode, + metadata = metadata, + ) -fun AuditEvent.toPersistedV1(): PersistedAuditEventV1 = PersistedAuditEventV1( - schemaVersion = schemaVersion, - hashAlgorithm = hashAlgorithm.wireName, - auditStreamId = auditStreamId, - eventId = eventId, - sequenceNumber = sequenceNumber, - workflowRunId = workflowRunId, - correlationId = correlationId, - actor = actor, - enforcementPoint = enforcementPoint, - decision = decision, - policyVersion = policyVersion, - workflowDigest = workflowDigest, - previousEventHash = previousEventHash, - eventHash = eventHash, - timestamp = timestamp.toString(), - reasonCode = reasonCode, - metadata = metadata, -) +fun AuditEvent.toPersistedV1(): PersistedAuditEventV1 = + PersistedAuditEventV1( + schemaVersion = schemaVersion, + hashAlgorithm = hashAlgorithm.wireName, + auditStreamId = auditStreamId, + eventId = eventId, + sequenceNumber = sequenceNumber, + workflowRunId = workflowRunId, + correlationId = correlationId, + actor = actor, + enforcementPoint = enforcementPoint, + decision = decision, + policyVersion = policyVersion, + workflowDigest = workflowDigest, + previousEventHash = previousEventHash, + eventHash = eventHash, + timestamp = timestamp.toString(), + reasonCode = reasonCode, + metadata = metadata, + ) // ==================================================================== // Suspended Invocation DTOs @@ -345,6 +429,154 @@ data class PersistedSuspendedInvocationRecordV1( } } +/** + * GOVERNED suspension record (0.7.1d): the V1 record plus the canonical run identity of the + * execution that created it. + * + * Written as ONE encrypted record through the same single atomic create as V1 — no second + * file, so a crash can never leave a governed suspension with partial attribution. + * + * V1 vs V2 is semantic, not merely serialization versioning: + * - V1 = no governed attribution was persisted (legacy, stays legacy forever) + * - V2 = governed attribution was persisted and is MANDATORY + * + * A V2 record that is missing an identity component is therefore corruption, never a legacy + * fallback, and a malformed V2 must never be decoded as V1. + */ +data class PersistedSuspendedInvocationRecordV2( + @get:JsonProperty("schemaVersion") val schemaVersion: Int, + @param:JsonProperty("metadata") val metadata: PersistedSuspendedInvocationMetadataV1, + @param:JsonProperty("replayEnvelope") val replayEnvelope: PersistedReplayEnvelopeV1, + @param:JsonProperty("governedRunIdentity") val governedRunIdentity: PersistedGovernedRunIdentityV1, +) { + fun toJson(): String = FILE_STORE_JSON.writeValueAsString(this) + + companion object { + fun fromJson(json: String): PersistedSuspendedInvocationRecordV2 = strictReadValue(json) + } +} + +/** + * Persisted canonical run identity: the five components as flat fields, so a missing + * component fails strict decoding rather than silently defaulting. + */ +data class PersistedGovernedRunIdentityV1( + @get:JsonProperty("schemaVersion") val schemaVersion: Int, + @param:JsonProperty("workloadId") val workloadId: String, + @param:JsonProperty("configurationId") val configurationId: String, + @param:JsonProperty("configurationVersion") val configurationVersion: String, + @param:JsonProperty("environmentId") val environmentId: String, + @param:JsonProperty("deploymentId") val deploymentId: String, + @param:JsonProperty("runId") val runId: String, +) + +/** + * A decoded suspended invocation record of either schema version. [governedRunIdentity] is + * null exactly when the record is a V1 (legacy) record. + */ +internal data class DecodedSuspendedInvocationRecord( + val schemaVersion: Int, + val metadata: PersistedSuspendedInvocationMetadataV1, + val replayEnvelope: PersistedReplayEnvelopeV1, + val governedRunIdentity: GovernedRunIdentity?, +) + +private const val GOVERNED_SUSPENDED_INVOCATION_CORRUPTED = "suspended-invocation-record-corrupted" +private const val GOVERNED_SUSPENDED_INVOCATION_UNSUPPORTED = "unsupported-suspended-invocation-schema-version" + +/** + * Decodes a suspended invocation record by OUTER schema version: 1 legacy, 2 governed, + * anything else unsupported. A V2 that fails to decode (missing or malformed identity) is + * corruption — it is never retried as V1, because the version already told us the record + * claims to be governed. + */ +internal fun decodeSuspendedInvocationRecord(json: String): DecodedSuspendedInvocationRecord { + val version = decodeSuspendedInvocationSchemaVersion(json) + return when (version) { + 1 -> { + decodeLegacySuspendedInvocationRecord(json) + } + + 2 -> { + decodeGovernedSuspendedInvocationRecord(json) + } + + else -> { + throw FileStoreUnsupportedFormatException(GOVERNED_SUSPENDED_INVOCATION_UNSUPPORTED) + } + } +} + +/** + * Declared schema version of a suspended-invocation payload. + * + * Only an integral, in-range JSON number is a schema declaration: anything else — missing, null, + * text, float, out of int range — is corruption. `asInt()` coerces instead (`"2"` and `2.9` to 2, + * `"abc"` to 0), so a damaged payload could decode as a valid version or be reported as an + * unsupported one instead of as corruption. + */ +private fun decodeSuspendedInvocationSchemaVersion(json: String): Int = + try { + val version = FILE_STORE_JSON.readTree(json)?.get("schemaVersion") + version?.takeIf { it.isIntegralNumber && it.canConvertToInt() }?.intValue() + ?: throw FileStoreCorruptionException(GOVERNED_SUSPENDED_INVOCATION_CORRUPTED) + } catch (e: FileStoreCorruptionException) { + throw e + } catch (e: JsonProcessingException) { + throw FileStoreCorruptionException(GOVERNED_SUSPENDED_INVOCATION_CORRUPTED, e) + } + +private fun decodeLegacySuspendedInvocationRecord(json: String): DecodedSuspendedInvocationRecord { + val record = + try { + PersistedSuspendedInvocationRecordV1.fromJson(json) + } catch (e: IllegalArgumentException) { + throw FileStoreCorruptionException(GOVERNED_SUSPENDED_INVOCATION_CORRUPTED, e) + } + return DecodedSuspendedInvocationRecord(1, record.metadata, record.replayEnvelope, null) +} + +private fun decodeGovernedSuspendedInvocationRecord(json: String): DecodedSuspendedInvocationRecord { + val record = + try { + PersistedSuspendedInvocationRecordV2.fromJson(json) + } catch (e: IllegalArgumentException) { + throw FileStoreCorruptionException(GOVERNED_SUSPENDED_INVOCATION_CORRUPTED, e) + } + val identity = record.governedRunIdentity.toDomain() + if (identity.runId.value != record.metadata.identity.workflowRunId) { + throw FileStoreCorruptionException("suspended-invocation-governed-identity-mismatch") + } + return DecodedSuspendedInvocationRecord(2, record.metadata, record.replayEnvelope, identity) +} + +internal fun PersistedGovernedRunIdentityV1.toDomain(): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configurationId), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId(environmentId), + deploymentId = DeploymentId(deploymentId), + ), + runId = RunId(runId), + ) + +internal fun GovernedRunIdentity.toPersistedV1(): PersistedGovernedRunIdentityV1 = + PersistedGovernedRunIdentityV1( + schemaVersion = 1, + workloadId = deployment.workloadId.value, + configurationId = deployment.configuration.id.value, + configurationVersion = deployment.configuration.version.value, + environmentId = deployment.environmentId.value, + deploymentId = deployment.deploymentId.value, + runId = runId.value, + ) + data class PersistedSuspendedInvocationMetadataV1( @get:JsonProperty("schemaVersion") val schemaVersion: Int, @JsonProperty("approvalId") val approvalId: String, @@ -548,13 +780,14 @@ internal fun SuspendedInvocationMetadata.toPersistedV1(): PersistedSuspendedInvo toolSecurity = toolSecurity?.toPersistedV1(), ) -internal fun PersistedEngineExecutionIdentityV1.toDomain(): EngineExecutionIdentity = EngineExecutionIdentity( - workflowRunId = workflowRunId, - correlationId = correlationId, - workflowDigest = Sha256Digest.of(workflowDigest), - policyVersion = policyVersion, - actorId = actorId, -) +internal fun PersistedEngineExecutionIdentityV1.toDomain(): EngineExecutionIdentity = + EngineExecutionIdentity( + workflowRunId = workflowRunId, + correlationId = correlationId, + workflowDigest = Sha256Digest.of(workflowDigest), + policyVersion = policyVersion, + actorId = actorId, + ) internal fun EngineExecutionIdentity.toPersistedV1(): PersistedEngineExecutionIdentityV1 = PersistedEngineExecutionIdentityV1( @@ -566,10 +799,11 @@ internal fun EngineExecutionIdentity.toPersistedV1(): PersistedEngineExecutionId actorId = actorId, ) -internal fun PersistedExecutionSecurityContextV1.toDomain(): ExecutionSecurityContext = ExecutionSecurityContext( - dataClassification = dataClassification?.let(DataClassification::valueOf), - classificationSource = classificationSource?.let(ClassificationSource::valueOf), -) +internal fun PersistedExecutionSecurityContextV1.toDomain(): ExecutionSecurityContext = + ExecutionSecurityContext( + dataClassification = dataClassification?.let(DataClassification::valueOf), + classificationSource = classificationSource?.let(ClassificationSource::valueOf), + ) internal fun ExecutionSecurityContext.toPersistedV1(): PersistedExecutionSecurityContextV1 = PersistedExecutionSecurityContextV1( @@ -578,12 +812,13 @@ internal fun ExecutionSecurityContext.toPersistedV1(): PersistedExecutionSecurit classificationSource = classificationSource?.name, ) -internal fun PersistedResumeOperationReferenceV1.toDomain(): ResumeOperationReference = ResumeOperationReference( - serviceInterface = serviceInterface, - methodName = methodName, - jvmMethodDescriptor = jvmMethodDescriptor, - resumeDefinitionDigest = Sha256Digest.of(resumeDefinitionDigest), -) +internal fun PersistedResumeOperationReferenceV1.toDomain(): ResumeOperationReference = + ResumeOperationReference( + serviceInterface = serviceInterface, + methodName = methodName, + jvmMethodDescriptor = jvmMethodDescriptor, + resumeDefinitionDigest = Sha256Digest.of(resumeDefinitionDigest), + ) internal fun ResumeOperationReference.toPersistedV1(): PersistedResumeOperationReferenceV1 = PersistedResumeOperationReferenceV1( @@ -594,10 +829,11 @@ internal fun ResumeOperationReference.toPersistedV1(): PersistedResumeOperationR resumeDefinitionDigest = resumeDefinitionDigest.value, ) -internal fun PersistedResumeToolReferenceV1.toDomain(): ResumeToolReference = ResumeToolReference( - toolName = toolName, - declarationDigest = Sha256Digest.of(declarationDigest), -) +internal fun PersistedResumeToolReferenceV1.toDomain(): ResumeToolReference = + ResumeToolReference( + toolName = toolName, + declarationDigest = Sha256Digest.of(declarationDigest), + ) internal fun ResumeToolReference.toPersistedV1(): PersistedResumeToolReferenceV1 = PersistedResumeToolReferenceV1( @@ -606,13 +842,14 @@ internal fun ResumeToolReference.toPersistedV1(): PersistedResumeToolReferenceV1 declarationDigest = declarationDigest.value, ) -internal fun PersistedTokenBudgetSnapshotV1.toDomain(): TokenBudgetSnapshot = TokenBudgetSnapshot( - totalInputTokens = totalInputTokens, - totalOutputTokens = totalOutputTokens, - totalInputCost = totalInputCost, - totalOutputCost = totalOutputCost, - warnIfExceeded = warnIfExceeded, -) +internal fun PersistedTokenBudgetSnapshotV1.toDomain(): TokenBudgetSnapshot = + TokenBudgetSnapshot( + totalInputTokens = totalInputTokens, + totalOutputTokens = totalOutputTokens, + totalInputCost = totalInputCost, + totalOutputCost = totalOutputCost, + warnIfExceeded = warnIfExceeded, + ) internal fun TokenBudgetSnapshot.toPersistedV1(): PersistedTokenBudgetSnapshotV1 = PersistedTokenBudgetSnapshotV1( @@ -624,14 +861,15 @@ internal fun TokenBudgetSnapshot.toPersistedV1(): PersistedTokenBudgetSnapshotV1 warnIfExceeded = warnIfExceeded, ) -internal fun PersistedToolSecurityMetadataV1.toDomain(): ToolSecurityMetadata = ToolSecurityMetadata( - permission = permission, - risk = RiskLevel.valueOf(risk), - approval = ApprovalMode.valueOf(approval), - managedNetworkEgress = ManagedNetworkEgress.valueOf(managedNetworkEgress), - audit = AuditDetail.valueOf(audit), - compatibilityMode = CompatibilityMode.valueOf(compatibilityMode), -) +internal fun PersistedToolSecurityMetadataV1.toDomain(): ToolSecurityMetadata = + ToolSecurityMetadata( + permission = permission, + risk = RiskLevel.valueOf(risk), + approval = ApprovalMode.valueOf(approval), + managedNetworkEgress = ManagedNetworkEgress.valueOf(managedNetworkEgress), + audit = AuditDetail.valueOf(audit), + compatibilityMode = CompatibilityMode.valueOf(compatibilityMode), + ) internal fun ToolSecurityMetadata.toPersistedV1(): PersistedToolSecurityMetadataV1 = PersistedToolSecurityMetadataV1( @@ -644,79 +882,109 @@ internal fun ToolSecurityMetadata.toPersistedV1(): PersistedToolSecurityMetadata compatibilityMode = compatibilityMode.name, ) -internal fun PersistedReplayEnvelopeV1.toDomain(): SensitiveReplayEnvelope = - SensitiveReplayEnvelope.of(messages.map { it.toDomain() }) - -internal fun PersistedMessageV1.toDomain(): Message = Message( - role = MessageRole.valueOf(role), - content = content, - contentParts = contentParts?.map { it.toDomain() }, - toolCallId = toolCallId, - toolCalls = toolCalls?.map { it.toDomain() }, -) - -internal fun Message.toPersistedV1(): PersistedMessageV1 = PersistedMessageV1( - schemaVersion = 1, - role = role.name, - content = content, - contentParts = contentParts?.map { it.toPersistedV1() }, - toolCallId = toolCallId, - toolCalls = toolCalls?.map { it.toPersistedV1() }, -) - -internal fun PersistedToolCallV1.toDomain(): ToolCall = ToolCall( - id = id, - name = name, - argumentsJson = argumentsJson, -) - -internal fun ToolCall.toPersistedV1(): PersistedToolCallV1 = PersistedToolCallV1( - schemaVersion = 1, - id = id, - name = name, - argumentsJson = argumentsJson, -) +internal fun PersistedReplayEnvelopeV1.toDomain(): SensitiveReplayEnvelope { + val domainMessages = messages.map { it.toDomain() } + return SensitiveReplayEnvelope.of(domainMessages) +} -internal fun PersistedContentPartV1.toDomain(): ContentPart = when (type) { - "text" -> ContentPart.TextPart( - text = requireNotNull(text) { "persisted-content-part-text-missing" }, - ) - "image" -> ContentPart.ImagePart( - mimeType = requireNotNull(mimeType) { "persisted-content-part-image-mime-missing" }, - data = Base64.getDecoder().decode( - requireNotNull(dataBase64) { "persisted-content-part-image-data-missing" }, - ).copyOf(), - ) - "image_url" -> ContentPart.ImageUrlContent( - url = requireNotNull(url) { "persisted-content-part-image-url-missing" }, - mimeType = mimeType, +internal fun PersistedMessageV1.toDomain(): Message = + Message( + role = MessageRole.valueOf(role), + content = content, + contentParts = contentParts?.map { it.toDomain() }, + toolCallId = toolCallId, + toolCalls = toolCalls?.map { it.toDomain() }, ) - else -> error("persisted-content-part-type-unsupported") -} -internal fun ContentPart.toPersistedV1(): PersistedContentPartV1 = when (this) { - is ContentPart.TextPart -> PersistedContentPartV1( +internal fun Message.toPersistedV1(): PersistedMessageV1 = + PersistedMessageV1( schemaVersion = 1, - type = "text", - text = text, - mimeType = null, - dataBase64 = null, - url = null, + role = role.name, + content = content, + contentParts = contentParts?.map { it.toPersistedV1() }, + toolCallId = toolCallId, + toolCalls = toolCalls?.map { it.toPersistedV1() }, ) - is ContentPart.ImagePart -> PersistedContentPartV1( - schemaVersion = 1, - type = "image", - text = null, - mimeType = mimeType, - dataBase64 = Base64.getEncoder().encodeToString(data), - url = null, + +internal fun PersistedToolCallV1.toDomain(): ToolCall = + ToolCall( + id = id, + name = name, + argumentsJson = argumentsJson, ) - is ContentPart.ImageUrlContent -> PersistedContentPartV1( + +internal fun ToolCall.toPersistedV1(): PersistedToolCallV1 = + PersistedToolCallV1( schemaVersion = 1, - type = "image_url", - text = null, - mimeType = mimeType, - dataBase64 = null, - url = url, + id = id, + name = name, + argumentsJson = argumentsJson, ) -} + +internal fun PersistedContentPartV1.toDomain(): ContentPart = + when (type) { + "text" -> { + ContentPart.TextPart( + text = requireNotNull(text) { "persisted-content-part-text-missing" }, + ) + } + + "image" -> { + ContentPart.ImagePart( + mimeType = requireNotNull(mimeType) { "persisted-content-part-image-mime-missing" }, + data = + Base64 + .getDecoder() + .decode( + requireNotNull(dataBase64) { "persisted-content-part-image-data-missing" }, + ).copyOf(), + ) + } + + "image_url" -> { + ContentPart.ImageUrlContent( + url = requireNotNull(url) { "persisted-content-part-image-url-missing" }, + mimeType = mimeType, + ) + } + + else -> { + error("persisted-content-part-type-unsupported") + } + } + +internal fun ContentPart.toPersistedV1(): PersistedContentPartV1 = + when (this) { + is ContentPart.TextPart -> { + PersistedContentPartV1( + schemaVersion = 1, + type = "text", + text = text, + mimeType = null, + dataBase64 = null, + url = null, + ) + } + + is ContentPart.ImagePart -> { + PersistedContentPartV1( + schemaVersion = 1, + type = "image", + text = null, + mimeType = mimeType, + dataBase64 = Base64.getEncoder().encodeToString(data), + url = null, + ) + } + + is ContentPart.ImageUrlContent -> { + PersistedContentPartV1( + schemaVersion = 1, + type = "image_url", + text = null, + mimeType = mimeType, + dataBase64 = null, + url = url, + ) + } + } diff --git a/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileBackedSovereignStoresTest.kt b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileBackedSovereignStoresTest.kt index d40fc10e7..478e71f3c 100644 --- a/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileBackedSovereignStoresTest.kt +++ b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileBackedSovereignStoresTest.kt @@ -14,6 +14,7 @@ import dev.tramai.core.policy.RiskLevel import dev.tramai.core.policy.ToolSecurityMetadata import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocationStore import dev.tramai.engine.ResumeOperationReference import dev.tramai.engine.ResumeToolReference import dev.tramai.engine.SensitiveReplayEnvelope @@ -29,29 +30,34 @@ import java.nio.file.attribute.PosixFilePermissions import java.util.Base64 import javax.crypto.KeyGenerator import javax.crypto.SecretKey -import kotlin.io.path.* +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.readText import kotlin.test.assertNotNull import kotlin.test.assertTrue class FileBackedSovereignStoresTest { - private val rootDir: Path = Files.createTempDirectory("tramai-bundle-test-").toAbsolutePath() - private fun testKey(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun testKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private val testKey: SecretKey by lazy { testKey() } private val keyProvider = FileStoreEncryptionKeyProvider { testKey } - private fun createConfig(root: Path = rootDir) = FileBackedStoreConfiguration( - rootDirectory = root, - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "test-key", - keyProvider = keyProvider, - ), - verifyOnOpen = false, - ) + private fun createConfig(root: Path = rootDir) = + FileBackedStoreConfiguration( + rootDirectory = root, + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "test-key", + keyProvider = keyProvider, + ), + verifyOnOpen = false, + ) @AfterEach fun cleanup() { @@ -169,7 +175,7 @@ class FileBackedSovereignStoresTest { assertTrue(stores.approvalStore is FileApprovalStore) assertTrue(stores.approvalContinuationStore is FileApprovalContinuationStore) assertTrue(stores.auditStore is FileAuditStore) - assertTrue(stores.suspendedInvocationStore is FileSuspendedInvocationStore) + assertTrue(stores.suspendedInvocationStore is GovernedSuspendedInvocationStore) } finally { stores.close() } @@ -192,14 +198,16 @@ class FileBackedSovereignStoresTest { @Test fun `open with verifyOnOpen succeeds on empty directory`() { - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("verify-empty"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "test-key", - keyProvider = keyProvider, - ), - verifyOnOpen = true, - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("verify-empty"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "test-key", + keyProvider = keyProvider, + ), + verifyOnOpen = true, + ) val stores = FileBackedSovereignStores.open(config) stores.close() } @@ -213,76 +221,85 @@ class FileBackedSovereignStoresTest { } @Test - fun `corrupted suspended record fails startup verification`() { runBlocking { - val config = createConfig() - val approvalId = "bundle-suspended-corrupt-1" - - FileBackedSovereignStores.open(config).use { stores -> - val (metadata, envelope) = createValidSuspendedRecord(approvalId) - stores.suspendedInvocationStore.create(metadata, envelope) - } + fun `corrupted suspended record fails startup verification`() { + runBlocking { + val config = createConfig() + val approvalId = "bundle-suspended-corrupt-1" + + FileBackedSovereignStores.open(config).use { stores -> + val (metadata, envelope) = createValidSuspendedRecord(approvalId) + stores.suspendedInvocationStore.create(metadata, envelope) + } - val path = suspendedRecordPath(approvalId) - val encrypted = EncryptedFileEnvelopeV1.fromJson(path.readText()) - // Flip one byte of the ciphertext+tag so the corruption is guaranteed to - // change the decrypted content. String-level mutations are unreliable: - // depending on the trailing Base64 padding, some character replacements - // decode to the same significant bits (e.g. 'X' -> 'Y' under '==' padding - // keeps the top 2 bits), making the corruption a silent no-op. A byte - // flip always changes the decoded ciphertext, so GCM auth always fails. - val corruptedCiphertext = Base64.getDecoder().decode(encrypted.ciphertextBase64) - .also { it[0] = (it[0].toInt() xor 0x01).toByte() } - Files.writeString( - path, - encrypted.copy( - ciphertextBase64 = Base64.getEncoder().encodeToString(corruptedCiphertext), - ).toJson(), - ) + val path = suspendedRecordPath(approvalId) + val encrypted = EncryptedFileEnvelopeV1.fromJson(path.readText()) + // Flip one byte of the ciphertext+tag so the corruption is guaranteed to + // change the decrypted content. String-level mutations are unreliable: + // depending on the trailing Base64 padding, some character replacements + // decode to the same significant bits (e.g. 'X' -> 'Y' under '==' padding + // keeps the top 2 bits), making the corruption a silent no-op. A byte + // flip always changes the decoded ciphertext, so GCM auth always fails. + val corruptedCiphertext = + Base64 + .getDecoder() + .decode(encrypted.ciphertextBase64) + .also { it[0] = (it[0].toInt() xor 0x01).toByte() } + Files.writeString( + path, + encrypted + .copy( + ciphertextBase64 = Base64.getEncoder().encodeToString(corruptedCiphertext), + ).toJson(), + ) - assertThrows { - FileBackedSovereignStores.open( - FileBackedStoreConfiguration( - rootDirectory = rootDir, - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "test-key", - keyProvider = keyProvider, + assertThrows { + FileBackedSovereignStores.open( + FileBackedStoreConfiguration( + rootDirectory = rootDir, + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "test-key", + keyProvider = keyProvider, + ), + verifyOnOpen = true, ), - verifyOnOpen = true, - ), - ) + ) + } } } - } @Test - fun `operations after close are rejected on suspended store`() { runBlocking { - val stores = FileBackedSovereignStores.open(createConfig()) - val suspendedStore = stores.suspendedInvocationStore - stores.close() + fun `operations after close are rejected on suspended store`() { + runBlocking { + val stores = FileBackedSovereignStores.open(createConfig()) + val suspendedStore = stores.suspendedInvocationStore + stores.close() - assertThrows { - runBlocking { suspendedStore.get("approval-closed") } - } - assertThrows { - runBlocking { suspendedStore.revealReplayEnvelope("approval-closed") } - } - assertThrows { - runBlocking { suspendedStore.remove("approval-closed") } + assertThrows { + runBlocking { suspendedStore.get("approval-closed") } + } + assertThrows { + runBlocking { suspendedStore.revealReplayEnvelope("approval-closed") } + } + assertThrows { + runBlocking { suspendedStore.remove("approval-closed") } + } } } - } // ── activeKeyId validation ────────────────────────────────────────── @Test fun `open rejects blank activeKeyId`() { - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("blank-keyid"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "", - keyProvider = keyProvider, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("blank-keyid"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "", + keyProvider = keyProvider, + ), + ) assertThrows { FileBackedSovereignStores.open(config) } @@ -290,13 +307,15 @@ class FileBackedSovereignStoresTest { @Test fun `open rejects whitespace-only activeKeyId`() { - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("ws-keyid"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = " ", - keyProvider = keyProvider, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("ws-keyid"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = " ", + keyProvider = keyProvider, + ), + ) assertThrows { FileBackedSovereignStores.open(config) } @@ -304,13 +323,15 @@ class FileBackedSovereignStoresTest { @Test fun `open rejects overly long activeKeyId`() { - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("long-keyid"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "x".repeat(129), - keyProvider = keyProvider, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("long-keyid"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "x".repeat(129), + keyProvider = keyProvider, + ), + ) assertThrows { FileBackedSovereignStores.open(config) } @@ -318,13 +339,15 @@ class FileBackedSovereignStoresTest { @Test fun `open rejects activeKeyId with unsafe pattern`() { - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("unsafe-keyid"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "-starts-with-hyphen", - keyProvider = keyProvider, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("unsafe-keyid"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "-starts-with-hyphen", + keyProvider = keyProvider, + ), + ) assertThrows { FileBackedSovereignStores.open(config) } @@ -332,13 +355,15 @@ class FileBackedSovereignStoresTest { @Test fun `open rejects activeKeyId containing whitespace`() { - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("space-keyid"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "my key", - keyProvider = keyProvider, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("space-keyid"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "my key", + keyProvider = keyProvider, + ), + ) assertThrows { FileBackedSovereignStores.open(config) } @@ -346,13 +371,15 @@ class FileBackedSovereignStoresTest { @Test fun `open accepts valid activeKeyId with allowed special characters`() { - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("valid-special-keyid"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "k8s_prod:us-east-1@v2", - keyProvider = keyProvider, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("valid-special-keyid"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "k8s_prod:us-east-1@v2", + keyProvider = keyProvider, + ), + ) val stores = FileBackedSovereignStores.open(config) stores.close() } @@ -362,13 +389,15 @@ class FileBackedSovereignStoresTest { @Test fun `open rejects non-AES encryption key`() { val desKey = KeyGenerator.getInstance("DES").generateKey() - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("non-aes-key"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "test-key", - keyProvider = FileStoreEncryptionKeyProvider { desKey }, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("non-aes-key"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "test-key", + keyProvider = FileStoreEncryptionKeyProvider { desKey }, + ), + ) assertThrows { FileBackedSovereignStores.open(config) } @@ -377,13 +406,15 @@ class FileBackedSovereignStoresTest { @Test fun `open rejects AES key that is not 256 bit`() { val aes128Key = KeyGenerator.getInstance("AES").apply { init(128) }.generateKey() - val config = FileBackedStoreConfiguration( - rootDirectory = rootDir.resolve("aes-128-key"), - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "test-key", - keyProvider = FileStoreEncryptionKeyProvider { aes128Key }, - ), - ) + val config = + FileBackedStoreConfiguration( + rootDirectory = rootDir.resolve("aes-128-key"), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "test-key", + keyProvider = FileStoreEncryptionKeyProvider { aes128Key }, + ), + ) assertThrows { FileBackedSovereignStores.open(config) } @@ -550,81 +581,79 @@ class FileBackedSovereignStoresTest { "suspended/${FileStoreSha256.digest("suspended-invocation", approvalId)}.tram.enc", ) - private fun createValidSuspendedRecord( - approvalId: String, - ): Pair { - val toolName = "bundle_lookup" - val toolCallId = "bundle-tool-call-1" - val operationReference = ResumeOperationReference( + private typealias SuspendedRecord = Pair + + private fun createValidSuspendedRecord(approvalId: String): SuspendedRecord { + val operationReference = bundleOperationReference() + val messages = bundleMessages() + return bundleMetadata(approvalId, operationReference, messages) to SensitiveReplayEnvelope.of(messages) + } + + private fun bundleOperationReference() = + ResumeOperationReference( serviceInterface = "dev.tramai.persistence.file.BundleTestService", methodName = "resume", jvmMethodDescriptor = "(Ljava/lang/String;)Ljava/lang/String;", - resumeDefinitionDigest = Sha256Digest.of( - "sha256:4444444444444444444444444444444444444444444444444444444444444444", - ), + resumeDefinitionDigest = + Sha256Digest.of( + "sha256:4444444444444444444444444444444444444444444444444444444444444444", + ), ) - val messages = listOf( - Message( - role = MessageRole.USER, - content = "bundle prompt", - ), + + private fun bundleMessages() = + listOf( + Message(role = MessageRole.USER, content = "bundle prompt"), Message( role = MessageRole.ASSISTANT, content = "", - toolCalls = listOf( - ToolCall( - id = toolCallId, - name = toolName, - argumentsJson = "__redacted_approval_continuation_args__", + toolCalls = + listOf( + ToolCall( + id = "bundle-tool-call-1", + name = "bundle_lookup", + argumentsJson = "__redacted_approval_continuation_args__", + ), ), - ), ), ) - val metadata = SuspendedInvocationMetadata( - approvalId = approvalId, - toolCallId = toolCallId, - toolName = toolName, - toolCallIndex = 0, - correlationId = "bundle-correlation-1", - identity = EngineExecutionIdentity( - workflowRunId = "bundle-workflow-1", - correlationId = "bundle-correlation-1", - workflowDigest = Sha256Digest.of( - "sha256:5555555555555555555555555555555555555555555555555555555555555555", - ), - policyVersion = "policy-v1", - actorId = "bundle-actor", - ), - securityContext = ExecutionSecurityContext( - dataClassification = DataClassification.CONFIDENTIAL, - classificationSource = ClassificationSource.RULE_BASED, - ), - operationReference = operationReference, - replayEnvelopeDigest = ReplayEnvelopePersistenceCodec.computeReplayEnvelopeDigest(operationReference, messages), - conversationId = "bundle-conversation-1", - historySize = 1, - tokenBudgetSnapshot = TokenBudgetSnapshot( - totalInputTokens = 1, - totalOutputTokens = 2, - totalInputCost = 0.01, - totalOutputCost = 0.02, - warnIfExceeded = true, + + private fun bundleMetadata( + approvalId: String, + operationReference: ResumeOperationReference, + messages: List, + ) = SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = "bundle-tool-call-1", + toolName = "bundle_lookup", + toolCallIndex = 0, + correlationId = "bundle-correlation-1", + identity = + EngineExecutionIdentity( + "bundle-workflow-1", + "bundle-correlation-1", + Sha256Digest.of("sha256:5555555555555555555555555555555555555555555555555555555555555555"), + "policy-v1", + "bundle-actor", ), - toolReference = ResumeToolReference( - toolName = toolName, - declarationDigest = Sha256Digest.of( - "sha256:6666666666666666666666666666666666666666666666666666666666666666", - ), + securityContext = ExecutionSecurityContext(DataClassification.CONFIDENTIAL, ClassificationSource.RULE_BASED), + operationReference = operationReference, + replayEnvelopeDigest = ReplayEnvelopePersistenceCodec.computeReplayEnvelopeDigest(operationReference, messages), + conversationId = "bundle-conversation-1", + historySize = 1, + tokenBudgetSnapshot = TokenBudgetSnapshot(1, 2, 0.01, 0.02, true), + toolReference = + ResumeToolReference( + "bundle_lookup", + Sha256Digest.of("sha256:6666666666666666666666666666666666666666666666666666666666666666"), ), - toolSecurity = ToolSecurityMetadata( - permission = "bundle.read", - risk = RiskLevel.LOW, - approval = ApprovalMode.HUMAN_REQUIRED, - managedNetworkEgress = ManagedNetworkEgress.DENY, - audit = AuditDetail.FULL, - compatibilityMode = CompatibilityMode.STRICT, + toolSecurity = + ToolSecurityMetadata( + "bundle.read", + RiskLevel.LOW, + ApprovalMode.HUMAN_REQUIRED, + ManagedNetworkEgress.DENY, + AuditDetail.FULL, + CompatibilityMode.STRICT, ), - ) - return metadata to SensitiveReplayEnvelope.of(messages) - } + ) } diff --git a/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileGovernedApprovalAttributionTest.kt b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileGovernedApprovalAttributionTest.kt new file mode 100644 index 000000000..d894a06be --- /dev/null +++ b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileGovernedApprovalAttributionTest.kt @@ -0,0 +1,382 @@ +package dev.tramai.persistence.file + +import com.fasterxml.jackson.databind.node.ObjectNode +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.approval.ApprovalTransition +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.engine.approval.ApprovalAttributionKeys +import dev.tramai.engine.approval.ApprovalRunAttribution +import dev.tramai.engine.approval.GovernedApprovalStore +import dev.tramai.testing.persistence.approval.ApprovalStoreFixtures +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.io.path.exists +import kotlin.test.assertFailsWith + +/** + * Governed approval attribution persistence on the file-backed store (0.7.1d1). + * + * The invariant under test: a governed file-backed approval cannot lose, partially preserve, or + * silently downgrade its governed attribution as its persisted lifecycle advances. Every assertion + * after a lifecycle step re-reads from disk (a fresh store over the same directory), because an + * in-memory assertion cannot prove what was persisted. + */ +class FileGovernedApprovalAttributionTest { + private val rootDir: Path = Files.createTempDirectory("tramai-governed-approval-").toAbsolutePath() + private val now: Instant = Instant.parse("2025-06-01T12:00:00Z") + private val expiry: Instant = now.plusSeconds(600) + private val clock: Clock = Clock.fixed(now, ZoneId.of("UTC")) + private val testKey: SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + @BeforeEach + fun setup() { + Files.createDirectories(rootDir.resolve("approvals")) + Files.setPosixFilePermissions( + rootDir.resolve("approvals"), + java.nio.file.attribute.PosixFilePermissions + .fromString("rwx------"), + ) + } + + @AfterEach + fun cleanup() { + if (rootDir.exists()) rootDir.toFile().deleteRecursively() + } + + // ── A. Ungoverned V1 ───────────────────────────────────────────────── + + @Test + fun `an ungoverned approval persists as V1 and synthesizes no attribution`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("legacy-1", now, expiry) + + store.create(request) + assertThat(persistedSchemaVersion("legacy-1")).isEqualTo(1) + assertThat(store.attributionOf("legacy-1")).isEqualTo(ApprovalRunAttribution.Ungoverned) + + // Transitions keep it a valid V1 record, and reopen still reads it. + store.transition("legacy-1", 0L, ApprovalTransition.Approve(decidedBy = "approver-1")) + assertThat(persistedSchemaVersion("legacy-1")).isEqualTo(1) + + val reopened = store() + assertThat(reopened.get("legacy-1")?.status).isEqualTo(ApprovalStatus.APPROVED) + assertThat(reopened.attributionOf("legacy-1")).isEqualTo(ApprovalRunAttribution.Ungoverned) + } + + @Test + fun `a denied ungoverned approval stays V1 with no attribution`() = + runBlocking { + val store = store() + store.create(ApprovalStoreFixtures.pending("legacy-deny", now, expiry)) + + store.transition("legacy-deny", 0L, ApprovalTransition.Deny(decidedBy = "approver-1", comment = "no")) + + assertThat(persistedSchemaVersion("legacy-deny")).isEqualTo(1) + assertThat(store().attributionOf("legacy-deny")).isEqualTo(ApprovalRunAttribution.Ungoverned) + } + + // ── B. Governed V2 initial persistence ─────────────────────────────── + + @Test + fun `a governed approval persists as V2 and survives reopen`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-create", now, expiry) + val attribution = governedAttribution(request) + + store.createGovernedApproval(request, attribution) + + assertThat(persistedSchemaVersion("gov-create")).isEqualTo(2) + assertThat(store.attributionOf("gov-create")).isEqualTo(attribution) + + val reopened = store() + assertThat(reopened.attributionOf("gov-create")).isEqualTo(attribution) + // The canonical run id is still sourced from the binding, not from the attribution DTO. + assertThat((reopened.attributionOf("gov-create") as ApprovalRunAttribution.Governed).identity.runId) + .isEqualTo(RunId(request.binding.workflowRunId)) + } + + // ── C/G. Lifecycle preservation (the rewrite hazard) ───────────────── + + @Test + fun `approving a governed approval keeps it V2 with identical attribution after reopen`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-approve", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + + val decided = store.transition("gov-approve", 0L, ApprovalTransition.Approve(decidedBy = "approver-1")) + + // This is the regression this test exists for: the transition rewrites the whole record. + // If attribution propagation is ever removed from that rewrite path, this fails. + assertThat(persistedSchemaVersion("gov-approve")).isEqualTo(2) + val reopened = store() + assertThat(reopened.attributionOf("gov-approve")).isEqualTo(governedAttribution(request)) + assertThat(reopened.get("gov-approve")?.status).isEqualTo(ApprovalStatus.APPROVED) + assertThat(decided.status).isEqualTo(ApprovalStatus.APPROVED) + } + + @Test + fun `denying a governed approval keeps it V2 with identical attribution after reopen`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-deny", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + + store.transition("gov-deny", 0L, ApprovalTransition.Deny(decidedBy = "approver-1", comment = "rejected")) + + assertThat(persistedSchemaVersion("gov-deny")).isEqualTo(2) + assertThat(store().attributionOf("gov-deny")).isEqualTo(governedAttribution(request)) + } + + @Test + fun `consuming a governed approval keeps it V2 with identical attribution after reopen`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-consume", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + val decided = store.transition("gov-consume", 0L, ApprovalTransition.Approve(decidedBy = "approver-1")) + + val receipt = + store.consumeApprovedOrReplay( + "gov-consume", + decided.version, + request.binding.approvalTokenDigest, + consumedBy = "consumer-1", + ) + + assertThat(receipt.replayed).isFalse() + assertThat(persistedSchemaVersion("gov-consume")).isEqualTo(2) + val reopened = store() + assertThat(reopened.attributionOf("gov-consume")).isEqualTo(governedAttribution(request)) + assertThat(reopened.get("gov-consume")?.consumedBy).isEqualTo("consumer-1") + } + + @Test + fun `the replay path reads a governed approval without altering its attribution`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-replay", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + val decided = store.transition("gov-replay", 0L, ApprovalTransition.Approve(decidedBy = "approver-1")) + store.consumeApprovedOrReplay( + "gov-replay", + decided.version, + request.binding.approvalTokenDigest, + "consumer-1", + ) + + val replay = + store.consumeApprovedOrReplay( + "gov-replay", + decided.version, + request.binding.approvalTokenDigest, + "consumer-1", + ) + + assertThat(replay.replayed).isTrue() + assertThat(persistedSchemaVersion("gov-replay")).isEqualTo(2) + assertThat(store().attributionOf("gov-replay")).isEqualTo(governedAttribution(request)) + } + + // ── D. Terminal behaviour unchanged ────────────────────────────────── + + @Test + fun `a terminal governed approval still rejects further transitions and keeps its attribution`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-terminal", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + store.transition("gov-terminal", 0L, ApprovalTransition.Approve(decidedBy = "approver-1")) + + assertFailsWith { + store.transition("gov-terminal", 1L, ApprovalTransition.Deny(decidedBy = "approver-2")) + } + + assertThat(persistedSchemaVersion("gov-terminal")).isEqualTo(2) + assertThat(store().attributionOf("gov-terminal")).isEqualTo(governedAttribution(request)) + } + + // ── E. Strict V2 corruption ────────────────────────────────────────── + + @Test + fun `every missing attribution component fails decoding instead of becoming ungoverned`() = + runBlocking { + for (component in PERSISTED_COMPONENTS) { + val id = "gov-corrupt-${component.replace("Id", "").lowercase()}" + val store = store() + val request = ApprovalStoreFixtures.pending(id, now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + tamper(id) { (it.get("attribution") as ObjectNode).remove(component) } + + val reopened = store() + assertFailsWith("missing $component must fail closed") { + reopened.attributionOf(id) + } + // Never silently reinterpreted as an un-attributed legacy record. + assertFailsWith { reopened.get(id) } + } + } + + @Test + fun `a V2 record without its attribution block fails decoding`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-no-block", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + tamper("gov-no-block") { it.remove("attribution") } + + assertFailsWith { store().attributionOf("gov-no-block") } + } + + @Test + fun `a blank attribution component fails decoding rather than decoding as a partial identity`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-blank", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + tamper("gov-blank") { it.get("attribution").let { a -> a as ObjectNode }.put("deploymentId", " ") } + + assertFailsWith { store().attributionOf("gov-blank") } + } + + @Test + fun `an unsupported approval schema version fails instead of decoding as V1`() = + runBlocking { + val store = store() + store.create(ApprovalStoreFixtures.pending("gov-version", now, expiry)) + tamper("gov-version") { it.put("schemaVersion", 3) } + + val failure = + assertFailsWith { store().attributionOf("gov-version") } + assertThat(failure.message.orEmpty()).contains("unsupported-approval-schema-version") + } + + @Test + fun `the run id is sourced from the binding and never from a duplicated persisted copy`() = + runBlocking { + val store = store() + val request = ApprovalStoreFixtures.pending("gov-binding", now, expiry) + store.createGovernedApproval(request, governedAttribution(request)) + + // The attribution block persists no run id at all, so a second identifier cannot exist; + // the decoded identity takes its run id from the canonical binding. + val attributionNode = persistedJson("gov-binding").get("attribution") as ObjectNode + assertThat(attributionNode.has("runId")).isFalse() + assertThat(attributionNode.size()).isEqualTo(PERSISTED_COMPONENTS.size + 1) + + val identity = + (store().attributionOf("gov-binding") as ApprovalRunAttribution.Governed).identity + assertThat(identity.runId).isEqualTo(RunId(request.binding.workflowRunId)) + } + + // ── helpers ────────────────────────────────────────────────────────── + + /** + * A fresh governed store over the same directory: proves what is on disk, not what is in memory. + * + * The governed capability is a wrapper over the approval store, so this exercises the contract a + * caller actually obtains rather than an implementation detail. + */ + private fun store(): GovernedApprovalStore = + GovernedFileApprovalStore(FileApprovalStore(rootDir, testKey, config(), FileStoreLease(), clock)) + + private fun config() = + FileBackedStoreConfiguration( + rootDirectory = rootDir, + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "test-key", + keyProvider = FileStoreEncryptionKeyProvider { testKey }, + ), + verifyOnOpen = false, + ) + + private fun governedAttribution(request: ApprovalRequest): ApprovalRunAttribution.Governed = + ApprovalRunAttribution.Governed( + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims-triage"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-triage-prod"), + version = ConfigurationVersion("v7"), + ), + environmentId = EnvironmentId("prod-eu"), + deploymentId = DeploymentId("deploy-42"), + ), + runId = RunId(request.binding.workflowRunId), + ), + ) + + private fun recordPath(approvalId: String): Path = + rootDir + .resolve("approvals") + .resolve("${FileStoreSha256.digest(RECORD_TYPE, approvalId)}$FILE_EXTENSION") + + private fun persistedJson(approvalId: String) = + FILE_STORE_JSON.readTree( + String( + FileStoreUtil.readAndDecrypt( + recordPath(approvalId), + RECORD_TYPE, + FileStoreSha256.digest(RECORD_TYPE, approvalId), + testKey, + "test-key", + ), + Charsets.UTF_8, + ), + ) + + private fun persistedSchemaVersion(approvalId: String): Int = persistedJson(approvalId).get("schemaVersion").asInt() + + private fun tamper( + approvalId: String, + transform: (ObjectNode) -> Unit, + ) = rewrite(approvalId, transform) + + private fun rewrite( + approvalId: String, + transform: (ObjectNode) -> Unit, + ) { + val node = persistedJson(approvalId) as ObjectNode + transform(node) + val json = FILE_STORE_JSON.writeValueAsString(node).toByteArray(Charsets.UTF_8) + FileStoreUtil.atomicEncryptWrite( + recordPath(approvalId), + RECORD_TYPE, + FileStoreSha256.digest(RECORD_TYPE, approvalId), + "test-key", + testKey, + json, + ) + } + + private companion object { + const val RECORD_TYPE = "approval-request" + const val FILE_EXTENSION = ".tram.enc" + val PERSISTED_COMPONENTS = + listOf("workloadId", "configurationId", "configurationVersion", "environmentId", "deploymentId") + } +} diff --git a/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStoreTest.kt b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStoreTest.kt index 5963a1391..f826874a4 100644 --- a/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStoreTest.kt +++ b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/FileSuspendedInvocationStoreTest.kt @@ -1,6 +1,15 @@ package dev.tramai.persistence.file import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolCall @@ -14,6 +23,7 @@ import dev.tramai.core.policy.RiskLevel import dev.tramai.core.policy.ToolSecurityMetadata import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocation import dev.tramai.engine.ResumeOperationReference import dev.tramai.engine.ResumeToolReference import dev.tramai.engine.SensitiveReplayEnvelope @@ -24,6 +34,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.nio.file.Files @@ -40,11 +51,9 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class FileSuspendedInvocationStoreTest { - private val rootDir: Path = Files.createTempDirectory("tramai-suspended-test-").toAbsolutePath() - private fun testKey(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun testKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private val testKey = testKey() private val keyProvider = FileStoreEncryptionKeyProvider { testKey } @@ -54,10 +63,11 @@ class FileSuspendedInvocationStoreTest { keyProvider: FileStoreEncryptionKeyProvider = this.keyProvider, ) = FileBackedStoreConfiguration( rootDirectory = rootDir, - encryption = FileStoreEncryptionConfiguration( - activeKeyId = "test-key", - keyProvider = keyProvider, - ), + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "test-key", + keyProvider = keyProvider, + ), verifyOnOpen = verifyOnOpen, ) @@ -83,335 +93,362 @@ class FileSuspendedInvocationStoreTest { } @Test - fun `lifecycle create get reveal remove`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord() - - store.create(metadata, envelope) - - val retrieved = store.get(metadata.approvalId) - assertNotNull(retrieved) - assertEquals(metadata.approvalId, retrieved.approvalId) - - val revealed = store.revealReplayEnvelope(metadata.approvalId) - assertNotNull(revealed) - assertEquals( - envelope.revealForResume().messages, - revealed.revealForResume().messages, - ) + fun `lifecycle create get reveal remove`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord() + + store.create(metadata, envelope) + + val retrieved = store.get(metadata.approvalId) + assertNotNull(retrieved) + assertEquals(metadata.approvalId, retrieved.approvalId) + + val revealed = store.revealReplayEnvelope(metadata.approvalId) + assertNotNull(revealed) + assertEquals( + envelope.revealForResume().messages, + revealed.revealForResume().messages, + ) - val removed = store.remove(metadata.approvalId) - assertNotNull(removed) - assertEquals(metadata.approvalId, removed.approvalId) - assertNull(store.get(metadata.approvalId)) - } + val removed = store.remove(metadata.approvalId) + assertNotNull(removed) + assertEquals(metadata.approvalId, removed.approvalId) + assertNull(store.get(metadata.approvalId)) + } } @Test - fun `restart reopen preserves suspended invocation`() { runBlocking { - val (metadata, envelope) = createValidRecord() + fun `restart reopen preserves suspended invocation`() { + runBlocking { + val (metadata, envelope) = createValidRecord() - createStore().create(metadata, envelope) + createStore().create(metadata, envelope) - val reopened = createStore() - val retrieved = reopened.get(metadata.approvalId) - assertNotNull(retrieved) - assertEquals(metadata.approvalId, retrieved.approvalId) - assertNotNull(reopened.revealReplayEnvelope(metadata.approvalId)) - } + val reopened = createStore() + val retrieved = reopened.get(metadata.approvalId) + assertNotNull(retrieved) + assertEquals(metadata.approvalId, retrieved.approvalId) + assertNotNull(reopened.revealReplayEnvelope(metadata.approvalId)) + } } @Test - fun `encrypted file does not expose sensitive plaintext`() { runBlocking { - val store = createStore() - val workflowRunId = "workflow-run-sensitive-123" - val correlationId = "corr-sensitive-456" - val promptSecret = "TOP-SECRET-PROMPT" - val (metadata, envelope) = createValidRecord( - approvalId = "approval-confidentiality-1", - workflowRunId = workflowRunId, - correlationId = correlationId, - promptSecret = promptSecret, - ) - - store.create(metadata, envelope) - - val fileContent = recordPath(metadata.approvalId).readText() - assertFalse(fileContent.contains(promptSecret)) - assertFalse(fileContent.contains("""{"accountId":"A-42","amount":99}""")) - assertFalse(fileContent.contains(workflowRunId)) - assertFalse(fileContent.contains(correlationId)) - } + fun `encrypted file does not expose sensitive plaintext`() { + runBlocking { + val store = createStore() + val workflowRunId = "workflow-run-sensitive-123" + val correlationId = "corr-sensitive-456" + val promptSecret = "TOP-SECRET-PROMPT" + val (metadata, envelope) = + createValidRecord( + approvalId = "approval-confidentiality-1", + workflowRunId = workflowRunId, + correlationId = correlationId, + promptSecret = promptSecret, + ) + + store.create(metadata, envelope) + + val fileContent = recordPath(metadata.approvalId).readText() + assertFalse(fileContent.contains(promptSecret)) + assertFalse(fileContent.contains("""{"accountId":"A-42","amount":99}""")) + assertFalse(fileContent.contains(workflowRunId)) + assertFalse(fileContent.contains(correlationId)) + } } @Test - fun `duplicate create rejected`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord() + fun `duplicate create rejected`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord() - store.create(metadata, envelope) + store.create(metadata, envelope) - assertThrows { - runBlocking { store.create(metadata, envelope) } + assertThrows { + runBlocking { store.create(metadata, envelope) } + } } } - } @Test - fun `wrong key rejects suspended invocation reads`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord() - store.create(metadata, envelope) - - val wrongStore = createStore(key = testKey()) - - assertThrows { - runBlocking { wrongStore.get(metadata.approvalId) } - } - assertThrows { - runBlocking { wrongStore.revealReplayEnvelope(metadata.approvalId) } + fun `wrong key rejects suspended invocation reads`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord() + store.create(metadata, envelope) + + val wrongStore = createStore(key = testKey()) + + assertThrows { + runBlocking { wrongStore.get(metadata.approvalId) } + } + assertThrows { + runBlocking { wrongStore.revealReplayEnvelope(metadata.approvalId) } + } } } - } @Test - fun `ciphertext tampering rejected`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord() - store.create(metadata, envelope) - - val path = recordPath(metadata.approvalId) - val encrypted = EncryptedFileEnvelopeV1.fromJson(path.readText()) - val corrupted = encrypted.copy( - ciphertextBase64 = encrypted.ciphertextBase64.dropLast(1) + "X", - ) - Files.writeString(path, corrupted.toJson()) - - assertThrows { - runBlocking { store.get(metadata.approvalId) } + fun `ciphertext tampering rejected`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord() + store.create(metadata, envelope) + + val path = recordPath(metadata.approvalId) + val encrypted = EncryptedFileEnvelopeV1.fromJson(path.readText()) + val corrupted = + encrypted.copy( + ciphertextBase64 = encrypted.ciphertextBase64.dropLast(1) + "X", + ) + Files.writeString(path, corrupted.toJson()) + + assertThrows { + runBlocking { store.get(metadata.approvalId) } + } + assertThrows { + runBlocking { store.revealReplayEnvelope(metadata.approvalId) } + } } - assertThrows { - runBlocking { store.revealReplayEnvelope(metadata.approvalId) } - } - } } @Test - fun `filename substitution rejected`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord(approvalId = "approval-substitution-a") - store.create(metadata, envelope) - - val originalPath = recordPath(metadata.approvalId) - val substitutedApprovalId = "approval-substitution-b" - Files.move(originalPath, recordPath(substitutedApprovalId)) - - assertNull(store.get(metadata.approvalId)) - assertThrows { - runBlocking { store.get(substitutedApprovalId) } + fun `filename substitution rejected`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord(approvalId = "approval-substitution-a") + store.create(metadata, envelope) + + val originalPath = recordPath(metadata.approvalId) + val substitutedApprovalId = "approval-substitution-b" + Files.move(originalPath, recordPath(substitutedApprovalId)) + + assertNull(store.get(metadata.approvalId)) + assertThrows { + runBlocking { store.get(substitutedApprovalId) } + } } } - } @Test - fun `startup verification fails for corrupted suspended record`() { runBlocking { - val (metadata, envelope) = createValidRecord(approvalId = "approval-verify-open-1") - createStore().create(metadata, envelope) - - val path = recordPath(metadata.approvalId) - val encrypted = EncryptedFileEnvelopeV1.fromJson(path.readText()) - val corrupted = encrypted.copy( - ciphertextBase64 = encrypted.ciphertextBase64.dropLast(1) + "X", - ) - Files.writeString(path, corrupted.toJson()) - - assertThrows { - FileBackedSovereignStores.open(createConfig(verifyOnOpen = true)) + fun `startup verification fails for corrupted suspended record`() { + runBlocking { + val (metadata, envelope) = createValidRecord(approvalId = "approval-verify-open-1") + createStore().create(metadata, envelope) + + val path = recordPath(metadata.approvalId) + val encrypted = EncryptedFileEnvelopeV1.fromJson(path.readText()) + val corrupted = + encrypted.copy( + ciphertextBase64 = encrypted.ciphertextBase64.dropLast(1) + "X", + ) + Files.writeString(path, corrupted.toJson()) + + assertThrows { + FileBackedSovereignStores.open(createConfig(verifyOnOpen = true)) + } } } - } @Test - fun `unexpected file in suspended directory rejected`() { runBlocking { - val store = createStore() - Files.writeString(rootDir.resolve("suspended/foo.txt"), "unexpected") - - assertThrows { - store.verifyAll() + fun `unexpected file in suspended directory rejected`() { + runBlocking { + val store = createStore() + Files.writeString(rootDir.resolve("suspended/foo.txt"), "unexpected") + + assertThrows { + store.verifyAll() + } } } - } @Test - fun `unexpected directory in suspended directory rejected`() { runBlocking { - val store = createStore() - Files.createDirectories(rootDir.resolve("suspended/extra")) - - assertThrows { - store.verifyAll() + fun `unexpected directory in suspended directory rejected`() { + runBlocking { + val store = createStore() + Files.createDirectories(rootDir.resolve("suspended/extra")) + + assertThrows { + store.verifyAll() + } } } - } @Test - fun `bad permissions on suspended file rejected`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord() - store.create(metadata, envelope) - Files.setPosixFilePermissions(recordPath(metadata.approvalId), PosixFilePermissions.fromString("rw-r--r--")) - - assertThrows { - runBlocking { store.get(metadata.approvalId) } - } - assertThrows { - runBlocking { store.revealReplayEnvelope(metadata.approvalId) } + fun `bad permissions on suspended file rejected`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord() + store.create(metadata, envelope) + Files.setPosixFilePermissions(recordPath(metadata.approvalId), PosixFilePermissions.fromString("rw-r--r--")) + + assertThrows { + runBlocking { store.get(metadata.approvalId) } + } + assertThrows { + runBlocking { store.revealReplayEnvelope(metadata.approvalId) } + } } } - } @Test - fun `symlinked suspended record file rejected`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord() - store.create(metadata, envelope) - - val path = recordPath(metadata.approvalId) - val target = rootDir.resolve("symlink-target.bin") - Files.writeString(target, "not-a-record") - Files.delete(path) - Files.createSymbolicLink(path, target) - - assertThrows { - runBlocking { store.get(metadata.approvalId) } - } - assertThrows { - store.verifyAll() + fun `symlinked suspended record file rejected`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord() + store.create(metadata, envelope) + + val path = recordPath(metadata.approvalId) + val target = rootDir.resolve("symlink-target.bin") + Files.writeString(target, "not-a-record") + Files.delete(path) + Files.createSymbolicLink(path, target) + + assertThrows { + runBlocking { store.get(metadata.approvalId) } + } + assertThrows { + store.verifyAll() + } } } - } @Test - fun `symlinked suspended directory rejected`() { runBlocking { - val store = createStore() - val realDir = rootDir.resolve("external-suspended") - Files.createDirectories(realDir) - rootDir.resolve("suspended").toFile().deleteRecursively() - Files.createSymbolicLink(rootDir.resolve("suspended"), realDir) - - assertThrows { - runBlocking { store.get("approval-any") } + fun `symlinked suspended directory rejected`() { + runBlocking { + val store = createStore() + val realDir = rootDir.resolve("external-suspended") + Files.createDirectories(realDir) + rootDir.resolve("suspended").toFile().deleteRecursively() + Files.createSymbolicLink(rootDir.resolve("suspended"), realDir) + + assertThrows { + runBlocking { store.get("approval-any") } + } + assertThrows { + store.verifyAll() + } } - assertThrows { - store.verifyAll() - } - } } @Test - fun `operations after lease close rejected`() { runBlocking { - val lease = FileStoreLease() - val store = createStore(lease = lease) - lease.close() - - assertThrows { - runBlocking { store.get("approval-closed") } - } - assertThrows { - runBlocking { store.revealReplayEnvelope("approval-closed") } + fun `operations after lease close rejected`() { + runBlocking { + val lease = FileStoreLease() + val store = createStore(lease = lease) + lease.close() + + assertThrows { + runBlocking { store.get("approval-closed") } + } + assertThrows { + runBlocking { store.revealReplayEnvelope("approval-closed") } + } + assertThrows { + runBlocking { store.remove("approval-closed") } + } } - assertThrows { - runBlocking { store.remove("approval-closed") } - } - } } @Test - fun `concurrent creates have exactly one winner`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord(approvalId = "approval-concurrent-1") - - coroutineScope { - val outcomes = listOf( - async { - try { - store.create(metadata, envelope) - "winner" - } catch (_: IllegalArgumentException) { - "loser" - } - }, - async { - try { - store.create(metadata, envelope) - "winner" - } catch (_: IllegalArgumentException) { - "loser" - } - }, - ).map { it.await() } - - assertEquals(1, outcomes.count { it == "winner" }) + fun `concurrent creates have exactly one winner`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord(approvalId = "approval-concurrent-1") + + coroutineScope { + val outcomes = + listOf( + async { + try { + store.create(metadata, envelope) + "winner" + } catch (_: IllegalArgumentException) { + "loser" + } + }, + async { + try { + store.create(metadata, envelope) + "winner" + } catch (_: IllegalArgumentException) { + "loser" + } + }, + ).map { it.await() } + + assertEquals(1, outcomes.count { it == "winner" }) + } } } - } @Test - fun `get and remove validate replay record before returning metadata or deleting`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord(approvalId = "approval-validated-read-1") - store.create(metadata, envelope) - corruptPersistedRecord(metadata.approvalId) { record -> - record.copy( - replayEnvelope = record.replayEnvelope.copy( - messages = record.replayEnvelope.messages.mapIndexed { index, message -> - if (index == 1) { - message.copy( - toolCalls = message.toolCalls?.map { it.copy(argumentsJson = """{"unsafe":true}""") }, - ) - } else { - message - } - }, - ), - ) + fun `get and remove validate replay record before returning metadata or deleting`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord(approvalId = "approval-validated-read-1") + store.create(metadata, envelope) + corruptPersistedRecord(metadata.approvalId) { record -> + record.copy( + replayEnvelope = + record.replayEnvelope.copy( + messages = + record.replayEnvelope.messages.mapIndexed { index, message -> + if (index == 1) { + message.copy( + toolCalls = + message.toolCalls?.map { + it.copy(argumentsJson = """{"unsafe":true}""") + }, + ) + } else { + message + } + }, + ), + ) + } + + assertThrows { + runBlocking { store.get(metadata.approvalId) } + } + assertTrue(recordPath(metadata.approvalId).exists()) + + assertThrows { + runBlocking { store.remove(metadata.approvalId) } + } + assertTrue(recordPath(metadata.approvalId).exists()) + assertThrows { + runBlocking { store.revealReplayEnvelope(metadata.approvalId) } + } } - - assertThrows { - runBlocking { store.get(metadata.approvalId) } - } - assertTrue(recordPath(metadata.approvalId).exists()) - - assertThrows { - runBlocking { store.remove(metadata.approvalId) } - } - assertTrue(recordPath(metadata.approvalId).exists()) - assertThrows { - runBlocking { store.revealReplayEnvelope(metadata.approvalId) } - } - } } @Test - fun `cross module replay digest contract holds across persist and reveal`() { runBlocking { - val store = createStore() - val (metadata, envelope) = createValidRecord(approvalId = "approval-digest-contract-1") - - val createdMessages = ReplayEnvelopePersistenceCodec.snapshotForPersistence(metadata, envelope) - store.create(metadata, envelope) - - val reopened = createStore() - val restored = reopened.revealReplayEnvelope(metadata.approvalId) - assertNotNull(restored) - - val revealedMessages = restored.revealForResume().messages - assertEquals(createdMessages, revealedMessages) - assertEquals( - metadata.replayEnvelopeDigest, - ReplayEnvelopePersistenceCodec.computeReplayEnvelopeDigest( - metadata.operationReference, revealedMessages, - ), - ) - } + fun `cross module replay digest contract holds across persist and reveal`() { + runBlocking { + val store = createStore() + val (metadata, envelope) = createValidRecord(approvalId = "approval-digest-contract-1") + + val createdMessages = ReplayEnvelopePersistenceCodec.snapshotForPersistence(metadata, envelope) + store.create(metadata, envelope) + + val reopened = createStore() + val restored = reopened.revealReplayEnvelope(metadata.approvalId) + assertNotNull(restored) + + val revealedMessages = restored.revealForResume().messages + assertEquals(createdMessages, revealedMessages) + assertEquals( + metadata.replayEnvelopeDigest, + ReplayEnvelopePersistenceCodec.computeReplayEnvelopeDigest( + metadata.operationReference, + revealedMessages, + ), + ) + } } private fun createValidRecord(): Pair = @@ -425,76 +462,88 @@ class FileSuspendedInvocationStoreTest { ): Pair { val toolName = "sensitive_lookup" val toolCallId = "tool-call-1" - val operationReference = ResumeOperationReference( - serviceInterface = "dev.tramai.persistence.file.SuspendedTestService", - methodName = "resume", - jvmMethodDescriptor = "(Ljava/lang/String;)Ljava/lang/String;", - resumeDefinitionDigest = Sha256Digest.of( - "sha256:1111111111111111111111111111111111111111111111111111111111111111", - ), - ) - val messages = listOf( - Message( - role = MessageRole.USER, - content = "$promptSecret workflow=$workflowRunId correlation=$correlationId", - ), - Message( - role = MessageRole.ASSISTANT, - content = "", - toolCalls = listOf( - ToolCall( - id = toolCallId, - name = toolName, - argumentsJson = "__redacted_approval_continuation_args__", + val operationReference = + ResumeOperationReference( + serviceInterface = "dev.tramai.persistence.file.SuspendedTestService", + methodName = "resume", + jvmMethodDescriptor = "(Ljava/lang/String;)Ljava/lang/String;", + resumeDefinitionDigest = + Sha256Digest.of( + "sha256:1111111111111111111111111111111111111111111111111111111111111111", ), + ) + val messages = + listOf( + Message( + role = MessageRole.USER, + content = "$promptSecret workflow=$workflowRunId correlation=$correlationId", ), - ), - ) - val metadata = SuspendedInvocationMetadata( - approvalId = approvalId, - toolCallId = toolCallId, - toolName = toolName, - toolCallIndex = 0, - correlationId = correlationId, - identity = EngineExecutionIdentity( - workflowRunId = workflowRunId, - correlationId = correlationId, - workflowDigest = Sha256Digest.of( - "sha256:2222222222222222222222222222222222222222222222222222222222222222", + Message( + role = MessageRole.ASSISTANT, + content = "", + toolCalls = + listOf( + ToolCall( + id = toolCallId, + name = toolName, + argumentsJson = "__redacted_approval_continuation_args__", + ), + ), ), - policyVersion = "policy-v1", - actorId = "actor-1", - ), - securityContext = ExecutionSecurityContext( - dataClassification = DataClassification.CONFIDENTIAL, - classificationSource = ClassificationSource.RULE_BASED, - ), - operationReference = operationReference, - replayEnvelopeDigest = ReplayEnvelopePersistenceCodec.computeReplayEnvelopeDigest(operationReference, messages), - conversationId = "conversation-1", - historySize = 1, - tokenBudgetSnapshot = TokenBudgetSnapshot( - totalInputTokens = 10, - totalOutputTokens = 20, - totalInputCost = 0.1, - totalOutputCost = 0.2, - warnIfExceeded = true, - ), - toolReference = ResumeToolReference( + ) + val metadata = + SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = toolCallId, toolName = toolName, - declarationDigest = Sha256Digest.of( - "sha256:3333333333333333333333333333333333333333333333333333333333333333", - ), - ), - toolSecurity = ToolSecurityMetadata( - permission = "files.read", - risk = RiskLevel.MEDIUM, - approval = ApprovalMode.HUMAN_REQUIRED, - managedNetworkEgress = ManagedNetworkEgress.ALLOWLIST_ONLY, - audit = AuditDetail.FULL, - compatibilityMode = CompatibilityMode.STRICT, - ), - ) + toolCallIndex = 0, + correlationId = correlationId, + identity = + EngineExecutionIdentity( + workflowRunId = workflowRunId, + correlationId = correlationId, + workflowDigest = + Sha256Digest.of( + "sha256:2222222222222222222222222222222222222222222222222222222222222222", + ), + policyVersion = "policy-v1", + actorId = "actor-1", + ), + securityContext = + ExecutionSecurityContext( + dataClassification = DataClassification.CONFIDENTIAL, + classificationSource = ClassificationSource.RULE_BASED, + ), + operationReference = operationReference, + replayEnvelopeDigest = ReplayEnvelopePersistenceCodec.computeReplayEnvelopeDigest(operationReference, messages), + conversationId = "conversation-1", + historySize = 1, + tokenBudgetSnapshot = + TokenBudgetSnapshot( + totalInputTokens = 10, + totalOutputTokens = 20, + totalInputCost = 0.1, + totalOutputCost = 0.2, + warnIfExceeded = true, + ), + toolReference = + ResumeToolReference( + toolName = toolName, + declarationDigest = + Sha256Digest.of( + "sha256:3333333333333333333333333333333333333333333333333333333333333333", + ), + ), + toolSecurity = + ToolSecurityMetadata( + permission = "files.read", + risk = RiskLevel.MEDIUM, + approval = ApprovalMode.HUMAN_REQUIRED, + managedNetworkEgress = ManagedNetworkEgress.ALLOWLIST_ONLY, + audit = AuditDetail.FULL, + compatibilityMode = CompatibilityMode.STRICT, + ), + ) return metadata to SensitiveReplayEnvelope.of(messages) } @@ -509,23 +558,26 @@ class FileSuspendedInvocationStoreTest { ) { val path = recordPath(approvalId) val recordKeyDigest = FileStoreSha256.digest("suspended-invocation", approvalId) - val plaintext = FileStoreUtil.readAndDecrypt( - path, - "suspended-invocation", - recordKeyDigest, - testKey, - "test-key", - ) - val mutated = mutate( - PersistedSuspendedInvocationRecordV1.fromJson(plaintext.toString(Charsets.UTF_8)), - ) - val (nonceBase64, ciphertextBase64) = AesGcmFileEncryption.encrypt( - key = testKey, - recordType = "suspended-invocation", - recordKeyDigest = recordKeyDigest, - keyId = "test-key", - plaintextBytes = mutated.toJson().toByteArray(Charsets.UTF_8), - ) + val plaintext = + FileStoreUtil.readAndDecrypt( + path, + "suspended-invocation", + recordKeyDigest, + testKey, + "test-key", + ) + val mutated = + mutate( + PersistedSuspendedInvocationRecordV1.fromJson(plaintext.toString(Charsets.UTF_8)), + ) + val (nonceBase64, ciphertextBase64) = + AesGcmFileEncryption.encrypt( + key = testKey, + recordType = "suspended-invocation", + recordKeyDigest = recordKeyDigest, + keyId = "test-key", + plaintextBytes = mutated.toJson().toByteArray(Charsets.UTF_8), + ) Files.writeString( path, EncryptedFileEnvelopeV1( @@ -539,4 +591,221 @@ class FileSuspendedInvocationStoreTest { ) Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------")) } + + // ─── 0.7.1d governed attribution (V2 records) ─────────────────────────────── + + private fun runSuspending(block: suspend () -> T): T = runBlocking { block() } + + private inline fun expectFailure(noinline block: suspend () -> Unit): T = + assertThrows { runBlocking { block() } } + + private data class GovernedIdentityValues( + val runId: String = "governed-run-1", + val workloadId: String = "claims", + val configurationId: String = "claims-prod", + val configurationVersion: String = "17", + val environmentId: String = "production", + val deploymentId: String = "eu-west-amsterdam-01", + ) + + @Nested + inner class GovernedAttributionTests { + private fun governedIdentity(values: GovernedIdentityValues = GovernedIdentityValues()) = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(values.workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(values.configurationId), + version = ConfigurationVersion(values.configurationVersion), + ), + environmentId = EnvironmentId(values.environmentId), + deploymentId = DeploymentId(values.deploymentId), + ), + runId = RunId(values.runId), + ) + + /** Rewrites the persisted plaintext record in place, re-encrypting through the same envelope. */ + private fun rewritePersistedRecord( + approvalId: String, + transform: (String) -> String, + ) { + val path = recordPath(approvalId) + val recordKeyDigest = FileStoreSha256.digest("suspended-invocation", approvalId) + val plaintext = + FileStoreUtil.readAndDecrypt( + path, + "suspended-invocation", + recordKeyDigest, + testKey, + "test-key", + ) + FileStoreUtil.atomicEncryptWrite( + targetPath = path, + recordType = "suspended-invocation", + recordKeyDigest = recordKeyDigest, + keyId = "test-key", + key = testKey, + plaintextBytes = transform(plaintext.toString(Charsets.UTF_8)).toByteArray(Charsets.UTF_8), + ) + } + + private fun storeGovernedRecord( + approvalId: String, + runId: String, + ): GovernedRunIdentity { + val identity = governedIdentity(GovernedIdentityValues(runId = runId)) + val (metadata, envelope) = createValidRecord(approvalId = approvalId, workflowRunId = runId) + runSuspending { createStore().createGoverned(GovernedSuspendedInvocation(metadata, identity), envelope) } + return identity + } + + @Test + fun `governed create round trips the exact whole identity`() { + val store = createStore() + val identity = governedIdentity(GovernedIdentityValues(runId = "governed-run-1")) + val (metadata, envelope) = + createValidRecord(approvalId = "approval-governed-1", workflowRunId = "governed-run-1") + + runSuspending { store.createGoverned(GovernedSuspendedInvocation(metadata, identity), envelope) } + + val read = runSuspending { store.governedRunIdentity("approval-governed-1") } + assertEquals(identity, read) + assertEquals("eu-west-amsterdam-01", read?.deployment?.deploymentId?.value) + assertNotNull(runSuspending { store.get("approval-governed-1") }) + assertNotNull(runSuspending { store.revealReplayEnvelope("approval-governed-1") }) + assertEquals(null, runSuspending { store.governedRunIdentity("approval-absent") }) + } + + @Test + fun `a governed record survives store reconstruction`() { + val identity = storeGovernedRecord("approval-governed-restart", runId = "governed-restart-run") + + val restarted = createStore() + + assertEquals(identity, runSuspending { restarted.governedRunIdentity("approval-governed-restart") }) + val metadata = assertNotNull(runSuspending { restarted.get("approval-governed-restart") }) + assertEquals(identity.runId.value, metadata.identity.workflowRunId) + assertEquals(identity.deployment.deploymentId.value, "eu-west-amsterdam-01") + assertNotNull(runSuspending { restarted.revealReplayEnvelope("approval-governed-restart") }) + restarted.verifyAll() + } + + @Test + fun `a legacy record still reads as legacy`() { + val store = createStore() + val (metadata, envelope) = createValidRecord(approvalId = "approval-legacy-1") + runSuspending { store.create(metadata, envelope) } + + assertNull(runSuspending { store.governedRunIdentity("approval-legacy-1") }) + assertEquals( + metadata.identity.workflowRunId, + assertNotNull(runSuspending { store.get("approval-legacy-1") }).identity.workflowRunId, + ) + assertNotNull(runSuspending { store.revealReplayEnvelope("approval-legacy-1") }) + createStore().verifyAll() + } + + @Test + fun `a governed record whose identity names another run is corruption`() { + storeGovernedRecord("approval-governed-mismatch", runId = "governed-mismatch-run") + rewritePersistedRecord("approval-governed-mismatch") { json -> + json.replaceFirst("\"runId\":\"governed-mismatch-run\"", "\"runId\":\"other-run\"") + } + + expectFailure { + createStore().governedRunIdentity("approval-governed-mismatch") + } + expectFailure { + createStore().get("approval-governed-mismatch") + } + } + + @Test + fun `a governed record missing an identity component fails closed`() { + storeGovernedRecord("approval-governed-partial", runId = "governed-partial-run") + rewritePersistedRecord("approval-governed-partial") { json -> + json.replace(Regex("\"deploymentId\":\"[^\"]*\","), "") + } + + val failure = + expectFailure { + createStore().governedRunIdentity("approval-governed-partial") + } + assertFalse(failure.message.isNullOrBlank()) + } + + @Test + fun `an unknown record schema version fails closed`() { + storeGovernedRecord("approval-governed-unknown", runId = "governed-unknown-run") + rewritePersistedRecord("approval-governed-unknown") { json -> + json.replaceFirst("\"schemaVersion\":2", "\"schemaVersion\":3") + } + + expectFailure { + createStore().governedRunIdentity("approval-governed-unknown") + } + } + + @Test + fun `a mistyped record schema version is corruption, never an unsupported version`() { + listOf("\"2\"", "2.5", "\"two\"", "null", "99999999999").forEachIndexed { index, declared -> + val approvalId = "approval-governed-mistyped-$index" + storeGovernedRecord(approvalId, runId = "governed-mistyped-run-$index") + rewritePersistedRecord(approvalId) { json -> + json.replaceFirst("\"schemaVersion\":2", "\"schemaVersion\":$declared") + } + + val failure = + expectFailure { + createStore().governedRunIdentity(approvalId) + } + assertFalse(failure.message.isNullOrBlank()) + } + } + + @Test + fun `governed duplicate create keeps the duplicate semantics`() { + val store = createStore() + val identity = governedIdentity(GovernedIdentityValues(runId = "governed-dup-run")) + val (metadata, envelope) = + createValidRecord(approvalId = "approval-governed-dup", workflowRunId = "governed-dup-run") + runSuspending { store.createGoverned(GovernedSuspendedInvocation(metadata, identity), envelope) } + + val failure = + expectFailure { + store.createGoverned(GovernedSuspendedInvocation(metadata, identity), envelope) + } + assertEquals("suspended-invocation-already-exists", failure.message) + } + + @Test + fun `the governed wrapper is one durable write with no stale attribution`() { + val store = createStore() + val wrapper = GovernedFileSuspendedInvocationStore(store) + val identity = governedIdentity(GovernedIdentityValues(runId = "governed-wrapper-run")) + val (metadata, envelope) = + createValidRecord(approvalId = "approval-wrapper-1", workflowRunId = "governed-wrapper-run") + + runSuspending { wrapper.createGoverned(GovernedSuspendedInvocation(metadata, identity), envelope) } + + // One record: the wrapper is a front door to the same atomic write, not a second authority. + assertTrue(recordPath("approval-wrapper-1").toFile().isFile) + assertNotNull(runSuspending { store.get("approval-wrapper-1") }) + assertNotNull(runSuspending { store.revealReplayEnvelope("approval-wrapper-1") }) + assertEquals(identity, runSuspending { wrapper.governedRunIdentity("approval-wrapper-1") }) + store.verifyAll() + + // Restart recovers the exact whole identity through a fresh store and wrapper. + val restarted = GovernedFileSuspendedInvocationStore(createStore()) + assertEquals(identity, runSuspending { restarted.governedRunIdentity("approval-wrapper-1") }) + + // Removal leaves no stale attribution, and an absent record is never fabricated. + val removed = assertNotNull(runSuspending { wrapper.remove("approval-wrapper-1") }) + assertEquals(metadata.approvalId, removed.approvalId) + assertNull(runSuspending { wrapper.governedRunIdentity("approval-wrapper-1") }) + assertNull(runSuspending { wrapper.governedRunIdentity("approval-never-persisted") }) + } + } } diff --git a/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/GovernedFileApprovalStoreTckTest.kt b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/GovernedFileApprovalStoreTckTest.kt new file mode 100644 index 000000000..c6d0971dd --- /dev/null +++ b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/GovernedFileApprovalStoreTckTest.kt @@ -0,0 +1,58 @@ +package dev.tramai.persistence.file + +import dev.tramai.core.approval.ApprovalStore +import dev.tramai.testing.persistence.approval.ApprovalStoreTck +import dev.tramai.testing.persistence.approval.ApprovalStoreTckHarness +import dev.tramai.testing.persistence.approval.MutableClock +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.atomic.AtomicLong +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.io.path.exists + +/** + * 0.7.1d1: GovernedFileApprovalStore must satisfy the same ApprovalStore compatibility contract as + * the store it composes. + * + * The governed capability is a wrapper, so enrolling it proves the delegation is transparent for the + * whole released contract — not merely for the governed paths. Attribution persistence itself is + * proven separately by FileGovernedApprovalAttributionTest; this runner owns the key, layout and + * per-case isolation exactly like the plain store's runner, and only the returned type differs. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GovernedFileApprovalStoreTckTest : ApprovalStoreTck() { + private val rootDir: Path = Files.createTempDirectory("tramai-governed-approval-tck-").toAbsolutePath() + private val key: SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private val keyProvider = FileStoreEncryptionKeyProvider { key } + private val caseCounter = AtomicLong(0) + + override val harness = + object : ApprovalStoreTckHarness { + override fun createStore(clock: MutableClock): ApprovalStore { + // Fresh isolated storage per case: previous cases' records never leak forward. + val caseDir = rootDir.resolve("case-${caseCounter.incrementAndGet()}") + Files.createDirectories(caseDir.resolve("approvals")) + Files.setPosixFilePermissions( + caseDir.resolve("approvals"), + PosixFilePermissions.fromString("rwx------"), + ) + val config = + FileBackedStoreConfiguration( + rootDirectory = caseDir, + encryption = + FileStoreEncryptionConfiguration(activeKeyId = "tck-key", keyProvider = keyProvider), + verifyOnOpen = false, + ) + return GovernedFileApprovalStore(FileApprovalStore(caseDir, key, config, FileStoreLease(), clock)) + } + } + + @AfterAll + fun tearDownAll() { + if (rootDir.exists()) rootDir.toFile().deleteRecursively() + } +} diff --git a/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/GovernedFileSuspendedInvocationStoreTckTest.kt b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/GovernedFileSuspendedInvocationStoreTckTest.kt new file mode 100644 index 000000000..6e629b564 --- /dev/null +++ b/tramai-persistence-file/src/test/kotlin/dev/tramai/persistence/file/GovernedFileSuspendedInvocationStoreTckTest.kt @@ -0,0 +1,63 @@ +package dev.tramai.persistence.file + +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.testing.persistence.engine.SuspendedInvocationStoreTck +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.atomic.AtomicLong +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.io.path.exists + +/** + * 0.7.1d: GovernedFileSuspendedInvocationStore must satisfy the same + * SuspendedInvocationStore compatibility contract as the store it composes. + * + * The governed capability is a wrapper, so enrolling it means proving the + * delegation is transparent for the whole released contract — not just for the + * governed paths. The runner owns the key, layout, and per-case isolation + * exactly like the plain store's runner; only the returned type differs. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GovernedFileSuspendedInvocationStoreTckTest : SuspendedInvocationStoreTck() { + private val rootDir: Path = Files.createTempDirectory("tramai-governed-suspended-tck-").toAbsolutePath() + private val testKey: SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private val keyProvider = FileStoreEncryptionKeyProvider { testKey } + private val caseCounter = AtomicLong(0) + + override fun createStore(): SuspendedInvocationStore { + val caseDir = Files.createDirectories(rootDir.resolve("case-${caseCounter.incrementAndGet()}")) + Files.createDirectories(caseDir.resolve("suspended")) + Files.setPosixFilePermissions( + caseDir.resolve("suspended"), + PosixFilePermissions.fromString("rwx------"), + ) + return GovernedFileSuspendedInvocationStore( + FileSuspendedInvocationStore( + root = caseDir, + key = testKey, + configuration = + FileBackedStoreConfiguration( + rootDirectory = caseDir, + encryption = + FileStoreEncryptionConfiguration( + activeKeyId = "tck-key-1", + keyProvider = keyProvider, + ), + verifyOnOpen = false, + ), + lease = FileStoreLease(), + ), + ) + } + + @AfterAll + fun cleanup() { + if (rootDir.exists()) { + rootDir.toFile().deleteRecursively() + } + } +} diff --git a/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api b/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api index e651227ab..4f2390a86 100644 --- a/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api +++ b/tramai-persistence-jdbc/api/tramai-persistence-jdbc.api @@ -1,3 +1,27 @@ +public final class dev/tramai/persistence/jdbc/GovernedJdbcApprovalStore : dev/tramai/core/approval/ApprovalStore, dev/tramai/engine/approval/GovernedApprovalStore { + public fun attributionOf (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun consumeApprovedOrReplay-LJPQ52o (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun create (Ldev/tramai/core/approval/ApprovalRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createGovernedApproval (Ldev/tramai/core/approval/ApprovalRequest;Ldev/tramai/engine/approval/ApprovalRunAttribution$Governed;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun transition (Ljava/lang/String;JLdev/tramai/core/approval/ApprovalTransition;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/tramai/persistence/jdbc/GovernedJdbcApprovalStoreKt { + public static final fun asGovernedApprovalStore (Ldev/tramai/persistence/jdbc/JdbcApprovalStore;)Ldev/tramai/engine/approval/GovernedApprovalStore; +} + +public final class dev/tramai/persistence/jdbc/GovernedJdbcSuspendedInvocationStore : dev/tramai/engine/GovernedSuspendedInvocationStore, dev/tramai/engine/SuspendedInvocationStore { + public fun (Ldev/tramai/persistence/jdbc/JdbcSuspendedInvocationStore;)V + public fun create (Ldev/tramai/engine/SuspendedInvocationMetadata;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createGoverned (Ldev/tramai/engine/GovernedSuspendedInvocation;Ldev/tramai/engine/SensitiveReplayEnvelope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun governedRunIdentity (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun remove (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun revealReplayEnvelope (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun revealSensitiveContext (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/persistence/jdbc/JdbcApprovalContinuationStore : dev/tramai/core/approval/ApprovalContinuationStore { public static final field Companion Ldev/tramai/persistence/jdbc/JdbcApprovalContinuationStore$Companion; public fun (Ljavax/sql/DataSource;Ldev/tramai/persistence/jdbc/JdbcContinuationArgumentsCodec;Ljava/time/Clock;ILjava/time/Duration;)V diff --git a/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcApprovalStore.kt b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcApprovalStore.kt new file mode 100644 index 000000000..686c72350 --- /dev/null +++ b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcApprovalStore.kt @@ -0,0 +1,29 @@ +package dev.tramai.persistence.jdbc + +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStore +import dev.tramai.engine.approval.ApprovalRunAttribution +import dev.tramai.engine.approval.GovernedApprovalStore + +/** + * Governed capability for the JDBC-backed approval store (0.7.1d1). + * + * Composition, not a second durable authority: every method forwards to the same `approvals` row, so + * the reserved-key encoding, the shared attribution codec and the single-write atomicity live in + * exactly one place. See [dev.tramai.persistence.file.GovernedFileApprovalStore] for the rationale on + * exposing the capability as a wrapper rather than widening the store itself. + */ +class GovernedJdbcApprovalStore internal constructor( + private val delegate: JdbcApprovalStore, +) : ApprovalStore by delegate, + GovernedApprovalStore { + override suspend fun createGovernedApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution.Governed, + ): ApprovalRequest = delegate.createGovernedApproval(request, attribution) + + override suspend fun attributionOf(approvalId: String): ApprovalRunAttribution = delegate.attributionOf(approvalId) +} + +/** Wraps this approval store with the governed capability; see [GovernedJdbcApprovalStore]. */ +fun JdbcApprovalStore.asGovernedApprovalStore(): GovernedApprovalStore = GovernedJdbcApprovalStore(this) diff --git a/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcSuspendedInvocationStore.kt b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcSuspendedInvocationStore.kt new file mode 100644 index 000000000..0af41ca8c --- /dev/null +++ b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcSuspendedInvocationStore.kt @@ -0,0 +1,34 @@ +package dev.tramai.persistence.jdbc + +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationStore + +/** + * Governed suspension capability for the JDBC store (0.7.1d). + * + * Composition, not a second durable authority: [createGoverned] forwards into the same + * single-row insert the store already uses, with the whole [GovernedRunIdentity] inside + * that row's encrypted payload. One row, one transaction — a crash can never leave a + * suspension without its attribution, and attribution is never promoted into plaintext + * columns. + * + * [JdbcSuspendedInvocationStore] deliberately does not declare this interface: widening a + * released type changes the analyzer identity it is baselined under. + */ +class GovernedJdbcSuspendedInvocationStore( + private val delegate: JdbcSuspendedInvocationStore, +) : SuspendedInvocationStore by delegate, + GovernedSuspendedInvocationStore { + override suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ) = delegate.createGoverned(suspended, replayEnvelope) + + override suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? { + val identity = delegate.governedRunIdentity(approvalId) + return identity + } +} diff --git a/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcApprovalStore.kt b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcApprovalStore.kt index 78ca457ce..d419ee0cd 100644 --- a/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcApprovalStore.kt +++ b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcApprovalStore.kt @@ -19,6 +19,11 @@ import dev.tramai.core.exception.ApprovalStoreNotConsumableException import dev.tramai.core.exception.ApprovalStoreNotFoundException import dev.tramai.core.exception.ApprovalStoreTokenRejectedException import dev.tramai.core.exception.IllegalApprovalTransitionException +import dev.tramai.engine.approval.ApprovalRunAttribution +import dev.tramai.engine.approval.ApprovalRunAttribution.Ungoverned +import dev.tramai.engine.approval.decodeApprovalAttribution +import dev.tramai.engine.approval.mergeApprovalAttribution +import dev.tramai.engine.approval.requireAttributionMatchesBinding import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.sql.Connection @@ -77,38 +82,22 @@ class JdbcApprovalStore( .registerModule(JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - override suspend fun create(request: ApprovalRequest): ApprovalRequest = - withSafeJdbc({ "Database operation failed for approval: ${request.approvalId}" }) { - require(request.version == 0L) { "Initial approval version must be 0, got ${request.version}" } - require(request.status == ApprovalStatus.PENDING) { "Initial approval status must be PENDING, got ${request.status}" } - require(request.decidedBy == null) { "Initial approval must not have decidedBy set" } - require(request.decidedAt == null) { "Initial approval must not have decidedAt set" } - require(request.decisionComment == null) { "Initial approval must not have decisionComment set" } - - validateIdField(request.approvalId, "approvalId", maxIdLength) - validateIdField(request.requestedBy, "requestedBy", maxIdLength) - SafeActorIdPolicy.validateActorId(request.requestedBy, "requestedBy") + override suspend fun create(request: ApprovalRequest): ApprovalRequest = createApproval(request, Ungoverned) - val binding = request.binding - validateIdField(binding.workflowRunId, "workflowRunId", maxIdLength) - validateIdField(binding.toolName, "toolName", maxIdLength) - validateIdField(binding.policyVersion, "policyVersion", maxIdLength) + internal suspend fun createGovernedApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution.Governed, + ): ApprovalRequest = createApproval(request, attribution) + private suspend fun createApproval( + request: ApprovalRequest, + attribution: ApprovalRunAttribution, + ): ApprovalRequest = + withSafeJdbc({ "Database operation failed for approval: ${request.approvalId}" }) { + requireAttributionMatchesBinding(request, attribution) val now = clock.instant() - require(request.expiresAt > now) { - "expiresAt must be in the future, got $now for expiry ${request.expiresAt}" - } - require(request.expiresAt > request.requestedAt) { "expiresAt must be after requestedAt" } - require(request.requestedAt <= now) { - "requestedAt must not be in the future, got ${request.requestedAt} for now $now" - } - require(request.consumedBy == null) { "Initial approval must not have consumedBy set" } - require(request.consumedAt == null) { "Initial approval must not have consumedAt set" } - - val ttl = Duration.between(request.requestedAt, request.expiresAt) - require(ttl <= maxCreationTtl) { - "expiresAt exceeds maximum creation TTL of $maxCreationTtl" - } + val binding = request.binding + validateApprovalCreation(request, binding, maxIdLength, maxCreationTtl, now) val metadata = ApprovalMetadata( @@ -128,6 +117,7 @@ class JdbcApprovalStore( decisionComment = null, consumedBy = null, consumedAt = null, + attribution = mergeApprovalAttribution(emptyMap(), attribution).takeIf { it.isNotEmpty() }, ) val metadataJson = mapper.writeValueAsString(metadata) val nowOdt = OffsetDateTime.ofInstant(now, ZoneOffset.UTC) @@ -180,6 +170,20 @@ class JdbcApprovalStore( } } + internal suspend fun attributionOf(approvalId: String): ApprovalRunAttribution = + withSafeJdbc({ "Database operation failed for approval: $approvalId" }) { + validateIdField(approvalId, "approvalId", maxIdLength) + + dataSource.connection.use { conn -> + val row = readCurrent(conn, approvalId) ?: throw ApprovalStoreNotFoundException(approvalId) + val metadata = parseMetadata(row.sanitizedMetadataJson) + decodeApprovalAttribution( + workflowRunId = metadata.binding.workflowRunId, + metadata = metadata.attribution.orEmpty(), + ) + } + } + override suspend fun transition( approvalId: String, expectedVersion: Long, @@ -450,6 +454,7 @@ class JdbcApprovalStore( val consumedBy: String?, val consumedAt: String?, val inbox: ApprovalInboxMetadataJson? = null, + val attribution: Map? = null, ) private data class DecisionFields( @@ -642,25 +647,6 @@ class JdbcApprovalStore( return mapper.readValue(json) } - private fun incrementVersion( - approvalId: String, - version: Long, - ): Long = - try { - Math.addExact(version, 1L) - } catch (_: ArithmeticException) { - throw ApprovalStoreConflictException(approvalId) - } - - private fun tokenDigestsMatch( - presentedTokenDigest: Sha256Digest, - storedTokenDigest: Sha256Digest, - ): Boolean = - MessageDigest.isEqual( - presentedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), - storedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), - ) - private fun resolveNextStatus( current: ApprovalRow, transition: ApprovalTransition, @@ -737,17 +723,73 @@ class JdbcApprovalStore( val hex = hashBytes.joinToString("") { "%02x".format(it) } return "sha256:$hex" } +} - private fun validateIdField( - value: String, - fieldName: String, - maxLength: Int, - ): String { - val trimmed = value.trim() - require(trimmed.isNotBlank()) { "$fieldName must not be blank" } - require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } - require(trimmed.length <= maxLength) { "$fieldName exceeds maximum length of $maxLength" } - require(trimmed == value) { "$fieldName must not contain surrounding whitespace" } - return trimmed +private fun incrementVersion( + approvalId: String, + version: Long, +): Long = + try { + Math.addExact(version, 1L) + } catch (_: ArithmeticException) { + throw ApprovalStoreConflictException(approvalId) } + +private fun tokenDigestsMatch( + presentedTokenDigest: Sha256Digest, + storedTokenDigest: Sha256Digest, +): Boolean = + MessageDigest.isEqual( + presentedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), + storedTokenDigest.value.toByteArray(StandardCharsets.US_ASCII), + ) + +private fun validateIdField( + value: String, + fieldName: String, + maxLength: Int, +): String { + val trimmed = value.trim() + require(trimmed.isNotBlank()) { "$fieldName must not be blank" } + require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } + require(trimmed.length <= maxLength) { "$fieldName exceeds maximum length of $maxLength" } + require(trimmed == value) { "$fieldName must not contain surrounding whitespace" } + return trimmed +} + +/** + * Creation-time validation, kept at file scope so the store's function budget is unchanged. + * + * Ordering is preserved from the inline form: identity/attribution first, then the request shape, + * then the time window. + */ +private fun validateApprovalCreation( + request: ApprovalRequest, + binding: ApprovalBinding, + maxIdLength: Int, + maxCreationTtl: Duration, + now: Instant, +) { + require(request.version == 0L) { "Initial approval version must be 0, got ${request.version}" } + require(request.status == ApprovalStatus.PENDING) { "Initial status must be PENDING, got ${request.status}" } + require(request.decidedBy == null) { "Initial approval must not have decidedBy set" } + require(request.decidedAt == null) { "Initial approval must not have decidedAt set" } + require(request.decisionComment == null) { "Initial approval must not have decisionComment set" } + + validateIdField(request.approvalId, "approvalId", maxIdLength) + validateIdField(request.requestedBy, "requestedBy", maxIdLength) + SafeActorIdPolicy.validateActorId(request.requestedBy, "requestedBy") + + validateIdField(binding.workflowRunId, "workflowRunId", maxIdLength) + validateIdField(binding.toolName, "toolName", maxIdLength) + validateIdField(binding.policyVersion, "policyVersion", maxIdLength) + + require(request.expiresAt > now) { "expiresAt must be in the future, got $now for expiry ${request.expiresAt}" } + require(request.expiresAt > request.requestedAt) { "expiresAt must be after requestedAt" } + require(request.requestedAt <= now) { "requestedAt is in the future: ${request.requestedAt} vs $now" } + require(request.consumedBy == null) { "Initial approval must not have consumedBy set" } + require(request.consumedAt == null) { "Initial approval must not have consumedAt set" } + + val ttl = Duration.between(request.requestedAt, request.expiresAt) + require(ttl <= maxCreationTtl) { "expiresAt exceeds maximum creation TTL of $maxCreationTtl" } } diff --git a/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStore.kt b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStore.kt index e44601318..5fc84e82a 100644 --- a/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStore.kt +++ b/tramai-persistence-jdbc/src/main/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStore.kt @@ -1,5 +1,6 @@ package dev.tramai.persistence.jdbc +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule @@ -7,11 +8,22 @@ import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.registerKotlinModule import dev.tramai.core.approval.Sha256Digest import dev.tramai.core.coroutines.rethrowIfCancellation +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolCall import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.engine.GovernedSuspendedInvocationStore import dev.tramai.engine.ReplayEnvelopeDigestHelper import dev.tramai.engine.ResumeOperationReference import dev.tramai.engine.ResumeToolReference @@ -90,7 +102,7 @@ class JdbcSuspendedInvocationStore( ): Unit = withSafeJdbc({ "Database operation failed for suspended invocation: ${metadata.approvalId}" }) { validateCreateInput(metadata) - val payloadJson = buildCreatePayload(metadata, replayEnvelope) + val payloadJson = buildCreatePayload(metadata, replayEnvelope, runIdentity = null) val encrypted = replayEnvelopeCodec.encode(payloadJson) val now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC) @@ -99,6 +111,29 @@ class JdbcSuspendedInvocationStore( } } + /** + * Persists a GOVERNED suspension: the same single row and single insert as [create], with + * the canonical run identity inside the same encrypted payload. The identity is therefore + * never promoted into plaintext columns and a crash can never leave a half-governed record. + */ + internal suspend fun createGoverned( + suspended: GovernedSuspendedInvocation, + replayEnvelope: SensitiveReplayEnvelope, + ): Unit = + withSafeJdbc({ + "Database operation failed for suspended invocation: ${suspended.metadata.approvalId}" + }) { + validateCreateInput(suspended.metadata) + val payloadJson = + buildCreatePayload(suspended.metadata, replayEnvelope, suspended.runIdentity) + val encrypted = replayEnvelopeCodec.encode(payloadJson) + val now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC) + + dataSource.connection.use { conn -> + insertSuspendedInvocation(conn, suspended.metadata, encrypted, now) + } + } + override suspend fun get(approvalId: String): SuspendedInvocationMetadata? = withSafeJdbc({ "Database operation failed for suspended invocation: $approvalId" }) { validateIdField(approvalId, "approvalId") @@ -107,6 +142,14 @@ class JdbcSuspendedInvocationStore( row.metadata.toDomain() } + internal suspend fun governedRunIdentity(approvalId: String): GovernedRunIdentity? = + withSafeJdbc({ "Database operation failed for suspended invocation: $approvalId" }) { + validateIdField(approvalId, "approvalId") + + val row = readCurrent(approvalId) ?: return@withSafeJdbc null + row.governedRunIdentity + } + /** * Validates non-sensitive create fields before any replay payload is serialized. */ @@ -125,14 +168,17 @@ class JdbcSuspendedInvocationStore( private fun buildCreatePayload( metadata: SuspendedInvocationMetadata, replayEnvelope: SensitiveReplayEnvelope, + runIdentity: GovernedRunIdentity?, ): ByteArray { val messages = replayEnvelope.revealForResume().messages validateReplayEnvelopeInvariants(metadata, messages) validateReplayEnvelopeDigest(metadata, messages) val payload = Payload( + payloadVersion = if (runIdentity == null) 1 else 2, metadata = PayloadMetadata.fromDomain(metadata), persistedMessages = messages.map { toPersisted(it) }, + governedRunIdentity = runIdentity?.let { PayloadGovernedRunIdentity.fromDomain(it) }, ) return mapper.writeValueAsBytes(payload) } @@ -407,6 +453,7 @@ class JdbcSuspendedInvocationStore( metadata = payload.metadata, messages = payload.persistedMessages.map { toDomainMessage(it) }, version = version, + governedRunIdentity = payload.governedRunIdentity?.toDomain(), ) } } @@ -417,6 +464,7 @@ class JdbcSuspendedInvocationStore( val metadata: PayloadMetadata, val messages: List, val version: Long, + val governedRunIdentity: GovernedRunIdentity? = null, ) private fun readEncryptedFromRow(rs: ResultSet): JdbcEncryptedReplayEnvelope { @@ -445,18 +493,77 @@ class JdbcSuspendedInvocationStore( ) } + private fun decodePlaintext(encrypted: JdbcEncryptedReplayEnvelope): ByteArray = + try { + replayEnvelopeCodec.decode(encrypted) + } catch (e: Exception) { + throw IllegalStateException("suspended-invocation-decryption-failed", e) + } + private fun decryptAndDeserialize(encrypted: JdbcEncryptedReplayEnvelope): Payload { - val plaintext = + val plaintext = decodePlaintext(encrypted) + + val node = + try { + mapper.readTree(plaintext) + } catch (e: Exception) { + throw IllegalStateException("suspended-invocation-deserialization-failed", e) + } ?: error("suspended-invocation-deserialization-failed") + + val payload = try { - replayEnvelopeCodec.decode(encrypted) + mapper.treeToValue(node, Payload::class.java) } catch (e: Exception) { - throw IllegalStateException("suspended-invocation-decryption-failed", e) + throw IllegalStateException("suspended-invocation-deserialization-failed", e) + } ?: error("suspended-invocation-deserialization-failed") + + validatePayloadVersion(node, payload) + return payload + } + + /** + * V1 is legacy (no attribution persisted, and none allowed); V2 is governed and + * mandatory; any other version is corruption. A malformed V2 never degrades to V1. + */ + private fun validatePayloadVersion( + node: JsonNode, + payload: Payload, + ) { + // A payload written before governed attribution existed has no version field at all, + // which is exactly the legacy case. + val version = node.get("payloadVersion")?.asInt() ?: 1 + when (version) { + 1 -> { + if (payload.governedRunIdentity != null) { + error("suspended-invocation-corrupted: legacy payload carries a governed identity") + } } - return try { - mapper.readValue(plaintext) - } catch (e: Exception) { - throw IllegalStateException("suspended-invocation-deserialization-failed", e) + 2 -> { + val identity = + payload.governedRunIdentity + ?: error("suspended-invocation-corrupted: governed payload without a run identity") + val components = + listOf( + identity.workloadId, + identity.configurationId, + identity.configurationVersion, + identity.environmentId, + identity.deploymentId, + identity.runId, + ) + if (components.any { it.isBlank() }) { + error("suspended-invocation-corrupted: governed run identity is incomplete") + } + // Bidirectional invariant, checked on decode as well as on create. + if (identity.runId != payload.metadata.identityWorkflowRunId) { + error("suspended-invocation-corrupted: governed identity names another run") + } + } + + else -> { + error("suspended-invocation-unsupported-payload-version: $version") + } } } @@ -536,10 +643,58 @@ class JdbcSuspendedInvocationStore( * Uses PersistedMessage DTOs — no Jackson default typing. */ private data class Payload( + val payloadVersion: Int = 1, val metadata: PayloadMetadata, val persistedMessages: List, + val governedRunIdentity: PayloadGovernedRunIdentity? = null, ) + /** + * Persisted canonical run identity, carried INSIDE the encrypted payload (0.7.1d) so that + * workload/deployment attribution is never promoted into plaintext query columns — the + * same confidentiality decision the rest of this record already makes. + * + * `payloadVersion` is semantic: 1 means no governed attribution was persisted, 2 means it + * was persisted and is mandatory. A v2 payload missing a component is corruption, never a + * legacy fallback. + */ + private data class PayloadGovernedRunIdentity( + val workloadId: String, + val configurationId: String, + val configurationVersion: String, + val environmentId: String, + val deploymentId: String, + val runId: String, + ) { + fun toDomain(): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configurationId), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId(environmentId), + deploymentId = DeploymentId(deploymentId), + ), + runId = RunId(runId), + ) + + companion object { + fun fromDomain(identity: GovernedRunIdentity): PayloadGovernedRunIdentity = + PayloadGovernedRunIdentity( + workloadId = identity.deployment.workloadId.value, + configurationId = identity.deployment.configuration.id.value, + configurationVersion = identity.deployment.configuration.version.value, + environmentId = identity.deployment.environmentId.value, + deploymentId = identity.deployment.deploymentId.value, + runId = identity.runId.value, + ) + } + } + /** * Persistable snapshot of [SuspendedInvocationMetadata]. * All fields are plain Strings/numbers — no Jackson default typing. diff --git a/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcApprovalStoreTckTest.kt b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcApprovalStoreTckTest.kt new file mode 100644 index 000000000..a25cb6d3b --- /dev/null +++ b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcApprovalStoreTckTest.kt @@ -0,0 +1,226 @@ +package dev.tramai.persistence.jdbc + +import com.fasterxml.jackson.databind.ObjectMapper +import dev.tramai.core.approval.ApprovalStore +import dev.tramai.core.approval.ApprovalTransition.Approve +import dev.tramai.core.exception.ApprovalStoreNotFoundException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.engine.approval.ApprovalAttributionCorruptionException +import dev.tramai.engine.approval.ApprovalAttributionKeys +import dev.tramai.engine.approval.ApprovalRunAttribution +import dev.tramai.engine.approval.GovernedApprovalStore +import dev.tramai.testing.persistence.approval.ApprovalStoreFixtures +import dev.tramai.testing.persistence.approval.ApprovalStoreTck +import dev.tramai.testing.persistence.approval.ApprovalStoreTckHarness +import dev.tramai.testing.persistence.approval.MutableClock +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import java.sql.Connection +import java.sql.DriverManager +import javax.sql.DataSource +import kotlin.test.assertFailsWith + +/** + * 0.7.1d1: GovernedJdbcApprovalStore must satisfy the same ApprovalStore compatibility contract as + * the store it composes, and must persist governed attribution on the durable approval row. + * + * The governed capability delegates into the same `approvals` row, so the released contract has to + * hold through the wrapper: same datasource, same schema, per-case isolation owned here. + * + * The governed attribution discriminators live in this class rather than in a second class so that + * one PostgreSQL container, one datasource and one schema serve them: each additional container + * class costs a container startup inside the suite's CI time budget. + * + * The attribution snapshot must survive the approval's whole lifecycle — not merely creation — which + * is why the lifecycle case asserts the reserved values again after an approve and after a + * consumption, and why the raw JSONB is inspected rather than only the decoded value: the metadata + * record is typed with `ignoreUnknown = true`, so a flat reserved-key write would be silently erased + * by the first `parse → copy → write` round trip. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GovernedJdbcApprovalStoreTckTest : ApprovalStoreTck() { + private lateinit var postgres: PostgreSQLContainer + private lateinit var dataSource: DataSource + private lateinit var setupConnection: Connection + + @BeforeAll + fun setUpAll() { + postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("governed_approval_tck") + .withUsername("test") + .withPassword("test") + postgres.start() + dataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + setupConnection = DriverManager.getConnection(postgres.jdbcUrl, postgres.username, postgres.password) + val schemaSql = + javaClass.classLoader + .getResource("tramai/persistence/jdbc/postgres/V1__sovereign_persistence.sql") + ?.readText() + ?: error("Schema SQL resource not found") + setupConnection.createStatement().use { it.execute(schemaSql) } + } + + @AfterAll + fun tearDownAll() { + runCatching { setupConnection.close() } + runCatching { postgres.stop() } + } + + override val harness = + object : ApprovalStoreTckHarness { + override fun createStore(clock: MutableClock): ApprovalStore { + dataSource.connection.use { conn -> + conn.createStatement().use { it.execute("DELETE FROM approvals") } + } + return GovernedJdbcApprovalStore(JdbcApprovalStore(dataSource, clock = clock)) + } + } + + @Test + fun `a governed approval keeps its attribution snapshot across approval and consumption`() = + runBlocking { + val store = governedStore() + val request = ApprovalStoreFixtures.pending("gov-lifecycle", t0, expiry) + val attribution = ApprovalRunAttribution.Governed(identity(request.binding.workflowRunId)) + + store.createGovernedApproval(request, attribution) + assertThat(store.attributionOf("gov-lifecycle")).isEqualTo(attribution) + + // The typed metadata record is parsed and copied on every transition: this is where a + // flat reserved-key encoding would have been dropped. + val decide = Approve(decidedBy = "approver-1") + val decided = store.transition("gov-lifecycle", 0L, decide) + assertThat(store.attributionOf("gov-lifecycle")).isEqualTo(attribution) + + store.consumeApprovedOrReplay( + "gov-lifecycle", + decided.version, + request.binding.approvalTokenDigest, + consumedBy = "consumer-1", + ) + assertThat(store.attributionOf("gov-lifecycle")).isEqualTo(attribution) + + // Byte-level proof, independent of the codec: the five reserved keys are still on the row. + assertThat(rawAttribution("gov-lifecycle")) + .isEqualTo( + mapOf( + ApprovalAttributionKeys.WORKLOAD to "claims-triage", + ApprovalAttributionKeys.CONFIGURATION to "claims-triage-prod", + ApprovalAttributionKeys.CONFIGURATION_VERSION to "v7", + ApprovalAttributionKeys.ENVIRONMENT to "prod-eu", + ApprovalAttributionKeys.DEPLOYMENT to "deploy-42", + ), + ) + } + + @Test + fun `a legacy approval stores no reserved keys and decodes as un-attributed`() = + runBlocking { + val store = governedStore() + val request = ApprovalStoreFixtures.pending("legacy-1", t0, expiry) + + store.create(request) + + assertThat(store.attributionOf("legacy-1")).isEqualTo(ApprovalRunAttribution.Ungoverned) + assertThat(rawMetadata("legacy-1")).doesNotContain("approval.identity.") + } + + @Test + fun `a governed creation describing another run than its binding is rejected and writes nothing`() = + runBlocking { + val store = governedStore() + val request = ApprovalStoreFixtures.pending("mismatch-1", t0, expiry) + val otherRun = identity("run-somewhere-else") + + assertFailsWith { + store.createGovernedApproval(request, ApprovalRunAttribution.Governed(otherRun)) + } + + assertThat(store.get("mismatch-1")).isNull() + } + + @Test + fun `a partially attributed row is corruption rather than a legacy approval`() = + runBlocking { + val store = governedStore() + val request = ApprovalStoreFixtures.pending("partial-1", t0, expiry) + val governed = ApprovalRunAttribution.Governed(identity(request.binding.workflowRunId)) + + store.createGovernedApproval(request, governed) + + // Simulate a torn/foreign write: drop exactly one reserved key from the JSONB document. + setupConnection + .prepareStatement( + "UPDATE approvals SET sanitized_metadata = sanitized_metadata #- " + + "'{attribution,${ApprovalAttributionKeys.DEPLOYMENT}}' WHERE approval_id = ?", + ).use { stmt -> + stmt.setString(1, "partial-1") + stmt.executeUpdate() + } + + assertFailsWith { store.attributionOf("partial-1") } + } + + @Test + fun `attribution of an unknown approval is not found rather than un-attributed`() = + runBlocking { + val store = governedStore() + + assertFailsWith { store.attributionOf("no-such-approval") } + } + + /** The governed capability over a store on this class's container. */ + private fun governedStore(): GovernedApprovalStore = GovernedJdbcApprovalStore(JdbcApprovalStore(dataSource, clock)) + + private fun identity(runId: String): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims-triage"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-triage-prod"), + version = ConfigurationVersion("v7"), + ), + environmentId = EnvironmentId("prod-eu"), + deploymentId = DeploymentId("deploy-42"), + ), + runId = RunId(runId), + ) + + private fun rawMetadata(approvalId: String): String { + val sql = "SELECT sanitized_metadata::text FROM approvals WHERE approval_id = ?" + return setupConnection.prepareStatement(sql).use { stmt -> + stmt.setString(1, approvalId) + stmt.executeQuery().use { rs -> + check(rs.next()) { "no approval row for $approvalId" } + rs.getString(1) + } + } + } + + private fun rawAttribution(approvalId: String): Map { + val node = ObjectMapper().readTree(rawMetadata(approvalId)).get("attribution") + return ApprovalAttributionKeys.ALL.associateWith { key -> node.get(key).asText() } + } +} diff --git a/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcSuspendedInvocationStoreTckTest.kt b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcSuspendedInvocationStoreTckTest.kt new file mode 100644 index 000000000..bccaca8cb --- /dev/null +++ b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/GovernedJdbcSuspendedInvocationStoreTckTest.kt @@ -0,0 +1,118 @@ +package dev.tramai.persistence.jdbc + +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.testing.persistence.engine.SuspendedInvocationStoreTck +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import java.security.MessageDigest +import java.security.SecureRandom +import java.sql.Connection +import java.sql.DriverManager +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource + +/** + * 0.7.1d: GovernedJdbcSuspendedInvocationStore must satisfy the same + * SuspendedInvocationStore compatibility contract as the store it composes. + * + * The governed capability delegates into the JDBC store's single encrypted row, + * so the released contract has to hold through the wrapper: same datasource, + * same schema, same codec, per-case isolation owned here. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GovernedJdbcSuspendedInvocationStoreTckTest : SuspendedInvocationStoreTck() { + private lateinit var postgres: PostgreSQLContainer + private lateinit var dataSource: DataSource + private lateinit var setupConnection: Connection + + private val testAesKey = ByteArray(16).also { SecureRandom().nextBytes(it) } + + private val testCodec = + object : JdbcReplayEnvelopeCodec { + private val algorithm = "AES/GCM/NoPadding" + private val tagLength = 128 + + override fun encode(plaintext: ByteArray): JdbcEncryptedReplayEnvelope { + val cipher = Cipher.getInstance(algorithm) + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(testAesKey, "AES"), GCMParameterSpec(tagLength, nonce)) + val ciphertext = cipher.doFinal(plaintext) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(plaintext) + .joinToString("") { "%02x".format(it) } + return JdbcEncryptedReplayEnvelope( + ciphertext = ciphertext, + keyId = "tck-key-1", + algorithm = algorithm, + nonce = nonce, + payloadDigest = "sha256:$digest", + ) + } + + override fun decode(envelope: JdbcEncryptedReplayEnvelope): ByteArray { + val cipher = Cipher.getInstance(algorithm) + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(testAesKey, "AES"), + GCMParameterSpec(tagLength, envelope.nonce), + ) + return cipher.doFinal(envelope.ciphertext) + } + } + + private val fixedClock = + Clock.fixed( + Instant.parse("2026-06-21T12:00:00Z"), + ZoneId.of("UTC"), + ) + + @BeforeAll + fun setUpAll() { + postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("governed_suspended_tck") + .withUsername("test") + .withPassword("test") + postgres.start() + dataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + setupConnection = DriverManager.getConnection(postgres.jdbcUrl, postgres.username, postgres.password) + val v1Sql = + this::class.java.classLoader + .getResource("tramai/persistence/jdbc/postgres/V1__sovereign_persistence.sql") + ?.readText() + ?: error("V1 schema SQL resource not found") + setupConnection.createStatement().use { stmt -> stmt.execute(v1Sql) } + } + + @AfterAll + fun tearDownAll() { + runCatching { setupConnection.close() } + runCatching { postgres.stop() } + } + + override fun createStore(): SuspendedInvocationStore { + // Fresh isolated storage per case: previous cases' records must not + // leak into the next case. + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> stmt.execute("DELETE FROM suspended_invocations") } + } + return GovernedJdbcSuspendedInvocationStore( + JdbcSuspendedInvocationStore(dataSource, testCodec, clock = fixedClock), + ) + } +} diff --git a/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStoreTest.kt b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStoreTest.kt index a747663d0..11f44db35 100644 --- a/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStoreTest.kt +++ b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcSuspendedInvocationStoreTest.kt @@ -3,6 +3,15 @@ package dev.tramai.persistence.jdbc import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolCall @@ -10,6 +19,7 @@ import dev.tramai.core.policy.ClassificationSource import dev.tramai.core.policy.DataClassification import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocation import dev.tramai.engine.ReplayEnvelopeDigestHelper import dev.tramai.engine.ResumeOperationReference import dev.tramai.engine.ResumeToolReference @@ -918,4 +928,249 @@ class JdbcSuspendedInvocationStoreTest { } } } + + // ── 0.7.1d governed attribution (encrypted payload v2) ───────────────────── + + private fun governedIdentity(runId: String = "governed-run-1") = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId(runId), + ) + + private fun governedMetadata( + approvalId: String, + runId: String = "governed-run-1", + ): SuspendedInvocationMetadata { + val metadata = sampleMetadata(approvalId = approvalId) + return metadata.copy(identity = metadata.identity.copy(workflowRunId = runId)) + } + + private fun createGoverned( + approvalId: String, + runId: String = "governed-run-1", + ) { + runBlocking { + store().createGoverned( + GovernedSuspendedInvocation(governedMetadata(approvalId, runId), governedIdentity(runId)), + sampleEnvelope(), + ) + } + } + + /** Every string column of the row, used to prove attribution is never plaintext. */ + private fun rowColumnStrings(approvalId: String): List = + createDataSource().connection.use { conn -> + conn.prepareStatement("SELECT * FROM suspended_invocations WHERE invocation_id = ?").use { st -> + st.setString(1, approvalId) + st.executeQuery().use { rs -> + assertTrue(rs.next(), "expected a persisted row for $approvalId") + val columns = rs.metaData.columnCount + (1..columns).map { index -> rs.getString(index).orEmpty() } + } + } + } + + /** Rewrites the encrypted payload of an existing row through the same codec. */ + private fun rewritePayload( + approvalId: String, + transform: (String) -> String, + ) { + val select = + """ + SELECT encrypted_replay_envelope, encryption_key_id, encryption_algorithm, + encryption_nonce, payload_digest + FROM suspended_invocations WHERE invocation_id = ? + """.trimIndent() + createDataSource().connection.use { conn -> + val current = + conn.prepareStatement(select).use { st -> + st.setString(1, approvalId) + st.executeQuery().use { rs -> + assertTrue(rs.next()) + JdbcEncryptedReplayEnvelope( + ciphertext = rs.getBytes(1), + keyId = rs.getString(2), + algorithm = rs.getString(3), + nonce = rs.getBytes(4), + payloadDigest = rs.getString(5), + ) + } + } + val plaintext = String(testCodec.decode(current), StandardCharsets.UTF_8) + val rewritten = testCodec.encode(transform(plaintext).toByteArray(StandardCharsets.UTF_8)) + conn + .prepareStatement( + """ + UPDATE suspended_invocations + SET encrypted_replay_envelope = ?, encryption_nonce = ?, payload_digest = ? + WHERE invocation_id = ? + """.trimIndent(), + ).use { st -> + st.setBytes(1, rewritten.ciphertext) + st.setBytes(2, rewritten.nonce) + st.setString(3, rewritten.payloadDigest) + st.setString(4, approvalId) + st.executeUpdate() + } + } + } + + @Test + fun `governed create round trips the exact identity and keeps it out of plaintext columns`() { + runBlocking { + val s = store() + val id = "si-governed-1" + s.createGoverned( + GovernedSuspendedInvocation(governedMetadata(id, "governed-run-1"), governedIdentity("governed-run-1")), + sampleEnvelope(), + ) + + assertEquals(governedIdentity("governed-run-1"), s.governedRunIdentity(id)) + assertEquals("governed-run-1", s.get(id)?.identity?.workflowRunId) + assertNotNull(s.revealReplayEnvelope(id)) + } + // One durable row, and the whole attribution stays inside the encrypted payload: the + // confidentiality decision that keeps run/workload identity out of queryable columns. + val columns = rowColumnStrings("si-governed-1") + assertTrue(columns.none { it.contains("eu-west-amsterdam-01") }) + assertTrue(columns.none { it.contains("claims-prod") }) + } + + @Test + fun `a governed record survives store reconstruction`() { + val id = "si-governed-restart" + createGoverned(id, runId = "governed-restart-run") + + runBlocking { + val restarted = store() + assertEquals(governedIdentity("governed-restart-run"), restarted.governedRunIdentity(id)) + assertEquals("governed-restart-run", restarted.get(id)?.identity?.workflowRunId) + assertNotNull(restarted.revealReplayEnvelope(id)) + } + } + + @Test + fun `a legacy record still reads as legacy`() { + runBlocking { + val s = store() + s.create(sampleMetadata("si-legacy-1"), sampleEnvelope()) + + assertNull(s.governedRunIdentity("si-legacy-1")) + assertEquals("wf-1", s.get("si-legacy-1")?.identity?.workflowRunId) + assertNotNull(s.revealReplayEnvelope("si-legacy-1")) + } + } + + @Test + fun `a governed payload whose identity names another run is corruption`() { + val id = "si-governed-mismatch" + createGoverned(id) + rewritePayload(id) { json -> json.replaceFirst("\"runId\":\"governed-run-1\"", "\"runId\":\"other-run\"") } + + val ex = assertFailsWith { runBlocking { store().governedRunIdentity(id) } } + assertTrue(ex.message?.contains("corrupted") == true, "unexpected message: ${ex.message}") + } + + @Test + fun `a governed payload downgraded to the legacy version is corruption`() { + val id = "si-governed-downgrade" + createGoverned(id) + rewritePayload(id) { json -> json.replaceFirst("\"payloadVersion\":2", "\"payloadVersion\":1") } + + val ex = assertFailsWith { runBlocking { store().governedRunIdentity(id) } } + assertTrue(ex.message?.contains("corrupted") == true, "unexpected message: ${ex.message}") + } + + @Test + fun `a governed payload with an incomplete identity fails closed`() { + val id = "si-governed-partial" + createGoverned(id) + rewritePayload(id) { json -> json.replaceFirst("\"deploymentId\":\"eu-west-amsterdam-01\"", "\"deploymentId\":\"\"") } + + val ex = assertFailsWith { runBlocking { store().governedRunIdentity(id) } } + assertTrue(ex.message?.contains("corrupted") == true, "unexpected message: ${ex.message}") + } + + @Test + fun `an unknown payload version fails closed`() { + val id = "si-governed-unknown" + createGoverned(id) + rewritePayload(id) { json -> json.replaceFirst("\"payloadVersion\":2", "\"payloadVersion\":3") } + + val ex = assertFailsWith { runBlocking { store().governedRunIdentity(id) } } + assertTrue( + ex.message?.contains("unsupported-payload-version") == true, + "unexpected message: ${ex.message}", + ) + } + + @Test + fun `governed duplicate create keeps the duplicate semantics`() { + runBlocking { + val s = store() + val id = "si-governed-duplicate" + s.createGoverned( + GovernedSuspendedInvocation(governedMetadata(id), governedIdentity()), + sampleEnvelope(), + ) + + val ex = + assertFailsWith { + s.createGoverned( + GovernedSuspendedInvocation(governedMetadata(id), governedIdentity()), + sampleEnvelope(), + ) + } + assertTrue(ex.message?.contains("already-exists", ignoreCase = true) == true) + } + } + + @Test + fun `the governed wrapper is one row with no stale attribution`() { + val wrapper = GovernedJdbcSuspendedInvocationStore(store()) + val identity = governedIdentity("governed-wrapper-run") + val approvalId = "approval-wrapper-1" + + runBlocking { + wrapper.createGoverned( + GovernedSuspendedInvocation(governedMetadata(approvalId, "governed-wrapper-run"), identity), + sampleEnvelope(), + ) + } + + // One row: the wrapper writes through the same insert, never a second durable authority. + assertEquals(1, rowCount(approvalId)) + assertTrue(rowColumnStrings(approvalId).none { it.contains("governed-wrapper-run") }) + assertEquals(identity, runBlocking { wrapper.governedRunIdentity(approvalId) }) + assertNotNull(runBlocking { wrapper.get(approvalId) }) + + // A fresh wrapper over the same database recovers the exact whole identity. + assertEquals(identity, runBlocking { GovernedJdbcSuspendedInvocationStore(store()).governedRunIdentity(approvalId) }) + + // Removal leaves no stale attribution, and an absent record is never fabricated. + assertNotNull(runBlocking { wrapper.remove(approvalId) }) + assertNull(runBlocking { wrapper.governedRunIdentity(approvalId) }) + assertNull(runBlocking { wrapper.governedRunIdentity("approval-never-persisted") }) + } + + private fun rowCount(approvalId: String): Int = + createDataSource().connection.use { conn -> + conn.prepareStatement("SELECT COUNT(*) FROM suspended_invocations WHERE invocation_id = ?").use { st -> + st.setString(1, approvalId) + st.executeQuery().use { rs -> + rs.next() + rs.getInt(1) + } + } + } } diff --git a/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStaleAcrossInstancesTest.kt b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStaleAcrossInstancesTest.kt new file mode 100644 index 000000000..35faed422 --- /dev/null +++ b/tramai-persistence-jdbc/src/test/kotlin/dev/tramai/persistence/jdbc/JdbcWorkloadRegistrationStaleAcrossInstancesTest.kt @@ -0,0 +1,160 @@ +package dev.tramai.persistence.jdbc + +import dev.tramai.controlplane.MetadataUpdateOutcome +import dev.tramai.controlplane.WorkloadRegistrationAuthority +import dev.tramai.controlplane.testing.WorkloadRegistrationFixtures +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import javax.sql.DataSource + +/** + * The 0.7.1e command precondition over the DURABLE authority (0.7.1c store), across instances and + * across reconstruction. + * + * The point of this test is that the stale rule is not in-process state: instance A must lose to + * instance B's committed mutation, and it must still lose after a fresh store instance and a fresh + * authority are built over the same database. Anything weaker would let a command succeed merely + * because it was issued by the process that happened to hold the older record. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JdbcWorkloadRegistrationStaleAcrossInstancesTest { + private lateinit var postgres: PostgreSQLContainer + private lateinit var dataSource: DataSource + + @BeforeAll + fun setUpAll() { + postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("control_plane_stale") + .withUsername("test") + .withPassword("test") + postgres.start() + dataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + JdbcWorkloadRegistrationStoreTckTest.executeSchema(dataSource) + } + + @AfterAll + fun tearDownAll() { + postgres.stop() + } + + /** A separate "instance": its own store over the shared database, behind the command port. */ + private fun newInstance() = WorkloadRegistrationAuthority(JdbcWorkloadRegistrationStore(dataSource)) + + @Test + fun `a stale expected version is rejected across instances and after reconstruction`() = + runBlocking { + val scope = WorkloadRegistrationFixtures.identity(deployment = "cross-instance-1") + val instanceA = newInstance() + val instanceB = newInstance() + instanceA.register( + scope, + WorkloadRegistrationFixtures.fingerprint(), + WorkloadRegistrationFixtures.metadata(), + ) + + // A observes vN. + val observedByA = + instanceA.authoritative(scope.workloadId, scope.environmentId, scope.deploymentId)!!.observedVersion + + // B commits the next version while A still holds vN. + val committedByB = + instanceB.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + observedByA, + WorkloadRegistrationFixtures.metadata(owner = "Instance B"), + ) + assertThat(committedByB).isInstanceOf(MetadataUpdateOutcome.Applied::class.java) + val currentByB = (committedByB as MetadataUpdateOutcome.Applied).exposure.stateVersion + assertThat(currentByB).isEqualTo(observedByA.next()) + + // A submits the version it read: precondition failure naming B's committed version. + val staleFromA = + instanceA.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + observedByA, + WorkloadRegistrationFixtures.metadata(owner = "Instance A"), + ) + assertThat(staleFromA).isInstanceOf(MetadataUpdateOutcome.Stale::class.java) + (staleFromA as MetadataUpdateOutcome.Stale).let { + assertThat(it.expectedVersion).isEqualTo(observedByA) + assertThat(it.currentVersion).isEqualTo(currentByB) + } + assertThat(owner(instanceA, scope)).isEqualTo("Instance B") + + // Same rule after reconstruction: a fresh store instance and a fresh authority. + val reconstructed = newInstance() + val staleAfterReconstruction = + reconstructed.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + observedByA, + WorkloadRegistrationFixtures.metadata(owner = "Instance A after restart"), + ) + assertThat(staleAfterReconstruction).isInstanceOf(MetadataUpdateOutcome.Stale::class.java) + (staleAfterReconstruction as MetadataUpdateOutcome.Stale).let { + assertThat(it.expectedVersion).isEqualTo(observedByA) + assertThat(it.currentVersion).isEqualTo(currentByB) + } + assertThat(owner(reconstructed, scope)).isEqualTo("Instance B") + } + + @Test + fun `re-reading the authoritative version lets the stale writer reconcile and win exactly once`() = + runBlocking { + val scope = WorkloadRegistrationFixtures.identity(deployment = "cross-instance-2") + val writer = newInstance() + val other = newInstance() + writer.register(scope, WorkloadRegistrationFixtures.fingerprint(), WorkloadRegistrationFixtures.metadata()) + val observed = + writer.authoritative(scope.workloadId, scope.environmentId, scope.deploymentId)!!.observedVersion + other.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + observed, + WorkloadRegistrationFixtures.metadata(owner = "Other"), + ) + + // The stale writer re-reads instead of retrying blindly, then succeeds once. + val current = + writer.authoritative(scope.workloadId, scope.environmentId, scope.deploymentId)!!.observedVersion + val recovered = + writer.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + current, + WorkloadRegistrationFixtures.metadata(owner = "Reconciled Writer"), + ) + + assertThat(recovered).isInstanceOf(MetadataUpdateOutcome.Applied::class.java) + val advanced = (recovered as MetadataUpdateOutcome.Applied).exposure.stateVersion + assertThat(advanced).isEqualTo(current.next()) + assertThat(owner(writer, scope)).isEqualTo("Reconciled Writer") + } + + private suspend fun owner( + authority: WorkloadRegistrationAuthority, + scope: dev.tramai.core.identity.WorkloadDeploymentIdentity, + ): String = + authority + .authoritative(scope.workloadId, scope.environmentId, scope.deploymentId)!! + .exposure.metadata.owner +} diff --git a/tramai-scheduler/api/tramai-scheduler.api b/tramai-scheduler/api/tramai-scheduler.api index 591862f8d..4a51b2bac 100644 --- a/tramai-scheduler/api/tramai-scheduler.api +++ b/tramai-scheduler/api/tramai-scheduler.api @@ -98,6 +98,25 @@ public final class dev/tramai/scheduler/ClaimedScheduledTick { public fun toString ()Ljava/lang/String; } +public final class dev/tramai/scheduler/CompositeWorkflowSchedulerStore : dev/tramai/scheduler/GovernedScheduleBindingStore, dev/tramai/scheduler/WorkflowSchedulerStore { + public fun (Ldev/tramai/scheduler/WorkflowSchedulerStore;Ldev/tramai/scheduler/GovernedScheduleBindingStore;)V + public fun claimDueDelayWakeups (Ljava/time/Instant;Ljava/lang/String;Ljava/time/Duration;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun claimDueTicks (Ljava/time/Instant;Ljava/lang/String;Ljava/time/Duration;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getGovernedScheduleBinding (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getSchedule (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun listScheduleStatus (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markDelayWakeupCompleted (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markTickCompleted (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markTickMisfired (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markTickSkipped (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun markTickStarted (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun putGovernedScheduleBinding (Ldev/tramai/scheduler/GovernedScheduleBinding;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun releaseDelayWakeupClaim (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun releaseTickClaim (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun scheduleDelayWakeup (Ljava/lang/String;Ljava/lang/String;Ljava/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun upsertSchedule (Ldev/tramai/scheduler/ScheduleRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/scheduler/CronSchedule : dev/tramai/orchestration/WorkflowScheduleDefinition { public static final field Companion Ldev/tramai/scheduler/CronSchedule$Companion; public fun canonicalForm ()Ljava/lang/String; @@ -131,6 +150,43 @@ public final class dev/tramai/scheduler/CronScheduleKt { public static synthetic fun every$default (JLjava/time/temporal/ChronoUnit;Ljava/time/ZoneId;Ljava/util/List;ZILjava/lang/Object;)Ldev/tramai/scheduler/CronSchedule; } +public final class dev/tramai/scheduler/GovernedScheduleBinding { + public fun (Ljava/lang/String;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun copy (Ljava/lang/String;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;)Ldev/tramai/scheduler/GovernedScheduleBinding; + public static synthetic fun copy$default (Ldev/tramai/scheduler/GovernedScheduleBinding;Ljava/lang/String;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;ILjava/lang/Object;)Ldev/tramai/scheduler/GovernedScheduleBinding; + public fun equals (Ljava/lang/Object;)Z + public final fun getDeploymentIdentity ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun getScheduleId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/tramai/scheduler/GovernedScheduleBindingStore { + public abstract fun getGovernedScheduleBinding (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun putGovernedScheduleBinding (Ldev/tramai/scheduler/GovernedScheduleBinding;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class dev/tramai/scheduler/GovernedScheduleRegistration { + public fun (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/scheduler/GovernedScheduleBindingStore;)V + public final fun component1 ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public final fun component2 ()Ldev/tramai/scheduler/GovernedScheduleBindingStore; + public final fun copy (Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/scheduler/GovernedScheduleBindingStore;)Ldev/tramai/scheduler/GovernedScheduleRegistration; + public static synthetic fun copy$default (Ldev/tramai/scheduler/GovernedScheduleRegistration;Ldev/tramai/core/identity/WorkloadDeploymentIdentity;Ldev/tramai/scheduler/GovernedScheduleBindingStore;ILjava/lang/Object;)Ldev/tramai/scheduler/GovernedScheduleRegistration; + public fun equals (Ljava/lang/Object;)Z + public final fun getBindingStore ()Ldev/tramai/scheduler/GovernedScheduleBindingStore; + public final fun getDeploymentIdentity ()Ldev/tramai/core/identity/WorkloadDeploymentIdentity; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/scheduler/InMemoryGovernedScheduleBindingStore : dev/tramai/scheduler/GovernedScheduleBindingStore { + public fun ()V + public fun getGovernedScheduleBinding (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun putGovernedScheduleBinding (Ldev/tramai/scheduler/GovernedScheduleBinding;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/scheduler/InMemoryWorkflowSchedulerStore : dev/tramai/scheduler/WorkflowSchedulerStore { public fun ()V public fun claimDueDelayWakeups (Ljava/time/Instant;Ljava/lang/String;Ljava/time/Duration;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -148,6 +204,13 @@ public final class dev/tramai/scheduler/InMemoryWorkflowSchedulerStore : dev/tra public fun upsertSchedule (Ldev/tramai/scheduler/ScheduleRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class dev/tramai/scheduler/JdbcGovernedScheduleBindingStore : dev/tramai/scheduler/GovernedScheduleBindingStore { + public fun (Ljavax/sql/DataSource;)V + public final fun createTableSql ()Ljava/util/List; + public fun getGovernedScheduleBinding (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun putGovernedScheduleBinding (Ldev/tramai/scheduler/GovernedScheduleBinding;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/scheduler/JdbcWorkflowSchedulerStore : dev/tramai/scheduler/WorkflowSchedulerStore { public fun (Ljavax/sql/DataSource;Ldev/tramai/orchestration/WorkflowObserver;)V public synthetic fun (Ljavax/sql/DataSource;Ldev/tramai/orchestration/WorkflowObserver;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -223,6 +286,7 @@ public final class dev/tramai/scheduler/ScheduledWorkflowTimer : java/lang/AutoC public synthetic fun (Ldev/tramai/scheduler/WorkflowSchedulerStore;Ljava/lang/String;Ljava/time/Clock;Ljava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;ILdev/tramai/orchestration/WorkflowObserver;Lkotlinx/coroutines/CoroutineScope;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V public final fun pollOnce (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun register (Ldev/tramai/orchestration/Workflow;Lkotlin/jvm/functions/Function2;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Ldev/tramai/scheduler/GovernedScheduleRegistration;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun register (Ldev/tramai/orchestration/Workflow;Lkotlin/jvm/functions/Function2;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun register$default (Ldev/tramai/scheduler/ScheduledWorkflowTimer;Ldev/tramai/orchestration/Workflow;Lkotlin/jvm/functions/Function2;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun start ()Lkotlinx/coroutines/Job; diff --git a/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/CompositeWorkflowSchedulerStore.kt b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/CompositeWorkflowSchedulerStore.kt new file mode 100644 index 000000000..0fb0393a3 --- /dev/null +++ b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/CompositeWorkflowSchedulerStore.kt @@ -0,0 +1,18 @@ +package dev.tramai.scheduler + +/** + * Scheduler store that also carries the governed schedule-binding capability (0.7.1d). + * + * It exists so a timer can discover the durable binding authority from its STORE rather than + * from the current in-memory registration. A registration only declares intent, so reading + * the binding through the registration let an ordinary re-registration hide an existing + * durable governed binding and run the next tick unattributed. + * + * Both legs stay the released implementations: this composes them (Kotlin interface + * delegation) instead of reimplementing either, and it adds no state of its own. + */ +class CompositeWorkflowSchedulerStore( + private val schedules: WorkflowSchedulerStore, + private val bindings: GovernedScheduleBindingStore, +) : WorkflowSchedulerStore by schedules, + GovernedScheduleBindingStore by bindings diff --git a/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/InMemoryGovernedScheduleBindingStore.kt b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/InMemoryGovernedScheduleBindingStore.kt new file mode 100644 index 000000000..30e7c4c71 --- /dev/null +++ b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/InMemoryGovernedScheduleBindingStore.kt @@ -0,0 +1,21 @@ +package dev.tramai.scheduler + +/** + * In-memory governed schedule bindings (0.7.1d). + * + * Deliberately a separate store rather than an extra interface on + * [InMemoryWorkflowSchedulerStore]: a schedule binding is its own durable concern, and the + * scheduler store stays what it always was. + */ +class InMemoryGovernedScheduleBindingStore : GovernedScheduleBindingStore { + private val bindings = linkedMapOf() + + override suspend fun putGovernedScheduleBinding(binding: GovernedScheduleBinding) { + synchronized(this) { + bindings[binding.scheduleId] = binding + } + } + + override suspend fun getGovernedScheduleBinding(scheduleId: String): GovernedScheduleBinding? = + synchronized(this) { bindings[scheduleId] } +} diff --git a/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/JdbcGovernedScheduleBindingStore.kt b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/JdbcGovernedScheduleBindingStore.kt new file mode 100644 index 000000000..de56694d6 --- /dev/null +++ b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/JdbcGovernedScheduleBindingStore.kt @@ -0,0 +1,105 @@ +package dev.tramai.scheduler + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import javax.sql.DataSource + +/** + * Durable governed schedule bindings in JDBC (0.7.1d). + * + * One additive table of its own, owned by this store: the released scheduler tables are not + * altered, and no migration of an already-released schema is rewritten. Only the deployment + * is bound here — a schedule is not a run, so it never carries a run identity. + */ +class JdbcGovernedScheduleBindingStore( + private val dataSource: DataSource, +) : GovernedScheduleBindingStore { + fun createTableSql(): List = listOf(GOVERNANCE_TABLE) + + override suspend fun putGovernedScheduleBinding(binding: GovernedScheduleBinding) { + dataSource.connection.use { connection -> + connection.autoCommit = false + var committed = false + try { + connection.prepareStatement(DELETE_SQL).use { statement -> + statement.setString(1, binding.scheduleId) + statement.executeUpdate() + } + connection.prepareStatement(INSERT_SQL).use { statement -> + listOf( + binding.scheduleId, + binding.deploymentIdentity.workloadId.value, + binding.deploymentIdentity.configuration.id.value, + binding.deploymentIdentity.configuration.version.value, + binding.deploymentIdentity.environmentId.value, + binding.deploymentIdentity.deploymentId.value, + ).forEachIndexed { index, value -> statement.setString(index + 1, value) } + statement.executeUpdate() + } + connection.commit() + committed = true + } finally { + if (!committed) connection.rollback() + connection.autoCommit = true + } + } + } + + override suspend fun getGovernedScheduleBinding(scheduleId: String): GovernedScheduleBinding? { + dataSource.connection.use { connection -> return connection.readGovernedScheduleBinding(scheduleId) } + } + + private fun java.sql.Connection.readGovernedScheduleBinding(scheduleId: String): GovernedScheduleBinding? = + prepareStatement(SELECT_SQL).use { statement -> + statement.setString(1, scheduleId) + statement.executeQuery().use { resultSet -> + if (!resultSet.next()) return null + return resultSet.toBinding() + } + } + + private fun java.sql.ResultSet.toBinding() = + GovernedScheduleBinding( + scheduleId = getString("schedule_id"), + deploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(getString("workload_id")), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(getString("configuration_id")), + version = ConfigurationVersion(getString("configuration_version")), + ), + environmentId = EnvironmentId(getString("environment_id")), + deploymentId = DeploymentId(getString("deployment_id")), + ), + ) + + private companion object { + private const val GOVERNANCE_TABLE = """ + CREATE TABLE IF NOT EXISTS workflow_schedule_governance ( + schedule_id VARCHAR(255) PRIMARY KEY, + workload_id VARCHAR(255) NOT NULL, + configuration_id VARCHAR(255) NOT NULL, + configuration_version VARCHAR(255) NOT NULL, + environment_id VARCHAR(255) NOT NULL, + deployment_id VARCHAR(255) NOT NULL + ) + """ + + private const val DELETE_SQL = "DELETE FROM workflow_schedule_governance WHERE schedule_id = ?" + + private const val INSERT_SQL = + "INSERT INTO workflow_schedule_governance (" + + "schedule_id, workload_id, configuration_id, configuration_version, " + + "environment_id, deployment_id) VALUES (?, ?, ?, ?, ?, ?)" + + private const val SELECT_SQL = + "SELECT schedule_id, workload_id, configuration_id, configuration_version, " + + "environment_id, deployment_id FROM workflow_schedule_governance WHERE schedule_id = ?" + } +} diff --git a/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/ScheduledWorkflowTimer.kt b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/ScheduledWorkflowTimer.kt index 75f545d60..504b0a932 100644 --- a/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/ScheduledWorkflowTimer.kt +++ b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/ScheduledWorkflowTimer.kt @@ -1,21 +1,21 @@ @file:OptIn(ExperimentalTramaiInternalApi::class) -package dev.tramai.scheduler - - -import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi -import dev.tramai.core.observation.event.RuntimeEvents -import dev.tramai.core.observation.event.RuntimeEvent +package dev.tramai.scheduler +import dev.tramai.core.identity.WorkloadDeploymentIdentity import dev.tramai.core.observation.event.RuntimeAttributes - +import dev.tramai.core.observation.event.RuntimeEvent +import dev.tramai.core.observation.event.RuntimeEvents +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.orchestration.FailureIsolatingWorkflowObserver +import dev.tramai.orchestration.GovernedRun import dev.tramai.orchestration.NoOpWorkflowObserver import dev.tramai.orchestration.Workflow import dev.tramai.orchestration.WorkflowContext import dev.tramai.orchestration.WorkflowObserver import dev.tramai.orchestration.WorkflowPersistence import dev.tramai.orchestration.WorkflowSuspendedException +import dev.tramai.orchestration.recoverGovernedRun import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -39,6 +39,13 @@ class ScheduledWorkflowTimer( private val observer: WorkflowObserver = NoOpWorkflowObserver, private val scope: CoroutineScope = OwnedSchedulerScope(SupervisorJob() + Dispatchers.Default), ) : AutoCloseable { + /** + * Durable binding authority, discovered from the STORE rather than from the current + * registration (0.7.1d). A registration only declares intent; a later ordinary + * registration must not be able to hide an existing durable governed binding. + */ + private val bindings: GovernedScheduleBindingStore? = store as? GovernedScheduleBindingStore + // Epic 5.3: the scheduler owns WorkflowObserver instances and invokes // tick callbacks BEFORE durable scheduler transitions (markTickSkipped / // markTickMisfired / markTickStarted / releaseDelayWakeupClaim). A @@ -53,10 +60,11 @@ class ScheduledWorkflowTimer( // 8.3c: one lifecycle owner. The default-created scope is timer-owned // (marked via OwnedSchedulerScope) and is cancelled on close(); a // caller-supplied scope is borrowed and never cancelled. - private val loopOwner = SchedulerLoopOwner( - parentScope = scope, - ownsParentScope = scope is OwnedSchedulerScope, - ) + private val loopOwner = + SchedulerLoopOwner( + parentScope = scope, + ownsParentScope = scope is OwnedSchedulerScope, + ) init { require(!pollInterval.isNegative && !pollInterval.isZero) { @@ -78,24 +86,50 @@ class ScheduledWorkflowTimer( initialState: suspend (ClaimedScheduledTick) -> S, observer: WorkflowObserver = NoOpWorkflowObserver, persistence: WorkflowPersistence? = null, + ) = register(workflow, initialState, observer, persistence, governed = null) + + /** + * Registers a GOVERNED schedule (0.7.1d): the schedule is durably bound to the + * workload deployment whose runs it creates. + * + * The schedule stores no run identity — a schedule is not a run. Each tick creates a + * fresh run id, and `deployment + freshRunId` is the governed identity of that tick's + * execution. A binding, once declared, is never silently dropped by a later + * registration that omits it: downgrading a governed schedule to ungoverned ticks is + * exactly the attribution loss 0.7.1d removes. + */ + suspend fun register( + workflow: Workflow, + initialState: suspend (ClaimedScheduledTick) -> S, + observer: WorkflowObserver, + persistence: WorkflowPersistence?, + governed: GovernedScheduleRegistration?, ) { - val schedule = workflow.schedule - ?: throw IllegalArgumentException("Workflow '${workflow.name}' does not declare a schedule") + val schedule = + workflow.schedule + ?: throw IllegalArgumentException("Workflow '${workflow.name}' does not declare a schedule") require(schedule is CronSchedule) { "ScheduledWorkflowTimer currently supports cron schedules only; workflow '${workflow.name}' declared kind='${schedule.kind}'" } schedule.validate() val scheduleId = scheduleId(workflow.name) - val registration = ScheduledWorkflowRegistration( - workflow = workflow, - initialState = initialState, - // Epic 5.3: per-registration observer wrapped at the boundary too - // (registration.observer is invoked directly by pollOnce). - observer = FailureIsolatingWorkflowObserver(observer), - persistence = persistence, - ) - synchronized(monitor) { - registrations[scheduleId] = registration + val registration = + ScheduledWorkflowRegistration( + workflow = workflow, + initialState = initialState, + // Epic 5.3: per-registration observer wrapped at the boundary too + // (registration.observer is invoked directly by pollOnce). + observer = FailureIsolatingWorkflowObserver(observer), + persistence = persistence, + governed = governed, + ) + // Fail-safe order (0.7.1d): a durable binding write that throws must leave NO + // executable schedule behind, so the binding lands first, then the schedule, and the + // in-memory registration is published last. + governed?.let { declaration -> + declaration.bindingStore.putGovernedScheduleBinding( + GovernedScheduleBinding(scheduleId = scheduleId, deploymentIdentity = declaration.deploymentIdentity), + ) } store.upsertSchedule( ScheduleRecord( @@ -105,14 +139,18 @@ class ScheduledWorkflowTimer( nextFireAt = schedule.nextFireAfter(clock.instant()), ), ) + synchronized(monitor) { + registrations[scheduleId] = registration + } } - fun start(): Job = loopOwner.start { - while (kotlinx.coroutines.currentCoroutineContext().isActive) { - pollOnce() - delay(pollInterval.toMillis()) + fun start(): Job = + loopOwner.start { + while (kotlinx.coroutines.currentCoroutineContext().isActive) { + pollOnce() + delay(pollInterval.toMillis()) + } } - } suspend fun stop() { loopOwner.stop() @@ -120,22 +158,24 @@ class ScheduledWorkflowTimer( suspend fun pollOnce() { val now = clock.instant() - val ticks = store.claimDueTicks( - now = now, - ownerId = ownerId, - claimDuration = claimDuration, - limit = batchSize, - ) - for (tick in ticks) { - handleTick(tick = tick, now = now) - } - if (hasDelayRegistrations()) { - val wakeups = store.claimDueDelayWakeups( + val ticks = + store.claimDueTicks( now = now, ownerId = ownerId, claimDuration = claimDuration, limit = batchSize, ) + for (tick in ticks) { + handleTick(tick = tick, now = now) + } + if (hasDelayRegistrations()) { + val wakeups = + store.claimDueDelayWakeups( + now = now, + ownerId = ownerId, + claimDuration = claimDuration, + limit = batchSize, + ) for (wakeup in wakeups) { handleDelayWakeup(wakeup) } @@ -162,7 +202,25 @@ class ScheduledWorkflowTimer( store.markTickSkipped(tick.tickId, tick.claimToken, reason) return } - val context = scheduledTickContext(tick) + // A tick is a NEW execution: the SAME deployment identity, a FRESH run id created + // here, exactly once, by the execution envelope. Classification is DURABLE, never + // registration-local: the binding is read from the store, so a later ordinary + // registration cannot downgrade a schedule that is durably governed. + val binding = bindings?.getGovernedScheduleBinding(tick.scheduleId) + if (binding == null && registration.governed != null) { + // Explicitly governed registration with no durable binding: fail closed. Runtime + // authority is never reconstructed from the transient declaration. + val reason = GOVERNED_BINDING_MISSING_REASON + val context = scheduledTickContext(tick) + isolatedObserver.onSkippedTick(tick.workflowName, tick.scheduledFireAt, reason, context) + store.markTickSkipped(tick.tickId, tick.claimToken, reason) + return + } + val governedRun = + binding?.let { bound -> + GovernedRun.start(deployment = bound.deploymentIdentity, attributes = scheduledTickAttributes(tick)) + } + val context = governedRun?.context ?: scheduledTickContext(tick) val observer = registration.observer if (Duration.between(tick.scheduledFireAt, now) > misfireThreshold) { val reason = "misfire_threshold_exceeded" @@ -174,7 +232,7 @@ class ScheduledWorkflowTimer( isolatedObserver.onScheduledTick(tick.workflowName, tick.scheduledFireAt, context) store.markTickStarted(tick.tickId, tick.claimToken, runId) try { - registration.run(tick, context) + registration.run(tick, context, governedRun) } catch (_: WorkflowSuspendedException) { synchronized(monitor) { delayRegistrations[runId] = registration @@ -189,15 +247,17 @@ class ScheduledWorkflowTimer( store.releaseDelayWakeupClaim(wakeup.runId, wakeup.stepId, wakeup.claimToken) return } - val registration = synchronized(monitor) { - delayRegistrations[wakeup.runId] - } - if (registration == null) { - val unregisteredEvent = RuntimeEvent.of(RuntimeEvents.SCHEDULER_DELAY_WAKEUP_UNREGISTERED) { - set(RuntimeAttributes.WORKFLOW_ID_BARE, wakeup.runId) - set(RuntimeAttributes.STEP_ID, wakeup.stepId) - set(RuntimeAttributes.RESUME_AT_EPOCH_MILLIS, wakeup.resumeAt.toEpochMilli()) + val registration = + synchronized(monitor) { + delayRegistrations[wakeup.runId] } + if (registration == null) { + val unregisteredEvent = + RuntimeEvent.of(RuntimeEvents.SCHEDULER_DELAY_WAKEUP_UNREGISTERED) { + set(RuntimeAttributes.WORKFLOW_ID_BARE, wakeup.runId) + set(RuntimeAttributes.STEP_ID, wakeup.stepId) + set(RuntimeAttributes.RESUME_AT_EPOCH_MILLIS, wakeup.resumeAt.toEpochMilli()) + } isolatedObserver.onWorkflowEvent( workflowName = "unknown", name = unregisteredEvent.name, @@ -207,13 +267,15 @@ class ScheduledWorkflowTimer( store.releaseDelayWakeupClaim(wakeup.runId, wakeup.stepId, wakeup.claimToken) return } - val context = WorkflowContext( - workflowId = wakeup.runId, - attributes = mapOf( - RuntimeAttributes.SCHEDULER_DELAY_STEP_ID.name to wakeup.stepId, - RuntimeAttributes.SCHEDULER_DELAY_RESUME_AT.name to wakeup.resumeAt.toEpochMilli(), - ), - ) + val context = + WorkflowContext( + workflowId = wakeup.runId, + attributes = + mapOf( + RuntimeAttributes.SCHEDULER_DELAY_STEP_ID.name to wakeup.stepId, + RuntimeAttributes.SCHEDULER_DELAY_RESUME_AT.name to wakeup.resumeAt.toEpochMilli(), + ), + ) try { registration.resume(context) synchronized(monitor) { @@ -226,51 +288,89 @@ class ScheduledWorkflowTimer( } } - private fun hasDelayRegistrations(): Boolean = synchronized(monitor) { - delayRegistrations.isNotEmpty() - } - - private fun registrationFor(scheduleId: String): ScheduledWorkflowRegistration<*, *>? = synchronized(monitor) { - registrations[scheduleId] - } + private fun hasDelayRegistrations(): Boolean = + synchronized(monitor) { + delayRegistrations.isNotEmpty() + } - private fun scheduledTickContext(tick: ClaimedScheduledTick): WorkflowContext = WorkflowContext( - attributes = mapOf( - RuntimeAttributes.SCHEDULE_TICK_ID.name to tick.tickId, - RuntimeAttributes.SCHEDULE_SCHEDULE_ID.name to tick.scheduleId, - RuntimeAttributes.SCHEDULE_SCHEDULED_FIRE_AT.name to tick.scheduledFireAt.toEpochMilli(), - ), - ) + private fun registrationFor(scheduleId: String): ScheduledWorkflowRegistration<*, *>? = + synchronized(monitor) { + registrations[scheduleId] + } private data class ScheduledWorkflowRegistration( val workflow: Workflow, val initialState: suspend (ClaimedScheduledTick) -> S, val observer: WorkflowObserver, val persistence: WorkflowPersistence?, + val governed: GovernedScheduleRegistration? = null, ) { suspend fun run( tick: ClaimedScheduledTick, context: WorkflowContext, + governedRun: GovernedRun?, ) { - workflow.run( - initialState = initialState(tick), - context = context, - observer = observer, - persistence = persistence, - ) + val state = initialState(tick) + if (governedRun == null) { + workflow.run( + initialState = state, + context = context, + observer = observer, + persistence = persistence, + ) + } else { + workflow.run( + initialState = state, + run = governedRun, + observer = observer, + persistence = persistence, + ) + } } + suspend fun resume(context: WorkflowContext) { val persistence = persistence ?: return - workflow.resume( - context = context, - observer = observer, - persistence = persistence, - ) + // Continuation RECOVERS identity from the run's own durable witness and never + // rebuilds it: a schedule binding or configuration change between suspension + // and wake-up must not rewrite the identity of an existing run. Attribution + // that is partial or malformed fails closed here instead of degrading into a + // legacy un-attributed wake-up. + val governedRun = persistence.recoverGovernedRun(workflow.name, context.workflowId) + if (governedRun == null) { + workflow.resume( + context = context, + observer = observer, + persistence = persistence, + ) + } else { + workflow.resume( + run = governedRun, + observer = observer, + persistence = persistence, + ) + } } } } private fun scheduleId(workflowName: String): String = "workflow:$workflowName" +/** + * Operational tick identity (schedule id, tick id, fire time) — deliberately NOT workload + * attribution: these identify the tick, never the run's governed identity. + */ +private fun scheduledTickAttributes(tick: ClaimedScheduledTick): Map = + mapOf( + RuntimeAttributes.SCHEDULE_TICK_ID.name to tick.tickId, + RuntimeAttributes.SCHEDULE_SCHEDULE_ID.name to tick.scheduleId, + RuntimeAttributes.SCHEDULE_SCHEDULED_FIRE_AT.name to tick.scheduledFireAt.toEpochMilli(), + ) +/** Why a tick was skipped: the schedule declares governance but no binding is durable. */ +private const val GOVERNED_BINDING_MISSING_REASON = "governed_schedule_binding_missing" +/** Operational tick context — schedule/tick identity, never workload attribution. */ +private fun scheduledTickContext(tick: ClaimedScheduledTick): WorkflowContext = + WorkflowContext( + attributes = scheduledTickAttributes(tick), + ) diff --git a/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/WorkflowSchedulerStore.kt b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/WorkflowSchedulerStore.kt index d39873831..7f925b78c 100644 --- a/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/WorkflowSchedulerStore.kt +++ b/tramai-scheduler/src/main/kotlin/dev/tramai/scheduler/WorkflowSchedulerStore.kt @@ -51,55 +51,102 @@ data class ClaimedDelayWakeup( val claimExpiresAt: Instant, ) +/** + * Governed declaration of one schedule (0.7.1d): the deployment whose runs it creates, plus + * the durable binding authority that survives restart. + */ +data class GovernedScheduleRegistration( + val deploymentIdentity: dev.tramai.core.identity.WorkloadDeploymentIdentity, + val bindingStore: GovernedScheduleBindingStore, +) + +/** + * Durable binding of a governed schedule to the workload deployment it runs (0.7.1d). + * + * A schedule is not a run, so it never carries a [dev.tramai.core.identity.GovernedRunIdentity]: + * it carries the deployment whose runs it will create. Every tick creates a FRESH run id, + * so `deployment + freshRunId = GovernedRunIdentity` at execution time. + */ +data class GovernedScheduleBinding( + val scheduleId: String, + val deploymentIdentity: dev.tramai.core.identity.WorkloadDeploymentIdentity, +) + +/** + * Optional durable capability: governed schedule bindings. + * + * Kept separate from [ScheduleRecord] on purpose — the schedule's public shape does not + * change, and a schedule that was declared governed stays governed even if a later + * registration forgets to declare its deployment: silently downgrading a governed + * schedule to ungoverned ticks is exactly the attribution loss 0.7.1d removes. + */ +interface GovernedScheduleBindingStore { + suspend fun putGovernedScheduleBinding(binding: GovernedScheduleBinding) + + suspend fun getGovernedScheduleBinding(scheduleId: String): GovernedScheduleBinding? +} + interface WorkflowSchedulerStore : WorkflowDelayWakeupScheduler { suspend fun upsertSchedule(schedule: ScheduleRecord) + suspend fun getSchedule(scheduleId: String): ScheduleRecord? + suspend fun listScheduleStatus(): List + suspend fun claimDueTicks( now: Instant, ownerId: String, claimDuration: Duration, limit: Int, ): List + suspend fun markTickStarted( tickId: String, claimToken: String, runId: String, ) + suspend fun releaseTickClaim( tickId: String, claimToken: String, ) + suspend fun markTickCompleted( tickId: String, claimToken: String, ) + suspend fun markTickSkipped( tickId: String, claimToken: String, reason: String, ) + suspend fun markTickMisfired( tickId: String, claimToken: String, reason: String, ) + override suspend fun scheduleDelayWakeup( runId: String, stepId: String, resumeAt: Instant, ) + suspend fun claimDueDelayWakeups( now: Instant, ownerId: String, claimDuration: Duration, limit: Int, ): List + suspend fun releaseDelayWakeupClaim( runId: String, stepId: String, claimToken: String, ) + suspend fun markDelayWakeupCompleted( runId: String, stepId: String, diff --git a/tramai-scheduler/src/test/kotlin/dev/tramai/scheduler/GovernedScheduleAttributionTest.kt b/tramai-scheduler/src/test/kotlin/dev/tramai/scheduler/GovernedScheduleAttributionTest.kt new file mode 100644 index 000000000..1cfc76f23 --- /dev/null +++ b/tramai-scheduler/src/test/kotlin/dev/tramai/scheduler/GovernedScheduleAttributionTest.kt @@ -0,0 +1,375 @@ +package dev.tramai.scheduler + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.orchestration.InMemoryWorkflowCheckpointStore +import dev.tramai.orchestration.NoOpWorkflowObserver +import dev.tramai.orchestration.Workflow +import dev.tramai.orchestration.WorkflowPersistence +import dev.tramai.orchestration.WorkflowStateCodec +import dev.tramai.orchestration.recoverGovernedRun +import dev.tramai.orchestration.workflow +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.util.concurrent.TimeUnit + +/** + * 0.7.1d governed scheduler attribution. + * + * A schedule carries a DeploymentIdentity, never a run identity: a schedule is not a run. + * Each tick therefore creates a FRESH run id over the same deployment, while a delayed + * wake-up RECOVERS the exact identity of the run it continues — from that run's own durable + * checkpoint, never from whatever binding exists at wake-up time. + */ +class GovernedScheduleAttributionTest { + private companion object { + const val WORKFLOW_NAME = "governed-scheduled" + const val GOVERNED_DEPLOYMENT_KEY = "checkpoint.identity.deployment" + + val stateCodec = + object : WorkflowStateCodec { + override fun encode(state: String): String = state + + override fun decode(payload: String): String = payload + } + } + + private class MutableClock( + private var current: Instant, + ) : Clock() { + fun advanceBy(duration: Duration) { + current = current.plus(duration) + } + + override fun instant(): Instant = current + + override fun getZone(): ZoneId = ZoneId.of("UTC") + + override fun withZone(zone: ZoneId): Clock = this + } + + private class Fixture { + val clock = MutableClock(Instant.parse("2026-09-13T09:00:00Z")) + val schedules = InMemoryWorkflowSchedulerStore() + val bindings = InMemoryGovernedScheduleBindingStore() + + /** + * The timer discovers the binding capability from the STORE, so the durable binding + * is consulted independently of whatever the current registration declares. + */ + val store = CompositeWorkflowSchedulerStore(schedules, bindings) + val checkpoints = InMemoryWorkflowCheckpointStore() + val completedStates = mutableListOf() + val persistence = + WorkflowPersistence( + checkpointStore = checkpoints, + stateCodec = stateCodec, + delayWakeupScheduler = store, + deleteCheckpointOnCompletion = false, + ) + val workflow: Workflow = + workflow(WORKFLOW_NAME) { + schedule = CronSchedule.parse("* * * * *", ZoneId.of("UTC")) + delayStep("wait", 60, TimeUnit.SECONDS) + localStep("after") { state, _ -> + completedStates += state + "$state-after" + } + }.build(clock = clock) { it } + val timer = newTimer("owner-1") + + /** A second process over the same durable stores: restart, not a new schedule. */ + fun newTimer(owner: String): ScheduledWorkflowTimer = + ScheduledWorkflowTimer( + store = store, + clock = clock, + ownerId = owner, + pollInterval = Duration.ofMillis(1), + claimDuration = Duration.ofMinutes(5), + misfireThreshold = Duration.ofHours(1), + ) + + /** The schedule id the timer derives from the workflow name. */ + suspend fun scheduleId(): String = store.listScheduleStatus().single().scheduleId + + suspend fun registerGoverned(deployment: WorkloadDeploymentIdentity) { + timer.register( + workflow = workflow, + initialState = { "seed" }, + observer = NoOpWorkflowObserver, + persistence = persistence, + governed = GovernedScheduleRegistration(deployment, bindings), + ) + } + + /** Claims a tick by making the schedule due, then polling. */ + suspend fun fireTick(): String { + clock.advanceBy(Duration.ofMinutes(1)) + timer.pollOnce() + return store.listScheduleStatus().single().lastRunId!! + } + + suspend fun claimDelayedRun(): String = fireTick() + } + + private fun deployment( + deploymentId: String = "eu-west-amsterdam-01", + configurationVersion: String = "17", + ): WorkloadDeploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId(deploymentId), + ) + + /** A declaration that claims governance while nothing durable is written. */ + private class UnavailableBindingStore : GovernedScheduleBindingStore { + override suspend fun putGovernedScheduleBinding(binding: GovernedScheduleBinding) = Unit + + override suspend fun getGovernedScheduleBinding(scheduleId: String): GovernedScheduleBinding? = null + } + + /** A binding authority that is down: the write fails. */ + private class FailingBindingStore : GovernedScheduleBindingStore { + override suspend fun putGovernedScheduleBinding(binding: GovernedScheduleBinding) { + error("binding store unavailable") + } + + override suspend fun getGovernedScheduleBinding(scheduleId: String): GovernedScheduleBinding? = null + } + + @Test + fun `a governed tick runs under the bound deployment with a fresh run id`() { + runBlocking { + val fixture = Fixture() + fixture.registerGoverned(deployment()) + + val runId = fixture.fireTick() + + val recovered = fixture.persistence.recoverGovernedRun(WORKFLOW_NAME, runId) + assertThat(recovered).withFailMessage("a governed tick must persist its attribution").isNotNull() + assertThat(recovered!!.identity.deployment).isEqualTo(deployment()) + assertThat(recovered.identity.runId.value).isEqualTo(runId) + } + } + + @Test + fun `two ticks share the deployment and never share a run id`() { + runBlocking { + val fixture = Fixture() + fixture.registerGoverned(deployment()) + + val firstRunId = fixture.fireTick() + val secondRunId = fixture.fireTick() + + assertThat(secondRunId).withFailMessage("every tick is a new execution").isNotEqualTo(firstRunId) + val first = fixture.persistence.recoverGovernedRun(WORKFLOW_NAME, firstRunId)!! + val second = fixture.persistence.recoverGovernedRun(WORKFLOW_NAME, secondRunId)!! + assertThat(first.identity.deployment).isEqualTo(second.identity.deployment) + assertThat(first.identity).isNotEqualTo(second.identity) + } + } + + @Test + fun `a delayed wake-up continues the exact original identity even after the binding changes`() { + runBlocking { + val fixture = Fixture() + fixture.registerGoverned(deployment()) + val runId = fixture.fireTick() + + // The deployment binding changes while the run is suspended: a governed + // continuation must NOT adopt the new binding for an existing run. + fixture.bindings.putGovernedScheduleBinding( + GovernedScheduleBinding( + scheduleId = fixture.scheduleId(), + deploymentIdentity = deployment(deploymentId = "eu-central-frankfurt-01"), + ), + ) + + fixture.clock.advanceBy(Duration.ofMinutes(2)) + fixture.timer.pollOnce() + + assertThat(fixture.completedStates) + .withFailMessage("the delayed run must resume and complete") + .containsExactly("seed") + val persisted = fixture.checkpoints.load(WORKFLOW_NAME, runId)!! + assertThat(persisted.metadata[GOVERNED_DEPLOYMENT_KEY]) + .withFailMessage("the original attribution must not be rewritten") + .isEqualTo("eu-west-amsterdam-01") + } + } + + @Test + fun `a governed delayed run with corrupted attribution fails closed`() { + runBlocking { + val fixture = Fixture() + fixture.registerGoverned(deployment()) + val runId = fixture.fireTick() + + val persisted = fixture.checkpoints.load(WORKFLOW_NAME, runId)!! + fixture.checkpoints.save( + persisted.copy(metadata = persisted.metadata - GOVERNED_DEPLOYMENT_KEY), + expectedRevision = persisted.revision, + ) + + fixture.clock.advanceBy(Duration.ofMinutes(2)) + + assertThatThrownBy { runBlocking { fixture.timer.pollOnce() } } + .isInstanceOf(dev.tramai.orchestration.WorkflowCheckpointCorruptionException::class.java) + assertThat(fixture.completedStates) + .withFailMessage("a partially attributed run must never resume as legacy") + .isEmpty() + } + } + + @Test + fun `a schedule without a governed binding still runs un-attributed`() { + runBlocking { + val fixture = Fixture() + fixture.timer.register( + workflow = fixture.workflow, + initialState = { "seed" }, + observer = NoOpWorkflowObserver, + persistence = fixture.persistence, + ) + + val runId = fixture.fireTick() + + assertThat(fixture.persistence.recoverGovernedRun(WORKFLOW_NAME, runId)).isNull() + } + } + + @Test + fun `an ordinary re-registration cannot downgrade a governed schedule`() { + runBlocking { + val fixture = Fixture() + fixture.registerGoverned(deployment()) + val firstRunId = fixture.fireTick() + + // The in-memory registration is replaced by one that declares no governance. The + // durable binding still exists, so the next tick must stay governed. + fixture.timer.register( + workflow = fixture.workflow, + initialState = { "seed" }, + observer = NoOpWorkflowObserver, + persistence = fixture.persistence, + ) + val secondRunId = fixture.fireTick() + + assertThat(secondRunId).isNotEqualTo(firstRunId) + val second = fixture.persistence.recoverGovernedRun(WORKFLOW_NAME, secondRunId) + assertThat(second) + .withFailMessage("a durable binding must survive an ordinary re-registration") + .isNotNull() + assertThat(second!!.identity.deployment).isEqualTo(deployment()) + } + } + + @Test + fun `a restarted timer cannot downgrade a surviving durable binding`() { + runBlocking { + val fixture = Fixture() + fixture.registerGoverned(deployment()) + val firstRunId = fixture.fireTick() + + // Restart: a fresh process over the same durable stores registers the workflow the + // ordinary way. The binding survives, so its ticks are still governed. + val restarted = fixture.newTimer("owner-2") + restarted.register( + workflow = fixture.workflow, + initialState = { "seed" }, + observer = NoOpWorkflowObserver, + persistence = fixture.persistence, + ) + + fixture.clock.advanceBy(Duration.ofMinutes(1)) + restarted.pollOnce() + + val secondRunId = + fixture.store + .listScheduleStatus() + .single() + .lastRunId!! + assertThat(secondRunId).isNotEqualTo(firstRunId) + val second = fixture.persistence.recoverGovernedRun(WORKFLOW_NAME, secondRunId) + assertThat(second) + .withFailMessage("a surviving durable binding must not be downgraded by a restarted timer") + .isNotNull() + assertThat(second!!.identity.deployment).isEqualTo(deployment()) + } + } + + @Test + fun `an explicitly governed registration without a durable binding executes nothing`() { + runBlocking { + val fixture = Fixture() + val declared = deployment(deploymentId = "eu-central-frankfurt-01") + fixture.timer.register( + workflow = fixture.workflow, + initialState = { "seed" }, + observer = NoOpWorkflowObserver, + persistence = fixture.persistence, + governed = GovernedScheduleRegistration(declared, UnavailableBindingStore()), + ) + + fixture.clock.advanceBy(Duration.ofMinutes(1)) + fixture.timer.pollOnce() + + assertThat(fixture.completedStates) + .withFailMessage("a governed schedule with no durable binding must execute nothing") + .isEmpty() + assertThat( + fixture.store + .listScheduleStatus() + .single() + .lastRunId, + ).withFailMessage("nothing may be attributed, not even to the declared deployment") + .isNull() + } + } + + @Test + fun `a failed binding write publishes no schedule and executes nothing`() { + runBlocking { + val fixture = Fixture() + assertThatThrownBy { + runBlocking { + fixture.timer.register( + workflow = fixture.workflow, + initialState = { "seed" }, + observer = NoOpWorkflowObserver, + persistence = fixture.persistence, + governed = GovernedScheduleRegistration(deployment(), FailingBindingStore()), + ) + } + }.isInstanceOf(IllegalStateException::class.java) + + assertThat(fixture.store.getSchedule("workflow:$WORKFLOW_NAME")) + .withFailMessage("a failed binding write must not leave an executable schedule") + .isNull() + + fixture.clock.advanceBy(Duration.ofMinutes(1)) + fixture.timer.pollOnce() + + assertThat(fixture.completedStates) + .withFailMessage("an unbound governed schedule must never become an ungoverned run") + .isEmpty() + } + } +} diff --git a/tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceAttribution.kt b/tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceAttribution.kt new file mode 100644 index 000000000..4b6248890 --- /dev/null +++ b/tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceAttribution.kt @@ -0,0 +1,79 @@ +package dev.tramai.security.evidence + +/** + * 0.7.1d: governed-run attribution at the evidence boundary. + * + * Why this stays stringly-typed: [RuntimeEvidenceRecord] is a public runtime-evidence.v1 + * contract with an allowlisted, per-family-validated metadata extension point. Adding a typed + * identity field would change that public ABI and couple tramai-security to the core identity + * model, so the governed identity travels as reserved metadata keys — the same mechanism already + * used for checkpoint attribution — and the modules that actually hold the typed identity + * translate it at their own boundary. + * + * The run component is deliberately absent from [metadataKeys]: [RuntimeEvidenceRecord.workflowRunId] + * is the single canonical run identifier, and duplicating it here would create a second run + * identifier that could disagree with the first. Only the deployment tuple is added, so + * `workflowRunId` plus these five keys together are the complete governed identity. + * + * Atomicity: evidence metadata is either wholly attributed or not attributed at all. Zero keys is + * legacy evidence and stays valid; a partially present tuple is corruption, never a silent + * downgrade, and a complete tuple without the canonical run identity is likewise rejected. + */ +internal object RuntimeEvidenceAttribution { + /** Deployment-tuple keys that, with a non-blank `workflowRunId`, form a complete governed identity. */ + val metadataKeys = + setOf( + "identity.workloadId", + "identity.configurationId", + "identity.configurationVersion", + "identity.environmentId", + "identity.deploymentId", + ) + + /** + * Overlays framework-owned [attribution] on top of [metadata] so caller/event metadata can + * never substitute or shadow identity. + * + * Callers must overlay before the canonical payload digest is computed, so the identity + * participates in the digest the record advertises. + */ + fun merge( + metadata: Map, + attribution: Map, + ): Map { + val unknown = attribution.keys - metadataKeys + require(unknown.isEmpty()) { + "evidence attribution may only carry governed identity keys, got: $unknown" + } + return metadata + attribution + } + + /** + * Validates attribution atomically against the canonical run identity: zero keys is legacy, + * all five keys require a non-blank `workflowRunId`, and anything in between is rejected. + */ + fun validate( + workflowRunId: String?, + metadata: Map, + ) { + val present = metadata.keys.filter { it in metadataKeys } + when { + present.isEmpty() -> { + return + } + + present.size != metadataKeys.size -> { + reject("partial governed attribution: ${present.sorted()}") + } + + workflowRunId.isNullOrBlank() -> { + reject("attribution without the canonical workflowRunId") + } + } + } + + /** + * Refuses ambiguous evidence rather than emitting a record whose attribution cannot be trusted. + */ + private fun reject(cause: String): Nothing = error("$cause; refusing to emit ambiguous governed evidence") +} diff --git a/tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidator.kt b/tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidator.kt index 0b802d89b..3280929c8 100644 --- a/tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidator.kt +++ b/tramai-security/src/main/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidator.kt @@ -22,15 +22,21 @@ package dev.tramai.security.evidence */ internal object RuntimeEvidenceContractValidator { private val APPROVAL_REASON_CODES = setOf("approval-approved", "approval-denied") - private val ROUTING_REASON_CODES = setOf( - "provider-selected", - "provider-fallback", - "provider-blocked", - ) - private val ALLOWED_FALLBACK_REASONS = setOf( - "provider-failure", "streaming-startup-failure", "circuit-breaker-open", - "model-registry-blocked", "policy-blocked", "no-route", - ) + private val ROUTING_REASON_CODES = + setOf( + "provider-selected", + "provider-fallback", + "provider-blocked", + ) + private val ALLOWED_FALLBACK_REASONS = + setOf( + "provider-failure", + "streaming-startup-failure", + "circuit-breaker-open", + "model-registry-blocked", + "policy-blocked", + "no-route", + ) private val TOOL_RISK_LEVELS = setOf("LOW", "MEDIUM", "HIGH", "CRITICAL") @@ -63,7 +69,10 @@ internal object RuntimeEvidenceContractValidator { } } - private fun validateEventId(record: RuntimeEvidenceRecord, seenEventIds: MutableSet) { + private fun validateEventId( + record: RuntimeEvidenceRecord, + seenEventIds: MutableSet, + ) { require(record.eventId.isNotBlank()) { "eventId must not be blank" } @@ -74,11 +83,12 @@ internal object RuntimeEvidenceContractValidator { } private fun validateDecisionKind(record: RuntimeEvidenceRecord) { - val allowedKinds = requireNotNull( - RuntimeEvidenceBundleWriter.ALLOWED_DECISION_KINDS[record.eventType] - ) { - "No allowed decision kinds defined for event type: ${record.eventType}" - } + val allowedKinds = + requireNotNull( + RuntimeEvidenceBundleWriter.ALLOWED_DECISION_KINDS[record.eventType], + ) { + "No allowed decision kinds defined for event type: ${record.eventType}" + } require(record.decision.kind in allowedKinds) { "Invalid decision.kind '${record.decision.kind}' for event type " + "'${record.eventType}'. Allowed: $allowedKinds" @@ -86,11 +96,12 @@ internal object RuntimeEvidenceContractValidator { } private fun validateSourceComponent(record: RuntimeEvidenceRecord) { - val expectedComponent = requireNotNull( - RuntimeEvidenceBundleWriter.EXPECTED_SOURCE_COMPONENTS[record.eventType] - ) { - "No expected source component defined for event type: ${record.eventType}" - } + val expectedComponent = + requireNotNull( + RuntimeEvidenceBundleWriter.EXPECTED_SOURCE_COMPONENTS[record.eventType], + ) { + "No expected source component defined for event type: ${record.eventType}" + } require(record.source.component == expectedComponent) { "source.component must be '$expectedComponent' " + "for event type '${record.eventType}', " + @@ -109,11 +120,19 @@ internal object RuntimeEvidenceContractValidator { } private fun validateMetadata(record: RuntimeEvidenceRecord) { - val allowedKeys = requireNotNull( - RuntimeEvidenceBundleWriter.ALLOWED_METADATA_KEYS[record.eventType] - ) { - "No allowed metadata keys defined for event type: ${record.eventType}" - } + val familyAllowedKeys = + requireNotNull( + RuntimeEvidenceBundleWriter.ALLOWED_METADATA_KEYS[record.eventType], + ) { + "No allowed metadata keys defined for event type: ${record.eventType}" + } + + // Governed attribution is cross-family framework metadata, so it composes here rather than + // being duplicated into each family vocabulary above. Validation is atomic: a partial + // tuple is corruption, never a silent downgrade to legacy evidence. + val allowedKeys = familyAllowedKeys + RuntimeEvidenceAttribution.metadataKeys + + RuntimeEvidenceAttribution.validate(record.workflowRunId, record.metadata) for (key in record.metadata.keys) { require(key in allowedKeys) { @@ -153,10 +172,14 @@ internal object RuntimeEvidenceContractValidator { } } } + "provider.route" -> { for (digestKey in listOf( - "requestedModelDigest", "selectedProviderDigest", "selectedModelDigest", - "previousProviderDigest", "previousModelDigest", + "requestedModelDigest", + "selectedProviderDigest", + "selectedModelDigest", + "previousProviderDigest", + "previousModelDigest", )) { val value = record.metadata[digestKey] if (value != null) { @@ -186,6 +209,7 @@ internal object RuntimeEvidenceContractValidator { } } } + "tool.permission" -> { val toolName = record.metadata["toolName"] require(!toolName.isNullOrBlank()) { @@ -218,12 +242,14 @@ internal object RuntimeEvidenceContractValidator { "for approval.decision: $reasonCode" } } + "provider.route" -> { require(reasonCode in ROUTING_REASON_CODES) { "decision.reasonCode must be one of $ROUTING_REASON_CODES " + "for provider.route: $reasonCode" } } + "policy.decision", "tool.permission" -> { require(RuntimeEvidenceBundleWriter.REASON_CODE_REGEX.matches(reasonCode)) { "decision.reasonCode must match ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$: " + diff --git a/tramai-security/src/test/kotlin/dev/tramai/security/evidence/RuntimeEvidenceAttributionTest.kt b/tramai-security/src/test/kotlin/dev/tramai/security/evidence/RuntimeEvidenceAttributionTest.kt new file mode 100644 index 000000000..45452a2ed --- /dev/null +++ b/tramai-security/src/test/kotlin/dev/tramai/security/evidence/RuntimeEvidenceAttributionTest.kt @@ -0,0 +1,102 @@ +package dev.tramai.security.evidence + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * 0.7.1d discriminators for governed attribution at the evidence boundary. + * + * These prove the atomicity rule (nothing, or everything, or fail) and the authoritative-overlay + * rule that stops caller-supplied metadata from impersonating identity. The run component is + * checked to stay canonical: it is the record's own `workflowRunId`, never a metadata key. + */ +class RuntimeEvidenceAttributionTest { + private val complete = + mapOf( + "identity.workloadId" to "wl-1", + "identity.configurationId" to "cfg-1", + "identity.configurationVersion" to "v3", + "identity.environmentId" to "env-1", + "identity.deploymentId" to "dep-1", + ) + + @Test + fun `legacy evidence with no identity keys stays valid`() { + RuntimeEvidenceAttribution.validate(null, mapOf("toolName" to "search")) + RuntimeEvidenceAttribution.validate("run-1", mapOf("toolName" to "search")) + RuntimeEvidenceAttribution.validate(null, emptyMap()) + } + + @Test + fun `complete attribution with the canonical run identity is accepted`() { + RuntimeEvidenceAttribution.validate("run-1", complete) + } + + @Test + fun `every partial attribution is rejected rather than treated as legacy`() { + val keys = RuntimeEvidenceAttribution.metadataKeys.sorted() + for (size in 1 until keys.size) { + val partial = keys.take(size).associateWith { "value" } + assertFailsWith("$size of ${keys.size} keys must fail closed") { + RuntimeEvidenceAttribution.validate("run-1", partial) + } + } + } + + @Test + fun `complete attribution without the canonical run identity is rejected`() { + assertFailsWith { + RuntimeEvidenceAttribution.validate(null, complete) + } + assertFailsWith { + RuntimeEvidenceAttribution.validate(" ", complete) + } + } + + @Test + fun `framework identity overrides colliding caller metadata`() { + val merged = + RuntimeEvidenceAttribution.merge( + mapOf("identity.workloadId" to "caller-supplied", "toolName" to "search"), + mapOf("identity.workloadId" to "wl-authoritative"), + ) + assertEquals("wl-authoritative", merged["identity.workloadId"]) + assertEquals("search", merged["toolName"]) + } + + @Test + fun `merge preserves caller metadata and rejects non-identity attribution keys`() { + val merged = RuntimeEvidenceAttribution.merge(mapOf("riskLevel" to "LOW"), complete) + assertEquals("LOW", merged["riskLevel"]) + assertTrue(RuntimeEvidenceAttribution.metadataKeys.all { it in merged }) + + assertFailsWith { + RuntimeEvidenceAttribution.merge(emptyMap(), mapOf("workflowRunId" to "run-2")) + } + } + + @Test + fun `the run component is never carried as a metadata key`() { + assertTrue( + RuntimeEvidenceAttribution.metadataKeys.none { it.contains("runId", ignoreCase = true) }, + "runId must stay canonical on the record, not duplicated in metadata", + ) + } + + @Test + fun `attribution composes at the validator and leaves the writer vocabulary untouched`() { + val families = RuntimeEvidenceBundleWriter.ALLOWED_METADATA_KEYS + assertEquals( + setOf("policy.decision", "approval.decision", "provider.route", "tool.permission"), + families.keys.toSet(), + ) + families.forEach { (family, familyKeys) -> + assertTrue( + RuntimeEvidenceAttribution.metadataKeys.none { it in familyKeys }, + "$family keeps its family-specific vocabulary; attribution is composed separately", + ) + } + } +} diff --git a/tramai-security/src/test/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidatorTest.kt b/tramai-security/src/test/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidatorTest.kt new file mode 100644 index 000000000..efaa38a5d --- /dev/null +++ b/tramai-security/src/test/kotlin/dev/tramai/security/evidence/RuntimeEvidenceContractValidatorTest.kt @@ -0,0 +1,295 @@ +package dev.tramai.security.evidence + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant + +/** + * Contract tests for the family-specific metadata rules of [RuntimeEvidenceContractValidator]. + * + * The validator is what stops the writer from emitting a bundle the verifier rejects, so the + * rules are asserted as a contract rather than as coverage: a record that violates a rule must + * be rejected with a message naming that rule, and a record that satisfies it must pass. The + * positive cases are not decoration — several rules fail closed on valid input when inverted, + * so an accepted record is the discriminator that proves the check is not simply absent. + */ +class RuntimeEvidenceContractValidatorTest { + private val timestamp = Instant.parse("2026-09-25T10:00:00Z") + private val subjectDigest = "sha256:" + "a".repeat(64) + private val payloadDigest = "sha256:" + "b".repeat(64) + + // ─── approval.decision: digest and numeric metadata ───────────────── + + @Test + fun `approval metadata accepts well formed digests and numeric fields`() { + validate( + approvalRecord( + metadata = + mapOf( + "reasonDigest" to subjectDigest, + "eventKeyDigest" to payloadDigest, + "reasonLength" to "0", + "approvalVersion" to "3", + ), + ), + ) + } + + @Test + fun `approval metadata accepts an absent optional digest`() { + validate(approvalRecord(metadata = mapOf("approvalVersion" to "1"))) + } + + @Test + fun `approval metadata rejects a malformed reasonDigest`() { + rejects( + "Metadata reasonDigest must match", + approvalRecord(metadata = mapOf("reasonDigest" to "sha256:not-a-digest")), + ) + } + + @Test + fun `approval metadata rejects a malformed eventKeyDigest`() { + rejects( + "Metadata eventKeyDigest must match", + approvalRecord(metadata = mapOf("eventKeyDigest" to "deadbeef")), + ) + } + + @Test + fun `approval metadata rejects a negative reasonLength`() { + rejects( + "Metadata reasonLength must be a non-negative integer", + approvalRecord(metadata = mapOf("reasonLength" to "-1")), + ) + } + + @Test + fun `approval metadata rejects a non numeric approvalVersion`() { + rejects( + "Metadata approvalVersion must be a non-negative integer", + approvalRecord(metadata = mapOf("approvalVersion" to "v1")), + ) + } + + // ─── tool.permission: mandatory toolName, known enforcement point ─── + + @Test + fun `tool permission metadata accepts a known enforcement point and risk level`() { + validate( + toolPermissionRecord( + metadata = + mapOf( + "toolName" to "weather-tool", + "enforcementPoint" to "BEFORE_TOOL_EXECUTION", + "riskLevel" to "HIGH", + ), + ), + ) + } + + @Test + fun `tool permission metadata accepts an absent optional risk level`() { + validate( + toolPermissionRecord( + metadata = + mapOf( + "toolName" to "weather-tool", + "enforcementPoint" to "BEFORE_TOOL_EXPOSURE", + ), + ), + ) + } + + @Test + fun `tool permission metadata rejects a missing toolName`() { + rejects( + "Metadata toolName is required", + toolPermissionRecord(metadata = mapOf("enforcementPoint" to "BEFORE_TOOL_EXECUTION")), + ) + } + + @Test + fun `tool permission metadata rejects a blank toolName`() { + rejects( + "Metadata toolName is required", + toolPermissionRecord( + metadata = + mapOf( + "toolName" to " ", + "enforcementPoint" to "BEFORE_TOOL_EXECUTION", + ), + ), + ) + } + + @Test + fun `tool permission metadata rejects an unknown enforcement point`() { + rejects( + "Metadata enforcementPoint must be one of", + toolPermissionRecord( + metadata = + mapOf( + "toolName" to "weather-tool", + "enforcementPoint" to "BEFORE_TOOL_RETRY", + ), + ), + ) + } + + @Test + fun `tool permission metadata rejects an unknown risk level`() { + rejects( + "Metadata riskLevel must be one of", + toolPermissionRecord( + metadata = + mapOf( + "toolName" to "weather-tool", + "enforcementPoint" to "BEFORE_TOOL_RESULT_REINJECTION", + "riskLevel" to "SEVERE", + ), + ), + ) + } + + @Test + fun `tool permission metadata rejects a key that is not allowlisted`() { + rejects( + "is not allowlisted", + toolPermissionRecord( + metadata = + mapOf( + "toolName" to "weather-tool", + "enforcementPoint" to "BEFORE_TOOL_EXECUTION", + "riskScore" to "9", + ), + ), + ) + } + + // ─── governed attribution composes into the family vocabulary ─────── + + @Test + fun `complete governed attribution metadata is accepted`() { + // Attribution is framework metadata composed cross-family rather than duplicated into each + // family vocabulary, so the accepted case is the discriminator proving the composition is + // present: an empty attribution vocabulary rejects the identity tuple as unknown keys. + validate( + approvalRecord( + metadata = + mapOf( + "identity.workloadId" to "claims", + "identity.configurationId" to "claims-prod", + "identity.configurationVersion" to "17", + "identity.environmentId" to "production", + "identity.deploymentId" to "eu-west-amsterdam-01", + ), + ), + ) + } + + @Test + fun `a partial governed attribution is corruption rather than legacy`() { + val failure = + assertThrows { + RuntimeEvidenceContractValidator.validate( + listOf(approvalRecord(metadata = mapOf("identity.workloadId" to "claims"))), + ) + } + assertTrue( + failure.message.orEmpty().contains("partial governed attribution"), + "expected the atomicity rule to reject the partial tuple, got: ${failure.message}", + ) + } + + @Test + fun `approval metadata accepts a zero approvalVersion`() { + validate(approvalRecord(metadata = mapOf("approvalVersion" to "0"))) + } + + @Test + fun `provider route metadata accepts a zero attempt and route index`() { + // The first attempt of a route is 0; a strict positivity rule would reject ordinary evidence. + validate(providerRouteRecord(metadata = mapOf("attempt" to "0", "routeIndex" to "0"))) + } + + // ─── reason codes are family-scoped ───────────────────────────────── + + @Test + fun `approval rejects a reason code that belongs to another family`() { + rejects( + "decision.reasonCode must be one of", + approvalRecord(metadata = mapOf("approvalVersion" to "1"), reasonCode = "provider-selected"), + ) + } + + @Test + fun `tool permission rejects a reason code that is not code shaped`() { + rejects( + "decision.reasonCode must match", + toolPermissionRecord( + metadata = + mapOf( + "toolName" to "weather-tool", + "enforcementPoint" to "BEFORE_TOOL_EXECUTION", + ), + reasonCode = "not a code", + ), + ) + } + + // ─── helpers ──────────────────────────────────────────────────────── + + private fun approvalRecord( + metadata: Map, + reasonCode: String = "approval-approved", + ) = record("approval.decision", "approval-control-plane", "APPROVED", reasonCode, metadata) + + private fun toolPermissionRecord( + metadata: Map, + reasonCode: String = "tool_allowed", + ) = record("tool.permission", "policy-engine", "ALLOW", reasonCode, metadata) + + private fun providerRouteRecord( + metadata: Map, + reasonCode: String = "provider-selected", + ) = record("provider.route", "provider-router", "SELECTED", reasonCode, metadata) + + private fun record( + eventType: String, + sourceComponent: String, + kind: String, + reasonCode: String, + metadata: Map, + ) = RuntimeEvidenceRecord( + eventId = "evt-" + eventType.replace('.', '-'), + eventType = eventType, + workflowRunId = "wf-001", + correlationId = "corr-001", + actor = sourceComponent, + createdAt = timestamp, + source = RuntimeEvidenceSource(component = sourceComponent, module = "tramai-security"), + decision = RuntimeEvidenceDecision(kind = kind, reasonCode = reasonCode), + digests = RuntimeEvidenceDigests(subjectDigest = subjectDigest, payloadDigest = payloadDigest), + metadata = metadata, + ) + + private fun validate(record: RuntimeEvidenceRecord) { + RuntimeEvidenceContractValidator.validate(listOf(record)) + } + + private fun rejects( + expectedMessage: String, + record: RuntimeEvidenceRecord, + ) { + val failure = + assertThrows { + RuntimeEvidenceContractValidator.validate(listOf(record)) + } + assertTrue( + failure.message.orEmpty().contains(expectedMessage), + "expected message containing '$expectedMessage', got: ${failure.message}", + ) + } +} diff --git a/tramai-server/api/tramai-server.api b/tramai-server/api/tramai-server.api index 7bb5ab489..3f895842b 100644 --- a/tramai-server/api/tramai-server.api +++ b/tramai-server/api/tramai-server.api @@ -283,7 +283,9 @@ public final class dev/tramai/server/WorkflowEntry { public final fun getStateType ()Lkotlin/reflect/KType; public final fun getWorkflow ()Ldev/tramai/orchestration/Workflow; public fun hashCode ()I + public final fun resume (Ldev/tramai/orchestration/GovernedRun;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun resume (Ldev/tramai/orchestration/WorkflowContext;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun run (Ljava/lang/Object;Ldev/tramai/orchestration/GovernedRun;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun run (Ljava/lang/Object;Ldev/tramai/orchestration/WorkflowContext;Ldev/tramai/orchestration/WorkflowObserver;Ldev/tramai/orchestration/WorkflowPersistence;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun toString ()Ljava/lang/String; } @@ -494,3 +496,87 @@ public final class dev/tramai/server/WorkflowRunSummary { public fun toString ()Ljava/lang/String; } +public final class dev/tramai/server/WorkloadLifecycleCommand { + public fun (Ldev/tramai/controlplane/WorkloadLifecycleState;)V + public final fun component1 ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun copy (Ldev/tramai/controlplane/WorkloadLifecycleState;)Ldev/tramai/server/WorkloadLifecycleCommand; + public static synthetic fun copy$default (Ldev/tramai/server/WorkloadLifecycleCommand;Ldev/tramai/controlplane/WorkloadLifecycleState;ILjava/lang/Object;)Ldev/tramai/server/WorkloadLifecycleCommand; + public fun equals (Ljava/lang/Object;)Z + public final fun getTarget ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/server/WorkloadMetadataCommand { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/server/WorkloadMetadataCommand; + public static synthetic fun copy$default (Ldev/tramai/server/WorkloadMetadataCommand;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/server/WorkloadMetadataCommand; + public fun equals (Ljava/lang/Object;)Z + public final fun getOwner ()Ljava/lang/String; + public final fun getPurpose ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/server/WorkloadRegistrationCommand { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ldev/tramai/server/WorkloadRegistrationCommand; + public static synthetic fun copy$default (Ldev/tramai/server/WorkloadRegistrationCommand;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/server/WorkloadRegistrationCommand; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfigurationFingerprint ()Ljava/lang/String; + public final fun getConfigurationId ()Ljava/lang/String; + public final fun getConfigurationVersion ()Ljava/lang/String; + public final fun getDeploymentId ()Ljava/lang/String; + public final fun getEnvironmentId ()Ljava/lang/String; + public final fun getOwner ()Ljava/lang/String; + public final fun getPurpose ()Ljava/lang/String; + public final fun getWorkloadId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/server/WorkloadRegistrationResponse { + public static final field Companion Ldev/tramai/server/WorkloadRegistrationResponse$Companion; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ldev/tramai/controlplane/WorkloadLifecycleState;JLjava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun component9 ()J + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ldev/tramai/controlplane/WorkloadLifecycleState;JLjava/lang/String;)Ldev/tramai/server/WorkloadRegistrationResponse; + public static synthetic fun copy$default (Ldev/tramai/server/WorkloadRegistrationResponse;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ldev/tramai/controlplane/WorkloadLifecycleState;JLjava/lang/String;ILjava/lang/Object;)Ldev/tramai/server/WorkloadRegistrationResponse; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfigurationId ()Ljava/lang/String; + public final fun getConfigurationVersion ()Ljava/lang/String; + public final fun getConsistency ()Ljava/lang/String; + public final fun getDeploymentId ()Ljava/lang/String; + public final fun getEnvironmentId ()Ljava/lang/String; + public final fun getLifecycle ()Ldev/tramai/controlplane/WorkloadLifecycleState; + public final fun getOwner ()Ljava/lang/String; + public final fun getPurpose ()Ljava/lang/String; + public final fun getStateVersion ()J + public final fun getWorkloadId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/server/WorkloadRegistrationResponse$Companion { + public final fun from (Ldev/tramai/controlplane/WorkloadExposure;Ljava/lang/String;)Ldev/tramai/server/WorkloadRegistrationResponse; + public static synthetic fun from$default (Ldev/tramai/server/WorkloadRegistrationResponse$Companion;Ldev/tramai/controlplane/WorkloadExposure;Ljava/lang/String;ILjava/lang/Object;)Ldev/tramai/server/WorkloadRegistrationResponse; +} + diff --git a/tramai-server/build.gradle.kts b/tramai-server/build.gradle.kts index 2b07e4699..254bf6af1 100644 --- a/tramai-server/build.gradle.kts +++ b/tramai-server/build.gradle.kts @@ -9,6 +9,7 @@ plugins { dependencies { + implementation(project(":tramai-control-plane")) api(project(":tramai-orchestration")) implementation(project(":tramai-scheduler")) @@ -19,5 +20,6 @@ dependencies { testImplementation(libs.h2database) testImplementation(libs.spring.boot.starter.test) + testImplementation(testFixtures(project(":tramai-control-plane"))) } diff --git a/tramai-server/src/main/kotlin/dev/tramai/server/ServerGovernance.kt b/tramai-server/src/main/kotlin/dev/tramai/server/ServerGovernance.kt new file mode 100644 index 000000000..c66ff5a49 --- /dev/null +++ b/tramai-server/src/main/kotlin/dev/tramai/server/ServerGovernance.kt @@ -0,0 +1,144 @@ +package dev.tramai.server + +import dev.tramai.controlplane.WorkloadRegistrationAuthority +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId + +/** + * Server-level governed deployment (0.7.1d). + * + * One server instance runs as ONE workload deployment. Unset is the default and means + * "ungoverned": every legacy path stays byte-identical. + * + * The two halves of the rule are deliberately different: + * + * | | required to match | + * |---|---| + * | new execution | the authoritative registration (resolveForNewRun) | + * | continuation | the identity persisted with the existing run | + * + * A continuation never re-consults the registration: continuation is not a new + * execution, so a later SUSPENDED/RETIRED transition cannot invalidate a run that was + * already admitted, and the persisted identity — not server configuration — is what a + * resumed execution is attributed to. + */ +internal class ServerGovernance internal constructor( + /** The deployment this server instance runs as, or null for an ungoverned server. */ + val configuredIdentity: WorkloadDeploymentIdentity?, + private val authority: WorkloadRegistrationAuthority?, +) { + val isGoverned: Boolean get() = configuredIdentity != null + + /** + * Authorizes a NEW governed execution, or returns null when this server is ungoverned. + * + * Returns the AUTHORITATIVE identity, which is what the run must be attributed to. + */ + suspend fun authorizeNewRun(): WorkloadDeploymentIdentity? { + val configured = configuredIdentity ?: return null + val authority = + authority + ?: error("governed server has no WorkloadRegistrationAuthority") + return authority.resolveForNewRun(configured) + } + + /** + * Authorizes continuing an EXISTING run. + * + * A legacy run stays legacy even on a governed server. A governed run may only be + * continued by a server configured with the same deployment — a different (or absent) + * configured deployment cannot prove it is entitled to continue this run. + */ + fun authorizeContinuation(persisted: WorkloadDeploymentIdentity?) { + if (persisted == null) return + val configured = + configuredIdentity + ?: throw WorkflowConflictException( + "Run was created by governed deployment '$persisted' and cannot be continued by an ungoverned " + + "server", + ) + if (configured != persisted) { + throw WorkflowConflictException( + "Run was created by governed deployment '$persisted' and cannot be continued by deployment " + + "'$configured'", + ) + } + } + + companion object { + /** Ungoverned server: legacy behaviour everywhere. */ + val UNSET = ServerGovernance(configuredIdentity = null, authority = null) + + fun of( + identity: WorkloadDeploymentIdentity, + authority: WorkloadRegistrationAuthority, + ): ServerGovernance = ServerGovernance(identity, authority) + } +} + +/** + * The five governed-server configuration components exactly as declared in Spring + * configuration, all optional. Deliberately dumb: it holds strings and nothing else, so + * the all-or-nothing rule lives in one parser instead of being spread over the bindings. + */ +internal data class ServerGovernanceProperties( + val workloadId: String?, + val configurationId: String?, + val configurationVersion: String?, + val environmentId: String?, + val deploymentId: String?, +) + +/** + * Resolves the governed server configuration. Pure — no Spring types — so the + * fail-closed rule is provable without a container. + * + * All-or-nothing by design: starting *ungoverned* because a deployment id was misspelled + * would silently strip attribution from every run of that server, so a partial + * configuration is a startup failure rather than a default. + */ +internal fun serverGovernanceFrom( + properties: ServerGovernanceProperties, + authority: WorkloadRegistrationAuthority?, +): ServerGovernance { + val values = + mapOf( + "tramai.server.governed.workload-id" to properties.workloadId, + "tramai.server.governed.configuration-id" to properties.configurationId, + "tramai.server.governed.configuration-version" to properties.configurationVersion, + "tramai.server.governed.environment-id" to properties.environmentId, + "tramai.server.governed.deployment-id" to properties.deploymentId, + ) + val configured = values.filterValues { !it.isNullOrBlank() } + if (configured.isEmpty()) return ServerGovernance.UNSET + + val missing = values.keys - configured.keys + require(missing.isEmpty()) { + "Governed server configuration is incomplete, missing: ${missing.sorted()}" + } + val registeredAuthority = + authority + ?: error("Governed server configuration requires a WorkloadRegistrationAuthority bean") + return ServerGovernance.of( + identity = properties.completeIdentity(), + authority = registeredAuthority, + ) +} + +/** Every component is present and non-blank; the parser has already enforced that. */ +private fun ServerGovernanceProperties.completeIdentity(): WorkloadDeploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(requireNotNull(workloadId)), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(requireNotNull(configurationId)), + version = ConfigurationVersion(requireNotNull(configurationVersion)), + ), + environmentId = EnvironmentId(requireNotNull(environmentId)), + deploymentId = DeploymentId(requireNotNull(deploymentId)), + ) diff --git a/tramai-server/src/main/kotlin/dev/tramai/server/ServerGovernanceConfiguration.kt b/tramai-server/src/main/kotlin/dev/tramai/server/ServerGovernanceConfiguration.kt new file mode 100644 index 000000000..f0aefa678 --- /dev/null +++ b/tramai-server/src/main/kotlin/dev/tramai/server/ServerGovernanceConfiguration.kt @@ -0,0 +1,39 @@ +package dev.tramai.server + +import dev.tramai.controlplane.WorkloadRegistrationAuthority +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * Wiring for the governed server deployment (0.7.1d): the Spring binding layer is thin and + * the decision logic lives in [serverGovernanceFrom], which has no Spring types at all. + */ +@Configuration(proxyBeanMethods = false) +internal class ServerGovernanceConfiguration { + @Bean + @ConditionalOnMissingBean + internal fun serverGovernanceProperties( + @Value("\${tramai.server.governed.workload-id:}") workloadId: String, + @Value("\${tramai.server.governed.configuration-id:}") configurationId: String, + @Value("\${tramai.server.governed.configuration-version:}") configurationVersion: String, + @Value("\${tramai.server.governed.environment-id:}") environmentId: String, + @Value("\${tramai.server.governed.deployment-id:}") deploymentId: String, + ): ServerGovernanceProperties = + ServerGovernanceProperties( + workloadId = workloadId, + configurationId = configurationId, + configurationVersion = configurationVersion, + environmentId = environmentId, + deploymentId = deploymentId, + ) + + @Bean + @ConditionalOnMissingBean + internal fun serverGovernance( + authority: ObjectProvider, + properties: ServerGovernanceProperties, + ): ServerGovernance = serverGovernanceFrom(properties, authority.ifAvailable) +} diff --git a/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowController.kt b/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowController.kt index 9f83276d0..726140c43 100644 --- a/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowController.kt +++ b/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowController.kt @@ -1,21 +1,27 @@ package dev.tramai.server +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadDeploymentIdentity import dev.tramai.core.observation.event.RuntimeEvents +import dev.tramai.orchestration.GovernedRun import dev.tramai.orchestration.NoOpWorkflowObserver import dev.tramai.orchestration.WorkflowContext import dev.tramai.orchestration.WorkflowObserver import dev.tramai.orchestration.WorkflowPersistence import dev.tramai.orchestration.WorkflowResumeException import dev.tramai.orchestration.WorkflowSuspendedException +import dev.tramai.orchestration.recoverGovernedRun import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.http.ProblemDetail import org.springframework.http.ResponseEntity -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException -import org.springframework.web.method.annotation.HandlerMethodValidationException import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.GetMapping @@ -24,10 +30,11 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.HandlerMethodValidationException +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import org.springframework.web.servlet.mvc.method.annotation.SseEmitter -import org.slf4j.LoggerFactory import java.net.URI import java.nio.charset.StandardCharsets.UTF_8 import java.util.UUID @@ -41,9 +48,18 @@ class WorkflowController( ) { private val logger = LoggerFactory.getLogger(WorkflowController::class.java) + /** + * Additive wiring seam (0.7.1d): the governed deployment this server instance runs as. + * + * Deliberately NOT a constructor parameter: the public constructor stays exactly as it + * was, and an ungoverned server (the default) behaves as before. Assigned by + * [ServerConfiguration] when governed configuration is present. + */ + @Autowired(required = false) + internal var serverGovernance: ServerGovernance = ServerGovernance.UNSET + @GetMapping("/workflows") - fun listWorkflows(): List> = - registry.list().map { mapOf("name" to it.workflow.name) } + fun listWorkflows(): List> = registry.list().map { mapOf("name" to it.workflow.name) } @PostMapping("/workflows/{name}/run") fun runWorkflow( @@ -94,19 +110,45 @@ class WorkflowController( ): WorkflowRunResponse { val entry = registry.get(name) val persistence = persistenceOrConflict(entry, id) + // Read-only preconditions, in this order, BEFORE any mutable step: the run must be + // resumable at all, attribution is recovered from its own checkpoint, and the + // configured deployment must be entitled to continue it. An unauthorized resume must + // not be able to move another run out of DELAYED — or change any state at all. + runStore.requireResumable(entry.workflow.name, id) + val governedRun = recoverPersistedGovernedRun(entry, persistence, id) + serverGovernance.authorizeContinuation(governedRun?.identity?.deployment) + val running = runStore.markResuming(entry.workflow.name, id).toResponse() - val job = workflowExecutionScope.launch(start = CoroutineStart.LAZY) { - executeResumeSafely( - entry = entry, - workflowId = id, - persistence = persistence, - ) - } + val job = + workflowExecutionScope.launch(start = CoroutineStart.LAZY) { + executeResumeSafely( + entry = entry, + workflowId = id, + persistence = persistence, + governedRun = governedRun, + ) + } runStore.attachExecution(entry.workflow.name, id, job) job.start() return running } + /** + * Recovers the identity persisted with this run (null for a legacy run). + * + * ponytail: blocking bridge — the resume endpoint is not suspend and its ABI is public; + * recovery is a single checkpoint read. + */ + private fun recoverPersistedGovernedRun( + entry: WorkflowEntry<*, *>, + persistence: WorkflowPersistence<*>, + workflowId: String, + ): GovernedRun? { + @Suppress("UNCHECKED_CAST") + val typedPersistence = persistence as WorkflowPersistence + return runBlocking { typedPersistence.recoverGovernedRun(entry.workflow.name, workflowId) } + } + @GetMapping("/workflows/{name}/runs") fun listRuns( @PathVariable name: String, @@ -165,25 +207,37 @@ class WorkflowController( @GetMapping("/openapi.json") fun openApi(): Map { - val paths = registry.list().flatMap { entry -> - val workflowPath = "/workflows/${entry.workflow.name}" - listOf( - "$workflowPath/run" to mapOf("post" to operation("Start ${entry.workflow.name} workflow")), - "/webhooks/${entry.workflow.name}" to mapOf("post" to operation("Trigger ${entry.workflow.name} webhook")), - "$workflowPath/runs" to mapOf("get" to operation("List ${entry.workflow.name} runs")), - "$workflowPath/runs/{id}" to mapOf( - "get" to operation("Inspect ${entry.workflow.name} run"), - "delete" to operation("Cancel ${entry.workflow.name} run"), - ), - "$workflowPath/runs/{id}/resume" to mapOf("post" to operation("Resume ${entry.workflow.name} run")), - ) - }.toMap() + val paths = + registry + .list() + .flatMap { entry -> + val workflowPath = "/workflows/${entry.workflow.name}" + listOf( + "$workflowPath/run" to mapOf("post" to operation("Start ${entry.workflow.name} workflow")), + "/webhooks/${entry.workflow.name}" to mapOf("post" to operation("Trigger ${entry.workflow.name} webhook")), + "$workflowPath/runs" to mapOf("get" to operation("List ${entry.workflow.name} runs")), + "$workflowPath/runs/{id}" to + mapOf( + "get" to operation("Inspect ${entry.workflow.name} run"), + "delete" to operation("Cancel ${entry.workflow.name} run"), + ), + "$workflowPath/runs/{id}/resume" to + mapOf( + "post" to + operation( + "Resume ${entry.workflow.name} " + + "run", + ), + ), + ) + }.toMap() return mapOf( "openapi" to "3.1.0", - "info" to mapOf( - "title" to "Tramai Workflow Server", - "version" to VERSION, - ), + "info" to + mapOf( + "title" to "Tramai Workflow Server", + "version" to VERSION, + ), "paths" to paths, ) } @@ -193,25 +247,58 @@ class WorkflowController( initialState: Any?, idempotencyKey: String?, ): WorkflowRunResponse { + // 1. Idempotency first: an existing run wins. No admission lookup, no new run id. + runStore.findByIdempotencyKey(entry.workflow.name, idempotencyKey)?.let { return it.toResponse() } + + // 2. Authorize the NEW execution against the authoritative registration, BEFORE any + // run record exists, so a rejected deployment creates nothing at all. + // ponytail: blocking bridge — this endpoint is not suspend and its ABI is public; the + // check is one indexed lookup. Make the endpoint suspend if the request path ever + // becomes hot enough for that to matter. + val governedDeployment = runBlocking { serverGovernance.authorizeNewRun() } + val workflowId = UUID.randomUUID().toString() - val creation = runStore.getOrCreate( - workflowName = entry.workflow.name, - workflowId = workflowId, - definitionVersion = entry.workflow.definitionVersion, - idempotencyKey = idempotencyKey, - ) - if (!creation.created) { - return creation.record.toResponse() - } - runStore.event(entry.workflow.name, workflowId, RuntimeEvents.WORKFLOW_RUNNING.name, status = WorkflowRunStatus.RUNNING) - val running = runStore.get(entry.workflow.name, workflowId).toResponse() - val job = workflowExecutionScope.launch(start = CoroutineStart.LAZY) { - executeRunSafely( - entry = entry, + val creation = + runStore.getOrCreate( + workflowName = entry.workflow.name, workflowId = workflowId, - initialState = initialState, + definitionVersion = entry.workflow.definitionVersion, + idempotencyKey = idempotencyKey, ) + return if (creation.created) { + admitGovernedRun(entry, workflowId, initialState, governedDeployment) + } else { + // Another identical request won the race: return its run and launch nothing. + creation.record.toResponse() } + } + + /** Launches an admitted run and returns the run record the caller sees. */ + private fun admitGovernedRun( + entry: WorkflowEntry<*, *>, + workflowId: String, + initialState: Any?, + governedDeployment: WorkloadDeploymentIdentity?, + ): WorkflowRunResponse { + // One canonical identity, established exactly once: the run id IS the RunId. + val governedRun = + governedDeployment?.let { deployment -> + GovernedRun( + context = WorkflowContext(workflowId = workflowId), + identity = GovernedRunIdentity(deployment = deployment, runId = RunId(workflowId)), + ) + } + runStore.event(entry.workflow.name, workflowId, RuntimeEvents.WORKFLOW_RUNNING.name, status = WorkflowRunStatus.RUNNING) + val running = runStore.get(entry.workflow.name, workflowId).toResponse() + val job = + workflowExecutionScope.launch(start = CoroutineStart.LAZY) { + executeRunSafely( + entry = entry, + workflowId = workflowId, + initialState = initialState, + governedRun = governedRun, + ) + } runStore.attachExecution(entry.workflow.name, workflowId, job) job.start() return running @@ -220,28 +307,40 @@ class WorkflowController( private fun decodeInitialState( entry: WorkflowEntry<*, *>, body: String, - ): Any? = try { - entry.decodeState(body) - } catch (error: Throwable) { - throw BadWorkflowRequestException("Workflow '${entry.workflow.name}' state JSON is invalid", error) - } + ): Any? = + try { + entry.decodeState(body) + } catch (error: Throwable) { + throw BadWorkflowRequestException("Workflow '${entry.workflow.name}' state JSON is invalid", error) + } private suspend fun executeRunSafely( entry: WorkflowEntry<*, *>, workflowId: String, initialState: Any?, + governedRun: GovernedRun?, ) { @Suppress("UNCHECKED_CAST") val typedEntry = entry as WorkflowEntry val persistence = entry.persistenceFactory(workflowId) val observer = ServerWorkflowObserver(runStore, workflowId) try { - val result = typedEntry.run( - initialState = initialState, - context = WorkflowContext(workflowId = workflowId), - observer = observer, - persistence = persistence, - ) + val result = + if (governedRun == null) { + typedEntry.run( + initialState = initialState, + context = WorkflowContext(workflowId = workflowId), + observer = observer, + persistence = persistence, + ) + } else { + typedEntry.run( + initialState = initialState, + run = governedRun, + observer = observer, + persistence = persistence, + ) + } runStore.complete(entry.workflow.name, workflowId, result) } catch (suspended: WorkflowSuspendedException) { runStore.fail(entry.workflow.name, workflowId, suspended, WorkflowRunStatus.DELAYED) @@ -259,18 +358,32 @@ class WorkflowController( workflowId: String, @Suppress("UNCHECKED_CAST") persistence: WorkflowPersistence<*>, + governedRun: GovernedRun?, ) { val observer = ServerWorkflowObserver(runStore, workflowId) + @Suppress("UNCHECKED_CAST") val typedEntry = entry as WorkflowEntry + @Suppress("UNCHECKED_CAST") val typedPersistence = persistence as WorkflowPersistence try { - val result = typedEntry.resume( - context = WorkflowContext(workflowId = workflowId), - observer = observer, - persistence = typedPersistence, - ) + // The identity was recovered from this run's own checkpoint and authorized before + // the resume was admitted; it is never re-derived here. + val result = + if (governedRun == null) { + typedEntry.resume( + context = WorkflowContext(workflowId = workflowId), + observer = observer, + persistence = typedPersistence, + ) + } else { + typedEntry.resume( + run = governedRun, + observer = observer, + persistence = typedPersistence, + ) + } runStore.complete(entry.workflow.name, workflowId, result) } catch (suspended: WorkflowSuspendedException) { runStore.fail(entry.workflow.name, workflowId, suspended, WorkflowRunStatus.DELAYED) @@ -286,18 +399,21 @@ class WorkflowController( private fun persistenceOrConflict( entry: WorkflowEntry, workflowId: String, - ): WorkflowPersistence = entry.persistenceFactory(workflowId) - ?: throw WorkflowConflictException( - "Workflow '${entry.workflow.name}' has no persistence configured; resume requires WorkflowPersistence", - ) + ): WorkflowPersistence = + entry.persistenceFactory(workflowId) + ?: throw WorkflowConflictException( + "Workflow '${entry.workflow.name}' has no persistence configured; resume requires WorkflowPersistence", + ) - private fun operation(summary: String): Map = mapOf( - "summary" to summary, - "responses" to mapOf( - "200" to mapOf("description" to "OK"), - "400" to mapOf("description" to "Problem Details"), - ), - ) + private fun operation(summary: String): Map = + mapOf( + "summary" to summary, + "responses" to + mapOf( + "200" to mapOf("description" to "OK"), + "400" to mapOf("description" to "Problem Details"), + ), + ) } data class WorkflowRunResponse( @@ -470,29 +586,32 @@ private class ServerWorkflowObserver( } } -private fun WorkflowRunRecord.toResponse(): WorkflowRunResponse = WorkflowRunResponse( - workflowId = workflowId, - status = status.wireName, - definitionVersion = definitionVersion, - result = result, -) +private fun WorkflowRunRecord.toResponse(): WorkflowRunResponse = + WorkflowRunResponse( + workflowId = workflowId, + status = status.wireName, + definitionVersion = definitionVersion, + result = result, + ) -private fun WorkflowRunRecord.toSummary(): WorkflowRunSummary = WorkflowRunSummary( - workflowId = workflowId, - status = status.wireName, - definitionVersion = definitionVersion, - currentStep = currentStep, -) +private fun WorkflowRunRecord.toSummary(): WorkflowRunSummary = + WorkflowRunSummary( + workflowId = workflowId, + status = status.wireName, + definitionVersion = definitionVersion, + currentStep = currentStep, + ) -private fun WorkflowRunRecord.toDetail(): WorkflowRunDetail = WorkflowRunDetail( - workflowId = workflowId, - status = status.wireName, - definitionVersion = definitionVersion, - currentStep = currentStep, - history = history, - result = result, - error = error, -) +private fun WorkflowRunRecord.toDetail(): WorkflowRunDetail = + WorkflowRunDetail( + workflowId = workflowId, + status = status.wireName, + definitionVersion = definitionVersion, + currentStep = currentStep, + history = history, + result = result, + error = error, + ) /** @see WorkflowController */ private const val VERSION = "0.2.0" diff --git a/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowRegistry.kt b/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowRegistry.kt index 5010cbfa0..1b7579429 100644 --- a/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowRegistry.kt +++ b/tramai-server/src/main/kotlin/dev/tramai/server/WorkflowRegistry.kt @@ -1,5 +1,6 @@ package dev.tramai.server +import dev.tramai.orchestration.GovernedRun import dev.tramai.orchestration.JdbcWorkflowCheckpointStore import dev.tramai.orchestration.Workflow import dev.tramai.orchestration.WorkflowContext @@ -30,35 +31,38 @@ class WorkflowRegistry( require(!entries.containsKey(workflow.name)) { "Workflow '${workflow.name}' is already registered" } - entries[workflow.name] = WorkflowEntry( - workflow = workflow, - stateCodec = stateCodec, - stateType = workflow.stateType, - resultType = workflow.resultType, - persistenceFactory = defaultPersistence, - ) + entries[workflow.name] = + WorkflowEntry( + workflow = workflow, + stateCodec = stateCodec, + stateType = workflow.stateType, + resultType = workflow.resultType, + persistenceFactory = defaultPersistence, + ) } - fun get(workflowName: String): WorkflowEntry<*, *> = + fun get(workflowName: String): WorkflowEntry< + *, + *, + > = entries[workflowName] ?: throw WorkflowNotRegisteredException(workflowName) fun list(): List> = entries.values.toList() - private fun defaultPersistenceFactory( - stateCodec: WorkflowStateCodec, - ): (String) -> WorkflowPersistence? = { _ -> - val source = dataSource - if (source == null) { - null - } else { - createJdbcTablesIfNeeded(source) - WorkflowPersistence( - checkpointStore = JdbcWorkflowCheckpointStore(source), - stateCodec = stateCodec, - delayWakeupScheduler = schedulerStore ?: JdbcWorkflowSchedulerStore(source), - ) + private fun defaultPersistenceFactory(stateCodec: WorkflowStateCodec): (String) -> WorkflowPersistence? = + { _ -> + val source = dataSource + if (source == null) { + null + } else { + createJdbcTablesIfNeeded(source) + WorkflowPersistence( + checkpointStore = JdbcWorkflowCheckpointStore(source), + stateCodec = stateCodec, + delayWakeupScheduler = schedulerStore ?: JdbcWorkflowSchedulerStore(source), + ) + } } - } @Synchronized private fun createJdbcTablesIfNeeded(source: DataSource) { @@ -78,11 +82,12 @@ class WorkflowRegistry( logger.debug("Workflow checkpoint table already exists", error) } } - val schedulerSchemaStore = when (val store = schedulerStore) { - is JdbcWorkflowSchedulerStore -> store - null -> JdbcWorkflowSchedulerStore(source) - else -> null - } + val schedulerSchemaStore = + when (val store = schedulerStore) { + is JdbcWorkflowSchedulerStore -> store + null -> JdbcWorkflowSchedulerStore(source) + else -> null + } schedulerSchemaStore?.createTableSql()?.forEach { sql -> connection.createStatement().use { statement -> statement.execute(sql) @@ -111,27 +116,57 @@ data class WorkflowEntry( context: WorkflowContext, observer: WorkflowObserver, persistence: WorkflowPersistence?, - ): R = workflow.run( - initialState = initialState, - context = context, - observer = observer, - persistence = persistence, - ) + ): R = + workflow.run( + initialState = initialState, + context = context, + observer = observer, + persistence = persistence, + ) suspend fun resume( context: WorkflowContext, observer: WorkflowObserver, persistence: WorkflowPersistence, - ): R = workflow.resume( - context = context, - observer = observer, - persistence = persistence, - ) + ): R = + workflow.resume( + context = context, + observer = observer, + persistence = persistence, + ) + + /** + * Governed execution entry point (0.7.1d): the canonical identity travels alongside the + * unchanged public [WorkflowContext] contract. Legacy callers keep using the overload above. + */ + suspend fun run( + initialState: S, + run: GovernedRun, + observer: WorkflowObserver, + persistence: WorkflowPersistence?, + ): R = + workflow.run( + initialState = initialState, + run = run, + observer = observer, + persistence = persistence, + ) + + /** Governed continuation: the identity was RECOVERED from the run's own checkpoint. */ + suspend fun resume( + run: GovernedRun, + observer: WorkflowObserver, + persistence: WorkflowPersistence, + ): R = + workflow.resume( + run = run, + observer = observer, + persistence = persistence, + ) } class WorkflowNotRegisteredException( workflowName: String, ) : RuntimeException("Workflow '$workflowName' is not registered") -private fun SQLException.isTableAlreadyExists(): Boolean = - sqlState == "42S01" || errorCode == 42101 +private fun SQLException.isTableAlreadyExists(): Boolean = sqlState == "42S01" || errorCode == 42101 diff --git a/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadControlPlaneController.kt b/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadControlPlaneController.kt new file mode 100644 index 000000000..af93e6304 --- /dev/null +++ b/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadControlPlaneController.kt @@ -0,0 +1,263 @@ +package dev.tramai.server + +import dev.tramai.controlplane.ConfigurationFingerprint +import dev.tramai.controlplane.LifecycleTransitionOutcome +import dev.tramai.controlplane.MetadataUpdateOutcome +import dev.tramai.controlplane.RegisterOutcome +import dev.tramai.controlplane.WorkloadControlPlaneCommands +import dev.tramai.controlplane.WorkloadControlPlaneQueries +import dev.tramai.controlplane.WorkloadExposure +import dev.tramai.controlplane.WorkloadLifecycleState +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.identity.WorkloadMetadata +import kotlinx.coroutines.runBlocking +import org.springframework.beans.factory.ObjectProvider +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController + +/** Registration declaration for a new workload deployment. */ +data class WorkloadRegistrationCommand( + val workloadId: String, + val configurationId: String, + val configurationVersion: String, + val environmentId: String, + val deploymentId: String, + val configurationFingerprint: String, + val owner: String, + val purpose: String, +) + +/** Metadata update payload; the expected version travels in `If-Match`. */ +data class WorkloadMetadataCommand( + val owner: String, + val purpose: String, +) + +/** Lifecycle command payload; the expected version travels in `If-Match`. */ +data class WorkloadLifecycleCommand( + val target: WorkloadLifecycleState, +) + +/** + * Authoritative read model. + * + * Built from the safe exposure model ([WorkloadExposure]), never from the internal authority + * record. The fingerprint is structurally unreachable: it is the witness that binds a + * configuration to a registration, and a witness has no business being handed back to a client. + */ +data class WorkloadRegistrationResponse( + val workloadId: String, + val configurationId: String, + val configurationVersion: String, + val environmentId: String, + val deploymentId: String, + val owner: String, + val purpose: String, + val lifecycle: WorkloadLifecycleState, + val stateVersion: Long, + val consistency: String, +) { + companion object { + fun from( + exposure: WorkloadExposure, + consistency: String = "AUTHORITATIVE", + ): WorkloadRegistrationResponse = + WorkloadRegistrationResponse( + workloadId = exposure.identity.workloadId.value, + configurationId = exposure.identity.configuration.id.value, + configurationVersion = exposure.identity.configuration.version.value, + environmentId = exposure.identity.environmentId.value, + deploymentId = exposure.identity.deploymentId.value, + owner = exposure.metadata.owner, + purpose = exposure.metadata.purpose, + lifecycle = exposure.lifecycle, + stateVersion = exposure.stateVersion.value, + consistency = consistency, + ) + } +} + +/** + * HTTP adapter for the public control-plane authority contract (0.7.1e). + * + * This class maps transport to contract and nothing else. It never reads the authority's present + * state to decide anything, never compares versions itself and never mutates: the precondition is + * parsed here, handed to the command as `expectedVersion`, and the AUTHORITY decides. Reproducing + * `find -> compare -> mutate` in the adapter would re-create a second authority. + * + * Disabled by default (`tramai.control-plane.http.enabled=true` to enable): this surface mutates + * authoritative state, and the safe-exposure model is 0.7.1f's subject, not this slice's. + */ +@RestController +@ConditionalOnProperty(prefix = "tramai.control-plane.http", name = ["enabled"], havingValue = "true") +internal class WorkloadControlPlaneController( + commands: ObjectProvider, + queries: ObjectProvider, +) { + private val commands: WorkloadControlPlaneCommands = + commands.ifAvailable + ?: error("tramai.control-plane.http.enabled requires a WorkloadControlPlaneCommands bean") + private val queries: WorkloadControlPlaneQueries = + queries.ifAvailable + ?: error("tramai.control-plane.http.enabled requires a WorkloadControlPlaneQueries bean") + + // ── Commands ──────────────────────────────────────────────────── + + @PostMapping(BASE_PATH) + fun register( + @RequestBody command: WorkloadRegistrationCommand, + ): ResponseEntity = + runBlocking { + val outcome = + commands.register( + command.toIdentity(), + command.fingerprint(), + command.toMetadata(), + ) + when (outcome) { + is RegisterOutcome.Created -> { + createdResponse(outcome.exposure) + } + + is RegisterOutcome.AlreadyRegistered -> { + okResponse(outcome.exposure) + } + + is RegisterOutcome.Rejected -> { + problemResponse( + status = HttpStatus.CONFLICT, + title = "Workload registration conflicts with existing state", + detail = outcome.reason.toString(), + ) + } + } + } + + @PutMapping("$BASE_PATH/{workloadId}/environments/{environmentId}/deployments/{deploymentId}/metadata") + fun updateMetadata( + @PathVariable workloadId: String, + @PathVariable environmentId: String, + @PathVariable deploymentId: String, + @RequestHeader(name = IF_MATCH, required = false) ifMatch: String?, + @RequestBody command: WorkloadMetadataCommand, + ): ResponseEntity { + val precondition = parseWorkloadPrecondition(ifMatch) + if (precondition !is WorkloadPrecondition.Expected) return preconditionFailureResponse(precondition) + return runBlocking { + when ( + val outcome = + commands.updateMetadata( + WorkloadId(workloadId), + EnvironmentId(environmentId), + DeploymentId(deploymentId), + precondition.version, + WorkloadMetadata(command.owner, command.purpose), + ) + ) { + is MetadataUpdateOutcome.Applied -> okResponse(outcome.exposure) + is MetadataUpdateOutcome.Unchanged -> okResponse(outcome.exposure) + is MetadataUpdateOutcome.Stale -> preconditionFailed(outcome.currentVersion, outcome.expectedVersion) + MetadataUpdateOutcome.NotFound -> notFoundResponse() + } + } + } + + @PutMapping("$BASE_PATH/{workloadId}/environments/{environmentId}/deployments/{deploymentId}/lifecycle") + fun transitionLifecycle( + @PathVariable workloadId: String, + @PathVariable environmentId: String, + @PathVariable deploymentId: String, + @RequestHeader(name = IF_MATCH, required = false) ifMatch: String?, + @RequestBody command: WorkloadLifecycleCommand, + ): ResponseEntity { + val precondition = parseWorkloadPrecondition(ifMatch) + if (precondition !is WorkloadPrecondition.Expected) return preconditionFailureResponse(precondition) + return runBlocking { + when ( + val outcome = + commands.transitionLifecycle( + WorkloadId(workloadId), + EnvironmentId(environmentId), + DeploymentId(deploymentId), + precondition.version, + command.target, + ) + ) { + is LifecycleTransitionOutcome.Applied -> { + okResponse(outcome.exposure) + } + + is LifecycleTransitionOutcome.Unchanged -> { + okResponse(outcome.exposure) + } + + is LifecycleTransitionOutcome.Stale -> { + preconditionFailed(outcome.currentVersion, outcome.expectedVersion) + } + + is LifecycleTransitionOutcome.InvalidTransition -> { + problemResponse( + status = HttpStatus.CONFLICT, + title = "Lifecycle transition is not permitted", + detail = "Cannot transition from ${outcome.from} to ${outcome.to}", + ) + } + + LifecycleTransitionOutcome.NotFound -> { + notFoundResponse() + } + } + } + } + + // ── Queries ───────────────────────────────────────────────────── + + @GetMapping("$BASE_PATH/{workloadId}/environments/{environmentId}/deployments/{deploymentId}") + fun readAuthoritative( + @PathVariable workloadId: String, + @PathVariable environmentId: String, + @PathVariable deploymentId: String, + ): ResponseEntity = + runBlocking { + val read = + queries.authoritative( + WorkloadId(workloadId), + EnvironmentId(environmentId), + DeploymentId(deploymentId), + ) ?: return@runBlocking notFoundResponse() + okResponse(read) + } +} + +private fun WorkloadRegistrationCommand.toIdentity(): WorkloadDeploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configurationId), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId(environmentId), + deploymentId = DeploymentId(deploymentId), + ) + +private fun WorkloadRegistrationCommand.fingerprint() = ConfigurationFingerprint(configurationFingerprint) + +private fun WorkloadRegistrationCommand.toMetadata(): WorkloadMetadata = WorkloadMetadata(owner, purpose) + +private const val BASE_PATH = "/control-plane/workloads" +private const val IF_MATCH = "If-Match" diff --git a/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadControlPlaneResponses.kt b/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadControlPlaneResponses.kt new file mode 100644 index 000000000..ff6963d7b --- /dev/null +++ b/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadControlPlaneResponses.kt @@ -0,0 +1,123 @@ +package dev.tramai.server + +import dev.tramai.controlplane.ClassifiedRead +import dev.tramai.controlplane.WorkloadExposure +import dev.tramai.controlplane.WorkloadStateVersion +import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity + +/* + * Transport mapping for the control-plane authority contract (0.7.1e). + * + * Extracted from the controller so an endpoint reads as "parse -> command -> map outcome" and the + * response vocabulary lives in one place. These functions decide HTTP shape only: no authority + * state is read, compared or mutated here. + */ + +/** + * An authoritative or projection read, with the ETag of the version it reports. + * + * The ETag comes from [ClassifiedRead.observedVersion] — the version the read testifies to — rather + * than from the payload, so the header cannot disagree with the body if the two ever diverge. + */ +internal fun okResponse(read: ClassifiedRead): ResponseEntity = + ResponseEntity + .ok() + .eTag(workloadEtag(read.observedVersion)) + .body(WorkloadRegistrationResponse.from(read.exposure, read.consistency.name)) + +/** A command outcome that changed nothing or applied exactly once; both are 200 with the ETag. */ +internal fun okResponse(exposure: WorkloadExposure): ResponseEntity = + ResponseEntity + .ok() + .eTag(workloadEtag(exposure.stateVersion)) + .body(WorkloadRegistrationResponse.from(exposure)) + +/** A newly created registration is 201 with its ETag. */ +internal fun createdResponse(exposure: WorkloadExposure): ResponseEntity = + ResponseEntity + .status(HttpStatus.CREATED) + .eTag(workloadEtag(exposure.stateVersion)) + .body(WorkloadRegistrationResponse.from(exposure)) + +/** + * Precondition failed (412): the version the command was conditioned on is no longer current. + * + * Distinct from a domain conflict (409). The body carries both versions and the response carries the + * current ETag, so a client can re-read and reconcile without parsing human-readable text. + */ +internal fun preconditionFailed( + currentVersion: WorkloadStateVersion, + expectedVersion: WorkloadStateVersion, +): ResponseEntity { + val problem = + ProblemDetail.forStatus(HttpStatus.PRECONDITION_FAILED).apply { + title = "Precondition failed" + detail = + "The command was conditioned on state version ${expectedVersion.value}, " + + "but the authoritative version is ${currentVersion.value}" + setProperty("expectedVersion", expectedVersion.value) + setProperty("currentVersion", currentVersion.value) + } + return ResponseEntity + .status(HttpStatus.PRECONDITION_FAILED) + .eTag(workloadEtag(currentVersion)) + .body(problem) +} + +/** + * A missing precondition (428) or an unacceptable `If-Match` form (400). + * + * Neither is ever a silent write: a mutation without a usable version precondition does not happen. + */ +internal fun preconditionFailureResponse(precondition: WorkloadPrecondition): ResponseEntity { + val (status, title, detail) = + when (precondition) { + WorkloadPrecondition.Missing -> { + Triple( + HttpStatus.PRECONDITION_REQUIRED, + "Precondition required", + "This mutation requires an If-Match header carrying the authoritative state version, " + + "for example If-Match: \"7\"", + ) + } + + WorkloadPrecondition.Unsupported -> { + Triple( + HttpStatus.BAD_REQUEST, + "Unsupported If-Match precondition", + "If-Match must be exactly one strong numeric ETag, for example If-Match: \"7\"; " + + "weak tags, lists and * are not version preconditions", + ) + } + + is WorkloadPrecondition.Expected -> { + error("an accepted precondition is not a failure") + } + } + return problemResponse(status = status, title = title, detail = detail) +} + +/** The deployment scope has no authoritative registration. */ +internal fun notFoundResponse(): ResponseEntity = + problemResponse( + status = HttpStatus.NOT_FOUND, + title = "Workload deployment is not registered", + detail = "No authoritative registration exists for this deployment scope", + ) + +/** RFC 7807 problem response, matching the server's existing error convention. */ +internal fun problemResponse( + status: HttpStatus, + title: String, + detail: String, +): ResponseEntity = + ResponseEntity + .status(status) + .body( + ProblemDetail.forStatus(status).apply { + this.title = title + this.detail = detail + }, + ) diff --git a/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadPrecondition.kt b/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadPrecondition.kt new file mode 100644 index 000000000..da440051a --- /dev/null +++ b/tramai-server/src/main/kotlin/dev/tramai/server/WorkloadPrecondition.kt @@ -0,0 +1,52 @@ +package dev.tramai.server + +import dev.tramai.controlplane.WorkloadStateVersion + +/** + * `If-Match` precondition for a control-plane command (0.7.1e). + * + * The header can syntactically express more than this contract accepts, so unsupported forms are + * rejected rather than interpreted. The accepted form is exactly one strong, numeric ETag: + * `If-Match: "17"` — the authoritative [WorkloadStateVersion] the command is conditioned on. + * + * Rejected on purpose: + * - `*` — satisfies resource-existence semantics without naming a version, which would authorize a + * mutation that carries no expected version; + * - `W/"17"` — a weak tag is not a version-specific precondition; + * - `"16", "17"` — the authority has exactly one version, so alternatives have no meaning; + * - unquoted, empty or non-numeric values. + */ +internal sealed interface WorkloadPrecondition { + /** One strong numeric ETag, i.e. the version the command expects. */ + data class Expected( + val version: WorkloadStateVersion, + ) : WorkloadPrecondition + + /** No `If-Match` header at all — a required precondition is missing. */ + data object Missing : WorkloadPrecondition + + /** Present but not an acceptable form — never reinterpreted as "no precondition". */ + data object Unsupported : WorkloadPrecondition +} + +/** The `ETag` for an authoritative record: one strong numeric tag. */ +internal fun workloadEtag(version: WorkloadStateVersion): String = "\"${version.value}\"" + +/** Exactly one quoted strong numeric tag, with no leading zeros (`"0"` is not a version). */ +private val STRONG_NUMERIC_ETAG = Regex("""^"([1-9][0-9]*)"$""") + +/** Parses an `If-Match` header into a precondition outcome. */ +internal fun parseWorkloadPrecondition(header: String?): WorkloadPrecondition { + // Only an ABSENT header is Missing (428). A present value that does not carry exactly one strong + // numeric ETag — including empty or blank — is Unsupported (400), because "you sent nothing + // usable" is a malformed precondition, not a missing one, and collapsing the two would report a + // malformed request as a required-precondition error. + if (header == null) return WorkloadPrecondition.Missing + return STRONG_NUMERIC_ETAG + .matchEntire(header.trim()) + ?.groupValues + ?.get(1) + ?.toLongOrNull() + ?.let { WorkloadPrecondition.Expected(WorkloadStateVersion(it)) } + ?: WorkloadPrecondition.Unsupported +} diff --git a/tramai-server/src/test/kotlin/dev/tramai/server/ServerGovernedRunAttributionTest.kt b/tramai-server/src/test/kotlin/dev/tramai/server/ServerGovernedRunAttributionTest.kt new file mode 100644 index 000000000..5397c31fc --- /dev/null +++ b/tramai-server/src/test/kotlin/dev/tramai/server/ServerGovernedRunAttributionTest.kt @@ -0,0 +1,524 @@ +package dev.tramai.server + +import dev.tramai.controlplane.ConfigurationFingerprint +import dev.tramai.controlplane.InMemoryWorkloadRegistrationStore +import dev.tramai.controlplane.LifecycleTransitionOutcome +import dev.tramai.controlplane.WorkloadAdmissionRejectedException +import dev.tramai.controlplane.WorkloadLifecycleState +import dev.tramai.controlplane.WorkloadRegistrationAuthority +import dev.tramai.controlplane.WorkloadStateVersion +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.identity.WorkloadMetadata +import dev.tramai.orchestration.InMemoryWorkflowCheckpointStore +import dev.tramai.orchestration.WorkflowPersistence +import dev.tramai.orchestration.WorkflowStateCodec +import dev.tramai.orchestration.recoverGovernedRun +import dev.tramai.orchestration.workflow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * 0.7.1d server-level governed runs. + * + * Two rules, deliberately different: + * + * - NEW execution: the configured deployment must BE the authoritative registration (ACTIVE). + * - CONTINUATION: the identity persisted with the run is authoritative; the configured + * deployment must merely match it. Lifecycle is never re-consulted, because a continuation + * is not a new execution. + */ +class ServerGovernedRunAttributionTest { + private companion object { + const val FAST = "governed-fast" + const val DELAYED = "governed-delayed" + const val CONFIG_VERSION = "17" + const val DEPLOYMENT = "eu-west-amsterdam-01" + + const val FAST_WAIT_MS = 10_000L + const val DELAY_MS = 200L + } + + private object StringStateCodec : WorkflowStateCodec { + override fun encode(state: String): String = state + + override fun decode(payload: String): String = payload + } + + private object PassthroughSignatureVerifier : WebhookSignatureVerifier { + override val name: String = "passthrough" + + override fun verify( + payload: String, + headers: Map, + ): Boolean = true + } + + private fun deployment( + deploymentId: String = DEPLOYMENT, + configurationVersion: String = CONFIG_VERSION, + ): WorkloadDeploymentIdentity = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId(deploymentId), + ) + + private class Fixture { + val runStore = WorkflowRunStore() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val registry = WorkflowRegistry() + val fastCheckpoints = InMemoryWorkflowCheckpointStore() + val delayedCheckpoints = InMemoryWorkflowCheckpointStore() + val fastExecutions = AtomicInteger() + val delayedSteps = CopyOnWriteArrayList() + + init { + registry.register( + workflow = + workflow(name = FAST, definitionVersion = "1.0.0") { + localStep("count") { state, _ -> + fastExecutions.incrementAndGet() + "$state-done" + } + }.build { it }, + stateCodec = StringStateCodec, + defaultPersistence = { + WorkflowPersistence( + checkpointStore = fastCheckpoints, + stateCodec = StringStateCodec, + deleteCheckpointOnCompletion = false, + ) + }, + ) + registry.register( + workflow = + workflow(name = DELAYED, definitionVersion = "1.0.0") { + delayStep("pause", DELAY_MS, TimeUnit.MILLISECONDS) + localStep("after") { state, _ -> + delayedSteps += state + "$state-after" + } + }.build { it }, + stateCodec = StringStateCodec, + defaultPersistence = { + WorkflowPersistence( + checkpointStore = delayedCheckpoints, + stateCodec = StringStateCodec, + deleteCheckpointOnCompletion = false, + ) + }, + ) + } + + fun controller(governance: ServerGovernance): WorkflowController = + WorkflowController( + registry = registry, + runStore = runStore, + workflowExecutionScope = scope, + signatureVerifier = PassthroughSignatureVerifier, + ).also { it.serverGovernance = governance } + + fun waitForStatus( + workflowName: String, + workflowId: String, + expected: WorkflowRunStatus, + ) { + val deadline = System.currentTimeMillis() + FAST_WAIT_MS + while (System.currentTimeMillis() < deadline) { + if (runStore.get(workflowName, workflowId).status == expected) return + Thread.sleep(10) + } + throw AssertionError( + "run $workflowId of $workflowName did not reach $expected " + + "(was ${runStore.get(workflowName, workflowId).status})", + ) + } + + fun persistenceOf(workflowName: String): WorkflowPersistence = + if (workflowName == FAST) { + WorkflowPersistence( + checkpointStore = fastCheckpoints, + stateCodec = StringStateCodec, + deleteCheckpointOnCompletion = false, + ) + } else { + WorkflowPersistence( + checkpointStore = delayedCheckpoints, + stateCodec = StringStateCodec, + deleteCheckpointOnCompletion = false, + ) + } + } + + private fun registered( + identity: WorkloadDeploymentIdentity = deployment(), + lifecycle: WorkloadLifecycleState = WorkloadLifecycleState.ACTIVE, + ): WorkloadRegistrationAuthority { + val store = InMemoryWorkloadRegistrationStore() + val authority = WorkloadRegistrationAuthority(store) + runBlocking { + authority.register( + identity = identity, + configurationFingerprint = ConfigurationFingerprint("fp-claims-prod-1"), + metadata = WorkloadMetadata(owner = "team-claims", purpose = "claims processing"), + ) + if (lifecycle != WorkloadLifecycleState.ACTIVE) { + val current = store.find(identity.workloadId, identity.environmentId, identity.deploymentId)!! + authority.transitionLifecycle( + workloadId = identity.workloadId, + environmentId = identity.environmentId, + deploymentId = identity.deploymentId, + expectedVersion = current.stateVersion, + target = lifecycle, + ) + } + } + return authority + } + + // ─── New execution ────────────────────────────────────────────────────────── + + @Test + fun `a registered active deployment starts a governed run with a fresh run id`() { + val fixture = Fixture() + val controller = fixture.controller(ServerGovernance.of(deployment(), registered())) + + val response = controller.runWorkflow(FAST, "seed", idempotencyKey = null) + fixture.waitForStatus(FAST, response.workflowId, WorkflowRunStatus.COMPLETED) + + val recovered = + runBlocking { fixture.persistenceOf(FAST).recoverGovernedRun(FAST, response.workflowId) } + assertThat(recovered).isNotNull + assertThat(recovered!!.identity.deployment).isEqualTo(deployment()) + // The run id IS the canonical RunId: no second identifier is generated. + assertThat(recovered.identity.runId.value).isEqualTo(response.workflowId) + } + + @Test + fun `two governed runs of the same deployment get distinct run ids`() { + val fixture = Fixture() + val controller = fixture.controller(ServerGovernance.of(deployment(), registered())) + + val first = controller.runWorkflow(FAST, "one", idempotencyKey = null) + val second = controller.runWorkflow(FAST, "two", idempotencyKey = null) + + assertThat(first.workflowId).isNotEqualTo(second.workflowId) + fixture.waitForStatus(FAST, second.workflowId, WorkflowRunStatus.COMPLETED) + } + + @Test + fun `an unregistered deployment creates no run record and executes nothing`() { + val fixture = Fixture() + val controller = + fixture.controller(ServerGovernance.of(deployment(deploymentId = "eu-central-frankfurt-01"), registered())) + + assertThrows { + controller.runWorkflow(FAST, "seed", idempotencyKey = null) + } + + assertThat(fixture.runStore.list(FAST, offset = 0, limit = 50)).isEmpty() + assertThat(fixture.fastExecutions.get()).isZero() + } + + @Test + fun `a suspended or retired deployment cannot start a new run`() { + listOf(WorkloadLifecycleState.SUSPENDED, WorkloadLifecycleState.RETIRED).forEach { lifecycle -> + val fixture = Fixture() + val controller = + fixture.controller(ServerGovernance.of(deployment(), registered(lifecycle = lifecycle))) + + val failure = + assertThrows { + controller.runWorkflow(FAST, "seed", idempotencyKey = null) + } + + assertThat(failure.reason).isEqualTo("workload-deployment-not-active") + assertThat(fixture.runStore.list(FAST, offset = 0, limit = 50)).isEmpty() + } + } + + @Test + fun `a configured identity differing only by configuration version is rejected`() { + val fixture = Fixture() + val controller = + fixture.controller(ServerGovernance.of(deployment(configurationVersion = "18"), registered())) + + val failure = + assertThrows { + controller.runWorkflow(FAST, "seed", idempotencyKey = null) + } + + assertThat(failure.reason).isEqualTo("workload-deployment-configuration-mismatch") + assertThat(fixture.runStore.list(FAST, offset = 0, limit = 50)).isEmpty() + } + + @Test + fun `an idempotent retry succeeds even after the registration is suspended`() { + val fixture = Fixture() + val authority = registered() + val controller = fixture.controller(ServerGovernance.of(deployment(), authority)) + + val first = controller.runWorkflow(FAST, "seed", idempotencyKey = "idem-1") + fixture.waitForStatus(FAST, first.workflowId, WorkflowRunStatus.COMPLETED) + runBlocking { + val outcome = + authority.transitionLifecycle( + workloadId = WorkloadId("claims"), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId(DEPLOYMENT), + expectedVersion = WorkloadStateVersion.INITIAL, + target = WorkloadLifecycleState.SUSPENDED, + ) + assertThat(outcome).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) + } + + val retry = controller.runWorkflow(FAST, "seed", idempotencyKey = "idem-1") + + assertThat(retry.workflowId).isEqualTo(first.workflowId) + assertThat(fixture.fastExecutions.get()).isEqualTo(1) + } + + @Test + fun `concurrent same-idempotency requests yield one run and one execution`() { + val fixture = Fixture() + val controller = fixture.controller(ServerGovernance.of(deployment(), registered())) + val threads = 8 + val ready = CountDownLatch(threads) + val go = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(threads) + val ids = CopyOnWriteArrayList() + try { + repeat(threads) { + executor.submit { + ready.countDown() + go.await() + ids += controller.runWorkflow(FAST, "seed", idempotencyKey = "race-key").workflowId + } + } + ready.await() + go.countDown() + executor.shutdown() + assertThat(executor.awaitTermination(30, TimeUnit.SECONDS)).isTrue() + } finally { + executor.shutdownNow() + } + + assertThat(ids.toSet()).hasSize(1) + fixture.waitForStatus(FAST, ids.first(), WorkflowRunStatus.COMPLETED) + assertThat(fixture.runStore.list(FAST, offset = 0, limit = 50)).hasSize(1) + assertThat(fixture.fastExecutions.get()).isEqualTo(1) + } + + // ─── Continuation ─────────────────────────────────────────────────────────── + + private fun startDelayed( + fixture: Fixture, + governance: ServerGovernance, + body: String = "seed", + ): Pair { + val controller = fixture.controller(governance) + val response = controller.runWorkflow(DELAYED, body, idempotencyKey = null) + fixture.waitForStatus(DELAYED, response.workflowId, WorkflowRunStatus.DELAYED) + return controller to response.workflowId + } + + @Test + fun `a governed suspension and resume preserve the exact identity`() { + val fixture = Fixture() + val governance = ServerGovernance.of(deployment(), registered()) + val (controller, runId) = startDelayed(fixture, governance) + + val suspended = + runBlocking { fixture.persistenceOf(DELAYED).recoverGovernedRun(DELAYED, runId) }!! + assertThat(suspended.identity.deployment).isEqualTo(deployment()) + assertThat(suspended.identity.runId.value).isEqualTo(runId) + + Thread.sleep(DELAY_MS + 50) + controller.resumeWorkflow(DELAYED, runId) + fixture.waitForStatus(DELAYED, runId, WorkflowRunStatus.COMPLETED) + + val resumed = runBlocking { fixture.persistenceOf(DELAYED).recoverGovernedRun(DELAYED, runId) }!! + assertThat(resumed.identity).isEqualTo(suspended.identity) + } + + @Test + fun `a lifecycle change after suspension does not block the existing run`() { + val fixture = Fixture() + val authority = registered() + val governance = ServerGovernance.of(deployment(), authority) + val (controller, runId) = startDelayed(fixture, governance) + + runBlocking { + val outcome = + authority.transitionLifecycle( + workloadId = WorkloadId("claims"), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId(DEPLOYMENT), + expectedVersion = WorkloadStateVersion.INITIAL, + target = WorkloadLifecycleState.SUSPENDED, + ) + assertThat(outcome).isInstanceOf(LifecycleTransitionOutcome.Applied::class.java) + } + + Thread.sleep(DELAY_MS + 50) + controller.resumeWorkflow(DELAYED, runId) + + fixture.waitForStatus(DELAYED, runId, WorkflowRunStatus.COMPLETED) + assertThat(fixture.delayedSteps).containsExactly("seed") + } + + @Test + fun `a different server deployment cannot resume the governed run`() { + val fixture = Fixture() + val authority = registered() + val (_, runId) = startDelayed(fixture, ServerGovernance.of(deployment(), authority)) + + val other = + fixture.controller( + ServerGovernance.of(deployment(deploymentId = "eu-central-frankfurt-01"), authority), + ) + + // Rejected synchronously, before any mutation: the run is still resumable afterwards. + assertThrows { other.resumeWorkflow(DELAYED, runId) } + + assertThat(fixture.runStore.get(DELAYED, runId).status).isEqualTo(WorkflowRunStatus.DELAYED) + assertThat(fixture.delayedSteps).isEmpty() + } + + @Test + fun `an ungoverned server cannot resume a governed checkpoint`() { + val fixture = Fixture() + val (_, runId) = startDelayed(fixture, ServerGovernance.of(deployment(), registered())) + + val ungoverned = fixture.controller(ServerGovernance.UNSET) + + assertThrows { ungoverned.resumeWorkflow(DELAYED, runId) } + + assertThat(fixture.runStore.get(DELAYED, runId).status).isEqualTo(WorkflowRunStatus.DELAYED) + assertThat(fixture.delayedSteps).isEmpty() + } + + @Test + fun `concurrent resumes admit exactly one winner`() { + val fixture = Fixture() + val (controller, runId) = startDelayed(fixture, ServerGovernance.of(deployment(), registered())) + // Let the delay elapse first: otherwise the winner's resume legitimately re-suspends. + Thread.sleep(DELAY_MS + 50) + + val threads = 2 + val ready = CountDownLatch(threads) + val go = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(threads) + val outcomes = CopyOnWriteArrayList() + try { + repeat(threads) { + executor.submit { + ready.countDown() + go.await() + val outcome = runCatching { controller.resumeWorkflow(DELAYED, runId) } + outcomes += outcome.fold({ "admitted" }, { it::class.simpleName ?: "failure" }) + } + } + ready.await() + go.countDown() + executor.shutdown() + assertThat(executor.awaitTermination(30, TimeUnit.SECONDS)).isTrue() + } finally { + executor.shutdownNow() + } + + // Authorization and recovery mutate nothing, so both callers reach them; only one may + // win the admitted-resume transition, and the run executes exactly once. + assertThat(outcomes.count { it == "admitted" }).isEqualTo(1) + assertThat(outcomes).anyMatch { it == "WorkflowConflictException" } + fixture.waitForStatus(DELAYED, runId, WorkflowRunStatus.COMPLETED) + assertThat(fixture.delayedSteps).containsExactly("seed") + } + + @Test + fun `a legacy run stays legacy even on a governed server`() { + val fixture = Fixture() + val (_, runId) = startDelayed(fixture, ServerGovernance.UNSET) + assertThat( + runBlocking { fixture.persistenceOf(DELAYED).recoverGovernedRun(DELAYED, runId) }, + ).isNull() + + val governed = fixture.controller(ServerGovernance.of(deployment(), registered())) + Thread.sleep(DELAY_MS + 50) + governed.resumeWorkflow(DELAYED, runId) + + fixture.waitForStatus(DELAYED, runId, WorkflowRunStatus.COMPLETED) + assertThat(fixture.delayedSteps).containsExactly("seed") + } + + // ─── Configuration ────────────────────────────────────────────────────────── + + @Test + fun `an ungoverned server stays byte-identical and partial configuration fails closed`() { + assertThat( + serverGovernanceFrom( + properties = + ServerGovernanceProperties( + workloadId = null, + configurationId = null, + configurationVersion = null, + environmentId = null, + deploymentId = null, + ), + authority = null, + ), + ).isSameAs(ServerGovernance.UNSET) + + // Partial configuration is a typo, not a choice: starting ungoverned would silently + // strip attribution from every run of this server. + assertThrows { + serverGovernanceFrom( + properties = + ServerGovernanceProperties( + workloadId = "claims", + configurationId = null, + configurationVersion = null, + environmentId = "production", + deploymentId = DEPLOYMENT, + ), + authority = registered(), + ) + } + + // Governed configuration without an authoritative registration source cannot be trusted. + assertThrows { + serverGovernanceFrom( + properties = + ServerGovernanceProperties( + workloadId = "claims", + configurationId = "claims-prod", + configurationVersion = CONFIG_VERSION, + environmentId = "production", + deploymentId = DEPLOYMENT, + ), + authority = null, + ) + } + } +} diff --git a/tramai-server/src/test/kotlin/dev/tramai/server/WorkloadControlPlaneControllerTest.kt b/tramai-server/src/test/kotlin/dev/tramai/server/WorkloadControlPlaneControllerTest.kt new file mode 100644 index 000000000..a29a3dd58 --- /dev/null +++ b/tramai-server/src/test/kotlin/dev/tramai/server/WorkloadControlPlaneControllerTest.kt @@ -0,0 +1,403 @@ +package dev.tramai.server + +import com.fasterxml.jackson.databind.ObjectMapper +import dev.tramai.controlplane.MetadataUpdateOutcome +import dev.tramai.controlplane.WorkloadRegistrationAuthority +import dev.tramai.controlplane.WorkloadStateVersion +import dev.tramai.controlplane.testing.WorkloadRegistrationFixtures +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put +import dev.tramai.controlplane.InMemoryWorkloadRegistrationStore as InMemoryStore + +/** + * The HTTP concurrency contract over the public control-plane authority contract (0.7.1e). + * + * These tests pin the transport mapping AND the structural rule behind it: the adapter maps + * outcomes, it never reproduces `find -> compare -> mutate`. The strongest discriminator here is + * that a rejected precondition leaves authoritative state untouched — not merely that the response + * has the right status code. + */ +@SpringBootTest( + classes = [TramaiServerApplication::class, WorkloadControlPlaneControllerTest.Beans::class], + properties = ["tramai.control-plane.http.enabled=true"], +) +@AutoConfigureMockMvc +class WorkloadControlPlaneControllerTest + @Autowired + constructor( + private val mockMvc: MockMvc, + private val objectMapper: ObjectMapper, + private val authority: WorkloadRegistrationAuthority, + ) { + private val approvedResponseProperties = + setOf( + "workloadId", + "configurationId", + "configurationVersion", + "environmentId", + "deploymentId", + "owner", + "purpose", + "lifecycle", + "stateVersion", + "consistency", + ) + + // Kotlin classes are final, so CGLIB proxying is unavailable: mirror the production + // configuration convention and turn it off explicitly. + @TestConfiguration(proxyBeanMethods = false) + internal class Beans { + @Bean + internal fun workloadRegistrationStore() = InMemoryStore() + + @Bean + internal fun registrationAuthority(store: InMemoryStore) = WorkloadRegistrationAuthority(store) + } + + // ── Reads ─────────────────────────────────────────────────────── + + @Test + fun `GET returns the authoritative record with an ETag derived from its state version`() { + val scope = register("read-1") + advanceMetadata(scope, 1) + + mockMvc + .get(path(scope)) + .andExpect { + status { isOk() } + header { string("ETag", "\"2\"") } + }.andReturn() + .response + .contentAsString + .let { body -> + val json = objectMapper.readTree(body) + assertThat(json.get("stateVersion").asLong()).isEqualTo(2L) + assertThat(json.get("consistency").asText()).isEqualTo("AUTHORITATIVE") + // the configuration witness never leaves the authority + assertThat(json.has("configurationFingerprint")).isFalse() + } + } + + @Test + fun `GET on an unregistered scope is 404`() { + mockMvc + .get("/control-plane/workloads/nope/environments/e/deployments/d") + .andExpect { status { isNotFound() } } + } + + // ── Precondition forms ────────────────────────────────────────── + + @Test + fun `a matching precondition applies the command exactly once`() { + val scope = register("apply-1") + + mockMvc + .put(path(scope, "metadata")) { + header("If-Match", "\"1\"") + contentType = MediaType.APPLICATION_JSON + content = metadata("New Owner") + }.andExpect { + status { isOk() } + header { string("ETag", "\"2\"") } + } + + assertThat(currentVersion(scope)).isEqualTo(WorkloadStateVersion(2)) + assertThat(currentOwner(scope)).isEqualTo("New Owner") + } + + @Test + fun `a stale precondition is 412 with both versions and the current ETag`() { + val scope = register("stale-1") + advanceMetadata(scope, 1) // authoritative version is now 2 + + val response = + mockMvc + .put(path(scope, "metadata")) { + header("If-Match", "\"1\"") + contentType = MediaType.APPLICATION_JSON + content = metadata("Stale Writer") + }.andExpect { + status { isPreconditionFailed() } + header { string("ETag", "\"2\"") } + }.andReturn() + .response + + val problem = objectMapper.readTree(response.contentAsString) + assertThat(problem.get("expectedVersion").asLong()).isEqualTo(1L) + assertThat(problem.get("currentVersion").asLong()).isEqualTo(2L) + + // rejected means untouched, not "reported as rejected" + assertThat(currentVersion(scope)).isEqualTo(WorkloadStateVersion(2)) + assertThat(currentOwner(scope)).isNotEqualTo("Stale Writer") + } + + @Test + fun `a missing precondition is 428 and changes nothing`() { + val scope = register("missing-1") + + mockMvc + .put(path(scope, "metadata")) { + contentType = MediaType.APPLICATION_JSON + content = metadata("Unconditioned Writer") + }.andExpect { status { isPreconditionRequired() } } + + assertThat(currentVersion(scope)).isEqualTo(WorkloadStateVersion.INITIAL) + assertThat(currentOwner(scope)).isNotEqualTo("Unconditioned Writer") + } + + @Test + fun `unsupported precondition forms are 400 and mutate nothing`() { + val scope = register("form-1") + val ownerBefore = currentOwner(scope) + val unsupported = listOf("*", "W/\"1\"", "\"1\", \"2\"", "abc", "1") + val observed = mutableMapOf() + + unsupported.forEach { form -> + val response = + mockMvc + .put(path(scope, "metadata")) { + header("If-Match", form) + contentType = MediaType.APPLICATION_JSON + // a JSON-safe body: several unsupported forms contain quotes, and the + // precondition must be the only thing under test here + content = metadata("Unconditioned Writer") + }.andReturn() + .response + observed[form] = "${response.status} :: ${response.contentAsString.take(200)}" + } + + assertThat(observed.values.map { it.substringBefore(" :: ") }) + .describedAs("every unsupported form must be 400, observed: $observed") + .allSatisfy { status -> assertThat(status).isEqualTo("400") } + + // `*` in particular must not authorize anything: no version named, so no mutation. + assertThat(currentVersion(scope)).isEqualTo(WorkloadStateVersion.INITIAL) + assertThat(currentOwner(scope)).isEqualTo(ownerBefore) + } + + // ── Domain conflicts (409) vs precondition failures (412) ─────── + + @Test + fun `an illegal transition at the current version is 409, not 412`() { + val scope = register("lifecycle-1") + transition(scope, "\"1\"", "RETIRED").andExpect { status { isOk() } } + val retiredVersion = currentVersion(scope) + + transition(scope, "\"${retiredVersion.value}\"", "ACTIVE") + .andExpect { status { isConflict() } } + + // and a stale expectation on the same resource is still a precondition failure + transition(scope, "\"1\"", "ACTIVE") + .andExpect { status { isPreconditionFailed() } } + } + + @Test + fun `a mutation on an unregistered scope is 404`() { + mockMvc + .put("/control-plane/workloads/ghost/environments/e/deployments/d/metadata") { + header("If-Match", "\"1\"") + contentType = MediaType.APPLICATION_JSON + content = metadata("nobody") + }.andExpect { status { isNotFound() } } + } + + // ── Registration ──────────────────────────────────────────────── + + @Test + fun `registration is 201 with an ETag, and a duplicate declaration is 200`() { + val scope = deployment("register-1") + + mockMvc + .post("/control-plane/workloads") { + contentType = MediaType.APPLICATION_JSON + content = registration(scope) + }.andExpect { + status { isCreated() } + header { string("ETag", "\"1\"") } + } + + mockMvc + .post("/control-plane/workloads") { + contentType = MediaType.APPLICATION_JSON + content = registration(scope) + }.andExpect { status { isOk() } } + } + + @Test + fun `every successful control-plane response has the exact safe shape and no fingerprint`() { + val scope = + WorkloadRegistrationFixtures.identity( + configurationId = "exact-shape-config", + deployment = "exact-shape-deployment", + ) + val sentinel = "sha256:SENTINEL-0.7.1f-FINGERPRINT" + + fun assertShape(body: String) { + val json = objectMapper.readTree(body) + assertThat(json.fieldNames().asSequence().toSet()).isEqualTo(approvedResponseProperties) + assertThat(body).doesNotContain(sentinel) + } + + mockMvc + .post("/control-plane/workloads") { + contentType = MediaType.APPLICATION_JSON + content = registration(scope, sentinel) + }.andExpect { status { isCreated() } } + .andReturn() + .response.contentAsString + .let(::assertShape) + + mockMvc + .post("/control-plane/workloads") { + contentType = MediaType.APPLICATION_JSON + content = registration(scope, sentinel) + }.andExpect { status { isOk() } } + .andReturn() + .response.contentAsString + .let(::assertShape) + + mockMvc + .get(path(scope)) + .andExpect { status { isOk() } } + .andReturn() + .response.contentAsString + .let(::assertShape) + + mockMvc + .put(path(scope, "metadata")) { + header("If-Match", "\"1\"") + contentType = MediaType.APPLICATION_JSON + content = metadata("safe-owner") + }.andExpect { status { isOk() } } + .andReturn() + .response.contentAsString + .let(::assertShape) + + mockMvc + .put(path(scope, "lifecycle")) { + header("If-Match", "\"2\"") + contentType = MediaType.APPLICATION_JSON + content = "{\"target\":\"SUSPENDED\"}" + }.andExpect { status { isOk() } } + .andReturn() + .response.contentAsString + .let(::assertShape) + } + + @Test + fun `an explicitly present empty If-Match is 400 rather than 428 and mutates nothing`() { + val scope = register("empty-if-match") + val ownerBefore = currentOwner(scope) + + // Presented but unusable: the header exists, so this is a malformed precondition (400), not + // a missing one (428). Either way nothing is written. + mockMvc + .put(path(scope, "metadata")) { + header("If-Match", "") + contentType = MediaType.APPLICATION_JSON + content = metadata("Unconditioned Writer") + }.andExpect { status { isBadRequest() } } + + assertThat(currentVersion(scope)).isEqualTo(WorkloadStateVersion.INITIAL) + assertThat(currentOwner(scope)).isEqualTo(ownerBefore) + } + + // ── helpers ───────────────────────────────────────────────────── + + /** The control-plane fixtures own the identifiers, so the adapter tests never invent any. */ + private fun deployment(slug: String) = WorkloadRegistrationFixtures.identity(deployment = slug) + + private fun register(slug: String): WorkloadDeploymentIdentity = + runBlocking { + val scope = deployment(slug) + authority.register( + scope, + WorkloadRegistrationFixtures.fingerprint(), + WorkloadRegistrationFixtures.metadata(), + ) + scope + } + + private fun advanceMetadata( + scope: WorkloadDeploymentIdentity, + times: Int, + ) = runBlocking { + var version = WorkloadStateVersion.INITIAL + repeat(times) { + val outcome = + authority.updateMetadata( + scope.workloadId, + scope.environmentId, + scope.deploymentId, + version, + WorkloadRegistrationFixtures.metadata(owner = "owner-${version.value}"), + ) + version = (outcome as MetadataUpdateOutcome.Applied).exposure.stateVersion + } + Unit + } + + private fun transition( + scope: WorkloadDeploymentIdentity, + ifMatch: String, + target: String, + ) = mockMvc.put(path(scope, "lifecycle")) { + header("If-Match", ifMatch) + contentType = MediaType.APPLICATION_JSON + content = """{"target":"$target"}""" + } + + private fun currentVersion(scope: WorkloadDeploymentIdentity): WorkloadStateVersion = + runBlocking { + val read = + authority.authoritative(scope.workloadId, scope.environmentId, scope.deploymentId) + checkNotNull(read).observedVersion + } + + private fun currentOwner(scope: WorkloadDeploymentIdentity): String? = + runBlocking { + val read = + authority.authoritative(scope.workloadId, scope.environmentId, scope.deploymentId) + checkNotNull(read).exposure.metadata.owner + } + + private fun path( + scope: WorkloadDeploymentIdentity, + suffix: String? = null, + ): String = + "/control-plane/workloads/${scope.workloadId.value}" + + "/environments/${scope.environmentId.value}" + + "/deployments/${scope.deploymentId.value}" + + (suffix?.let { "/$it" } ?: "") + + private fun metadata(owner: String): String = """{"owner":"$owner","purpose":"p"}""" + + private fun registration( + scope: WorkloadDeploymentIdentity, + fingerprint: String = "sha256:aaaa", + ): String = + """ + { + "workloadId": "${scope.workloadId.value}", + "configurationId": "${scope.configuration.id.value}", + "configurationVersion": "${scope.configuration.version.value}", + "environmentId": "${scope.environmentId.value}", + "deploymentId": "${scope.deploymentId.value}", + "configurationFingerprint": "$fingerprint", + "owner": "Owner", + "purpose": "p" + } + """.trimIndent() + } diff --git a/tramai-server/src/test/kotlin/dev/tramai/server/WorkloadPreconditionTest.kt b/tramai-server/src/test/kotlin/dev/tramai/server/WorkloadPreconditionTest.kt new file mode 100644 index 000000000..c6f88a474 --- /dev/null +++ b/tramai-server/src/test/kotlin/dev/tramai/server/WorkloadPreconditionTest.kt @@ -0,0 +1,66 @@ +package dev.tramai.server + +import dev.tramai.controlplane.WorkloadStateVersion +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * `If-Match` precondition grammar (0.7.1e). + * + * The contract accepts exactly one strong numeric ETag. Everything else is rejected rather than + * interpreted, because a precondition that is silently ignored turns a version-guarded mutation + * into a last-write-wins write. + */ +class WorkloadPreconditionTest { + @Test + fun `a single strong numeric ETag is the expected version`() { + assertThat(parseWorkloadPrecondition("\"17\"")) + .isEqualTo(WorkloadPrecondition.Expected(WorkloadStateVersion(17))) + } + + @Test + fun `surrounding whitespace is tolerated`() { + assertThat(parseWorkloadPrecondition(" \"1\" ")) + .isEqualTo(WorkloadPrecondition.Expected(WorkloadStateVersion.INITIAL)) + } + + @Test + fun `an absent header is a missing precondition and a present unusable one is malformed`() { + // 428 means "you did not supply the precondition"; 400 means "what you supplied is not a + // version precondition". Only an absent header is the former. + assertThat(parseWorkloadPrecondition(null)).isEqualTo(WorkloadPrecondition.Missing) + assertThat(parseWorkloadPrecondition("")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition(" ")).isEqualTo(WorkloadPrecondition.Unsupported) + } + + @Test + fun `unsupported forms are rejected instead of interpreted`() { + // `*` names no version: accepting it would authorize a mutation with no expected version. + assertThat(parseWorkloadPrecondition("*")).isEqualTo(WorkloadPrecondition.Unsupported) + // weak tag: not a version-specific precondition + assertThat(parseWorkloadPrecondition("W/\"17\"")).isEqualTo(WorkloadPrecondition.Unsupported) + // alternatives: the authority has exactly one version + assertThat(parseWorkloadPrecondition("\"16\", \"17\"")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition("\"16\",\"17\"")).isEqualTo(WorkloadPrecondition.Unsupported) + // malformed / non-numeric / unquoted + assertThat(parseWorkloadPrecondition("abc")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition("17")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition("\"\"")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition("\"abc\"")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition("\"1.5\"")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition("\"-1\"")).isEqualTo(WorkloadPrecondition.Unsupported) + // "0" and leading zeros are not state versions (versions start at 1) + assertThat(parseWorkloadPrecondition("\"0\"")).isEqualTo(WorkloadPrecondition.Unsupported) + assertThat(parseWorkloadPrecondition("\"007\"")).isEqualTo(WorkloadPrecondition.Unsupported) + // beyond Long range + assertThat(parseWorkloadPrecondition("\"99999999999999999999\"")) + .isEqualTo(WorkloadPrecondition.Unsupported) + } + + @Test + fun `the ETag for a version is one strong numeric tag`() { + assertThat(workloadEtag(WorkloadStateVersion(7))).isEqualTo("\"7\"") + assertThat(parseWorkloadPrecondition(workloadEtag(WorkloadStateVersion(7)))) + .isEqualTo(WorkloadPrecondition.Expected(WorkloadStateVersion(7))) + } +} diff --git a/tramai-spring-boot-starter-sovereign-ops/api/tramai-spring-boot-starter-sovereign-ops.api b/tramai-spring-boot-starter-sovereign-ops/api/tramai-spring-boot-starter-sovereign-ops.api index 4991729bd..ee2acdcac 100644 --- a/tramai-spring-boot-starter-sovereign-ops/api/tramai-spring-boot-starter-sovereign-ops.api +++ b/tramai-spring-boot-starter-sovereign-ops/api/tramai-spring-boot-starter-sovereign-ops.api @@ -1066,6 +1066,31 @@ public abstract interface class dev/tramai/spring/sovereign/ops/lease/SovereignO public abstract fun tryAcquire (Ljava/lang/String;Ljava/lang/String;Ljava/time/Instant;Ljava/time/Duration;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$Governed : dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution { + public fun (Ldev/tramai/core/identity/GovernedRunIdentity;)V + public final fun component1 ()Ldev/tramai/core/identity/GovernedRunIdentity; + public final fun copy (Ldev/tramai/core/identity/GovernedRunIdentity;)Ldev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$Governed; + public static synthetic fun copy$default (Ldev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$Governed;Ldev/tramai/core/identity/GovernedRunIdentity;ILjava/lang/Object;)Ldev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$Governed; + public fun equals (Ljava/lang/Object;)Z + public final fun getIdentity ()Ldev/tramai/core/identity/GovernedRunIdentity; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$LegacySuspension : dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution { + public static final field INSTANCE Ldev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$LegacySuspension; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$NoSuspension : dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution { + public static final field INSTANCE Ldev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution$NoSuspension; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/tramai/spring/sovereign/ops/outbox/CompositeSovereignOpsAuditOutboxWorkerObserver : dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxWorkerObserver { public fun (Ljava/util/List;)V public fun onCycleCompleted (Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxWorkerRunSummary;)V @@ -1086,6 +1111,15 @@ public final class dev/tramai/spring/sovereign/ops/outbox/DefaultSovereignOpsAud public fun retryPending (Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore : dev/tramai/spring/sovereign/ops/outbox/SovereignOpsApprovalRequestMutationStore { + public abstract fun createGovernedApprovalRequest (Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Ldev/tramai/spring/sovereign/ops/inbox/ApprovalInboxMetadata;Ldev/tramai/core/approval/gateway/ApprovalResumeCredentialRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun createGovernedApprovalRequest$default (Ldev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore;Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Ldev/tramai/spring/sovereign/ops/inbox/ApprovalInboxMetadata;Ldev/tramai/core/approval/gateway/ApprovalResumeCredentialRecord;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore$DefaultImpls { + public static synthetic fun createGovernedApprovalRequest$default (Ldev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore;Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Ldev/tramai/spring/sovereign/ops/inbox/ApprovalInboxMetadata;Ldev/tramai/core/approval/gateway/ApprovalResumeCredentialRecord;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + public final class dev/tramai/spring/sovereign/ops/outbox/InMemorySovereignOpsApprovalMutationStore : dev/tramai/spring/sovereign/ops/outbox/SovereignOpsApprovalMutationStore { public fun (Ldev/tramai/core/approval/ApprovalStore;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxStore;)V public fun approveApprovalWithAuditIntent (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -1238,6 +1272,35 @@ public final class dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutbo public final fun dispatchPending (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Governed : dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance { + public fun (Ldev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord;)V + public final fun component1 ()Ldev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord; + public final fun copy (Ldev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Governed; + public static synthetic fun copy$default (Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Governed;Ldev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord;ILjava/lang/Object;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Governed; + public fun equals (Ljava/lang/Object;)Z + public final fun getRecord ()Ldev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Legacy : dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance { + public fun (Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;)V + public final fun component1 ()Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord; + public final fun copy (Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Legacy; + public static synthetic fun copy$default (Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Legacy;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;ILjava/lang/Object;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$Legacy; + public fun equals (Ljava/lang/Object;)Z + public final fun getRecord ()Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$NoRecord : dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance { + public static final field INSTANCE Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxGovernance$NoRecord; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class dev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxOperations { public abstract fun listOutboxRecords (Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxStatus;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun markPreparedFailed (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/SovereignOpsTransactionalApprovalGateway.kt b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/SovereignOpsTransactionalApprovalGateway.kt index 14d255197..8624aa837 100644 --- a/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/SovereignOpsTransactionalApprovalGateway.kt +++ b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/SovereignOpsTransactionalApprovalGateway.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + package dev.tramai.spring.sovereign.ops import dev.tramai.core.approval.ApprovalRequest @@ -13,14 +15,23 @@ import dev.tramai.core.approval.gateway.AuditStreamId import dev.tramai.core.approval.gateway.HumanApprovalDecision import dev.tramai.core.approval.gateway.SealedResumeToken import dev.tramai.core.approval.gateway.WorkflowRunId +import dev.tramai.core.exception.ConfigurationException +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest import dev.tramai.engine.approval.ApprovalGatewayRequestFactory +import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadata import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadataContext import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadataFactory +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsApprovalRequestMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationResult import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationStore -import java.time.Clock +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import java.time.Clock /** * Transactional [ApprovalGateway] implementation backed by @@ -33,6 +44,14 @@ import kotlinx.coroutines.CancellationException * durable "approval-requested" outbox intent during request creation and passes it * to the mutation store for atomic persistence alongside the core records. * + * **Governed runs:** an active [GovernedRunScope] is the creation authority, mirroring + * `DefaultApprovalGateway`: the gateway resolves the canonical identity, validates any caller-supplied + * run id against it, builds the identity-blind factory request, checks the factory's binding against + * the canonical run id, and persists through [GovernedSovereignOpsApprovalRequestMutationStore] so the + * approval attribution and the suspension identity come from one identity in one transaction. A + * governed request against a configured store without that capability fails closed with + * [ConfigurationException] before the factory runs and before anything is written. + * * @param mutationStore atomic creation store * @param requestFactory builds low-level persistence records from the ergonomic SPI input * @param auditIntentFactory optional factory for approval-requested audit outbox intent @@ -46,64 +65,175 @@ class SovereignOpsTransactionalApprovalGateway( private val inboxMetadataFactory: ApprovalInboxMetadataFactory? = null, private val clock: Clock = Clock.systemUTC(), ) : ApprovalGateway { - override suspend fun requestApproval( subject: ApprovalSubject, recommendation: dev.tramai.core.approval.gateway.ApprovalRecommendation, requiredRole: ApproverRole, workflowRunId: WorkflowRunId?, ): ApprovalRequestResult { - val request = requestFactory.createRequest( - subject = subject, - recommendation = recommendation, - requiredRole = requiredRole, - workflowRunId = workflowRunId, - ) + // THE canonical value for this request: a governed execution carries its identity, an + // ungoverned one carries nothing. Everything below derives from this one value. + val governedIdentity = resolveGovernedIdentity(workflowRunId) - val auditIntent = auditIntentFactory?.approvalRequested( - request = request, - subject = subject, - recommendation = recommendation, - requiredRole = requiredRole, - ) + // Capability discovery is a precondition, not a per-store surprise: a governed request + // against a deployment that cannot persist governed records fails before the factory runs, + // so a partially governed wiring can never write some records and then fail on the rest. + val governedStore = if (governedIdentity != null) requireGovernedStore() else null - val inboxMetadata = inboxMetadataFactory?.create( - ApprovalInboxMetadataContext( - approvalId = request.approvalRequest.approvalId, - workflowRunId = request.approvalRequest.binding.workflowRunId, - toolName = request.approvalRequest.binding.toolName, - requestedBy = request.approvalRequest.requestedBy, + val request = + requestFactory.createRequest( + subject = subject, + recommendation = recommendation, requiredRole = requiredRole, - correlationId = request.suspendedInvocationMetadata.correlationId, - ), - ) + workflowRunId = effectiveWorkflowRunId(governedIdentity, workflowRunId), + ) - val resumeCredential = ApprovalResumeCredentialRecord( - approvalId = ApprovalId(request.approvalRequest.approvalId), - workflowRunId = WorkflowRunId(request.approvalRequest.binding.workflowRunId), - resumeToken = SealedResumeToken.seal(request.resumeToken), - createdAt = request.approvalRequest.requestedAt, - expiresAt = request.approvalRequest.expiresAt, - version = 1L, - ) + // The factory is untrusted for identity: it was handed the canonical run id above, and a + // payload that re-points the binding must abort before anything durable exists. + requireFactoryBindingMatches(request, governedIdentity) + + // Optional artifacts for this one request. Assembled by a helper so the decision path + // above — resolve identity, validate, dispatch — stays the readable security-relevant part. + val artifacts = artifactsFor(request, subject, recommendation, requiredRole) return try { - when (val result = mutationStore.createApprovalRequest(request, auditIntent, inboxMetadata, resumeCredential)) { - is SovereignOpsApprovalRequestMutationResult.Created -> + val result = + if (governedIdentity == null) { + mutationStore.createApprovalRequest( + request, + artifacts.auditIntent, + artifacts.inboxMetadata, + artifacts.resumeCredential, + ) + } else { + // Non-null by construction: resolved above, before the factory ran. + checkNotNull(governedStore) + .createGovernedApprovalRequest( + request = request, + identity = governedIdentity, + auditIntent = artifacts.auditIntent, + inboxMetadata = artifacts.inboxMetadata, + resumeCredential = artifacts.resumeCredential, + ) + } + + when (result) { + is SovereignOpsApprovalRequestMutationResult.Created -> { ApprovalRequestResult.Suspended( approvalId = ApprovalId(result.approvalId), workflowRunId = WorkflowRunId(request.approvalRequest.binding.workflowRunId), auditStreamId = AuditStreamId(result.correlationId), resumeToken = result.resumeToken, ) + } - is SovereignOpsApprovalRequestMutationResult.Existing -> + is SovereignOpsApprovalRequestMutationResult.Existing -> { result.approval.toGatewayResult(request, clock) + } } } catch (e: CancellationException) { throw e } } + + /** + * Resolves the canonical governed identity for this request, or null when the execution is ungoverned. + * + * A caller may name the run it believes it is suspending, but it may never nominate one run's id + * together with another run's identity. When an active scope exists the scope is authoritative, and a + * disagreement aborts here — before the factory and before any store. + */ + private suspend fun resolveGovernedIdentity(workflowRunId: WorkflowRunId?): GovernedRunIdentity? { + val identity = GovernedRunScope.resolve(currentCoroutineContext()) ?: return null + if (workflowRunId != null && workflowRunId.value != identity.runId.value) { + throw GovernedRunContinuityException( + "Caller supplied workflow run id '${workflowRunId.value}' does not match the active " + + "governed run '${identity.runId.value}': a governed suspension cannot pair one " + + "run's id with another run's identity", + ) + } + return identity + } + + /** + * The effective run id for the factory call: for a governed request the canonical identity supplies + * it, so an omitted argument is derived rather than left absent. + */ + private fun effectiveWorkflowRunId( + governedIdentity: GovernedRunIdentity?, + callerRunId: WorkflowRunId?, + ): WorkflowRunId? = governedIdentity?.let { WorkflowRunId(it.runId.value) } ?: callerRunId + + /** + * Requires the governed mutation capability up front, so a partially governed wiring cannot commit + * the approval and then refuse the attribution. + */ + private fun requireGovernedStore(): GovernedSovereignOpsApprovalRequestMutationStore = + mutationStore as? GovernedSovereignOpsApprovalRequestMutationStore + ?: throw ConfigurationException( + "Governed approval requests require a SovereignOpsApprovalRequestMutationStore " + + "implementing GovernedSovereignOpsApprovalRequestMutationStore; the configured " + + "store '${mutationStore::class.simpleName}' does not", + ) + + /** + * The approval binding is the run-id carrier the gateway owns; the store validates the remaining + * carriers against the same identity before its transaction opens. + */ + private fun requireFactoryBindingMatches( + request: ApprovalGatewayPersistenceRequest, + governedIdentity: GovernedRunIdentity?, + ) { + if (governedIdentity == null) return + val bindingRunId = request.approvalRequest.binding.workflowRunId + if (bindingRunId != governedIdentity.runId.value) { + throw GovernedRunContinuityException( + "Approval request factory produced a binding for run '$bindingRunId' while the active " + + "governed run is '${governedIdentity.runId.value}': a governed request must keep " + + "the canonical run id", + ) + } + } + + /** + * Builds the optional inbox metadata, audit intent and resume credential for one request. These + * are description, not authority: none of them can nominate identity. + */ + private fun artifactsFor( + request: ApprovalGatewayPersistenceRequest, + subject: ApprovalSubject, + recommendation: ApprovalRecommendation, + requiredRole: ApproverRole, + ): ApprovalRequestArtifacts = + ApprovalRequestArtifacts( + auditIntent = + auditIntentFactory?.approvalRequested( + request = request, + subject = subject, + recommendation = recommendation, + requiredRole = requiredRole, + ), + inboxMetadata = + inboxMetadataFactory?.create( + ApprovalInboxMetadataContext( + approvalId = request.approvalRequest.approvalId, + workflowRunId = request.approvalRequest.binding.workflowRunId, + toolName = request.approvalRequest.binding.toolName, + requestedBy = request.approvalRequest.requestedBy, + requiredRole = requiredRole, + correlationId = request.suspendedInvocationMetadata.correlationId, + ), + ), + resumeCredential = + ApprovalResumeCredentialRecord( + approvalId = ApprovalId(request.approvalRequest.approvalId), + workflowRunId = WorkflowRunId(request.approvalRequest.binding.workflowRunId), + resumeToken = SealedResumeToken.seal(request.resumeToken), + createdAt = request.approvalRequest.requestedAt, + expiresAt = request.approvalRequest.expiresAt, + version = 1L, + ), + ) } private fun ApprovalRequest.toGatewayResult( @@ -114,44 +244,64 @@ private fun ApprovalRequest.toGatewayResult( val now = clock.instant() return when { - status == ApprovalStatus.APPROVED -> ApprovalRequestResult.AlreadyApproved( - decision = HumanApprovalDecision.Approved( - approvalId = approvalId, - decidedBy = requireNotNull(decidedBy) { - "approved request must have a decider" - }, - decidedAt = requireNotNull(decidedAt) { - "approved request must have a decision timestamp" - }, - comment = decisionComment, - ), - ) + status == ApprovalStatus.APPROVED -> { + ApprovalRequestResult.AlreadyApproved( + decision = + HumanApprovalDecision.Approved( + approvalId = approvalId, + decidedBy = + requireNotNull(decidedBy) { + "approved request must have a decider" + }, + decidedAt = + requireNotNull(decidedAt) { + "approved request must have a decision timestamp" + }, + comment = decisionComment, + ), + ) + } - status == ApprovalStatus.DENIED -> ApprovalRequestResult.AlreadyDenied( - decision = HumanApprovalDecision.Denied( - approvalId = approvalId, - decidedBy = requireNotNull(decidedBy) { - "denied request must have a decider" - }, - decidedAt = requireNotNull(decidedAt) { - "denied request must have a decision timestamp" - }, - reason = decisionComment ?: "approval-denied", - ), - ) + status == ApprovalStatus.DENIED -> { + ApprovalRequestResult.AlreadyDenied( + decision = + HumanApprovalDecision.Denied( + approvalId = approvalId, + decidedBy = + requireNotNull(decidedBy) { + "denied request must have a decider" + }, + decidedAt = + requireNotNull(decidedAt) { + "denied request must have a decision timestamp" + }, + reason = decisionComment ?: "approval-denied", + ), + ) + } - status == ApprovalStatus.TIMED_OUT || !expiresAt.isAfter(now) -> + status == ApprovalStatus.TIMED_OUT || !expiresAt.isAfter(now) -> { ApprovalRequestResult.Expired( approvalId = approvalId, expiredAt = expiresAt, reason = "approval-expired", ) + } - else -> ApprovalRequestResult.Suspended( - approvalId = approvalId, - workflowRunId = WorkflowRunId(binding.workflowRunId), - auditStreamId = AuditStreamId(binding.workflowRunId), - resumeToken = request.resumeToken, - ) + else -> { + ApprovalRequestResult.Suspended( + approvalId = approvalId, + workflowRunId = WorkflowRunId(binding.workflowRunId), + auditStreamId = AuditStreamId(binding.workflowRunId), + resumeToken = request.resumeToken, + ) + } } } + +/** The optional creation artifacts for one approval request. */ +private data class ApprovalRequestArtifacts( + val auditIntent: SovereignOpsAuditOutboxRecord?, + val inboxMetadata: ApprovalInboxMetadata?, + val resumeCredential: ApprovalResumeCredentialRecord?, +) diff --git a/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution.kt b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution.kt new file mode 100644 index 000000000..07523fd34 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/ApprovalRunAttribution.kt @@ -0,0 +1,55 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.ops.outbox + +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.SuspendedInvocationStore + +/** + * 0.7.1d: provenance of the run behind an approval decision, resolved from record existence first. + * + * A `null` governed identity is ambiguous on its own — the contract uses it for a legacy suspension, + * and stores also return it when no suspension exists at all. So provenance is classified by asking + * whether the suspension record exists before asking for its identity, and an absent record is never + * silently interpreted as legacy: whether that is acceptable depends on whether the calling path + * requires a suspension, which is the caller's decision, not this lookup's. + */ +@ExperimentalTramaiInternalApi +sealed interface ApprovalRunAttribution { + /** A suspension exists and carries the complete canonical identity. */ + data class Governed( + val identity: GovernedRunIdentity, + ) : ApprovalRunAttribution + + /** A suspension exists with no governed attribution: a genuine V1/legacy suspension. */ + data object LegacySuspension : ApprovalRunAttribution + + /** No suspension exists. Provenance is unresolved; the caller must decide the policy. */ + data object NoSuspension : ApprovalRunAttribution +} + +/** + * Resolves [ApprovalRunAttribution] for [approvalId]. + * + * Record existence is checked first because [SuspendedInvocationStore.get] is the only call that can + * distinguish "legacy suspension" from "no suspension". The governed identity is read from the same + * store's governed capability, which is the canonical authority — never reconstructed from + * `ApprovalRequest.binding.workflowRunId`, which carries a run identifier and not an authority. + */ +@ExperimentalTramaiInternalApi +suspend fun resolveApprovalRunAttribution( + suspendedInvocations: SuspendedInvocationStore, + approvalId: String, +): ApprovalRunAttribution = + if (suspendedInvocations.get(approvalId) == null) { + ApprovalRunAttribution.NoSuspension + } else { + val identity = (suspendedInvocations as? GovernedSuspendedInvocationStore)?.governedRunIdentity(approvalId) + if (identity == null) { + ApprovalRunAttribution.LegacySuspension + } else { + ApprovalRunAttribution.Governed(identity) + } + } diff --git a/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore.kt b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore.kt new file mode 100644 index 000000000..3cff55d9e --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore.kt @@ -0,0 +1,37 @@ +package dev.tramai.spring.sovereign.ops.outbox + +import dev.tramai.core.approval.gateway.ApprovalResumeCredentialRecord +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadata + +/** + * Additive governed capability beside [SovereignOpsApprovalRequestMutationStore] (0.7.1d1, P3). + * + * A governed request must persist its approval attribution and its suspension identity from ONE + * canonical [GovernedRunIdentity] inside the SAME transaction that already writes the approval, the + * suspension, the continuation, the resume credential and the inbox/audit artifacts. Composing a + * legacy mutation with a separate governed write would let a crash between them leave a + * half-governed approval behind — the silent downgrade 0.7.1d exists to prevent. + * + * The method takes the identity itself, not a nullable value and not a five-component snapshot: the + * implementation derives the approval-row attribution from it and embeds the full identity in the + * suspension, so the two records cannot be built from independently interpreted inputs. + * + * Deliberately a separate interface rather than a new method on the released SPI: third parties + * implement that interface, and a governed request therefore REQUIRES this capability and fails + * closed when the configured store does not provide it. + * + * Implementations MUST provide the atomicity [SovereignOpsApprovalRequestMutationStore] already + * promises — every record persisted, or none — and MUST verify any existing approval row's durable + * attribution against [identity] before reporting it as existing. + */ +interface GovernedSovereignOpsApprovalRequestMutationStore : SovereignOpsApprovalRequestMutationStore { + suspend fun createGovernedApprovalRequest( + request: ApprovalGatewayPersistenceRequest, + identity: GovernedRunIdentity, + auditIntent: SovereignOpsAuditOutboxRecord? = null, + inboxMetadata: ApprovalInboxMetadata? = null, + resumeCredential: ApprovalResumeCredentialRecord? = null, + ): SovereignOpsApprovalRequestMutationResult +} diff --git a/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord.kt b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord.kt new file mode 100644 index 000000000..1055d1ed4 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-ops/src/main/kotlin/dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsAuditOutboxRecord.kt @@ -0,0 +1,78 @@ +package dev.tramai.spring.sovereign.ops.outbox + +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi + +/** + * 0.7.1d: additive governed capability for the sovereign-ops audit outbox. + * + * The legacy [SovereignOpsAuditOutboxRecord] carries only `workflowRunId`, which is a run + * identifier and not an authority. A governed approval decision must therefore persist the + * complete canonical [GovernedRunIdentity] with the ordinary outbox state in one logical durable + * record, or fail closed. + * + * This mirrors the suspension precedent: the legacy record and its constructor stay untouched, and + * the governed form composes them. + */ +@ExperimentalTramaiInternalApi +data class GovernedSovereignOpsAuditOutboxRecord( + val record: SovereignOpsAuditOutboxRecord, + val runIdentity: GovernedRunIdentity, +) { + init { + require(runIdentity.runId.value == record.workflowRunId) { + "governed outbox attribution must match the ordinary record: " + + "'${record.workflowRunId}' != canonical runId '${runIdentity.runId.value}'" + } + } +} + +/** + * Additive durable capability: governed outbox records (0.7.1d). + * + * A store that does not implement this capability cannot hold governed attribution, so governed + * approval mutations must fail closed before the approval state changes rather than silently + * degrading to run-id-only attribution. + * + * Every status transition on a governed record must retain [GovernedSovereignOpsAuditOutboxRecord.runIdentity] + * exactly: transitions address records by identifier and status, so they must never carry identity + * and must never be able to drop it. + */ +@ExperimentalTramaiInternalApi +interface GovernedSovereignOpsAuditOutboxStore { + /** + * Appends a governed record in `PREPARED` state, persisting the ordinary outbox fields and the + * complete canonical identity in one logical durable write. + */ + suspend fun appendGoverned(entry: GovernedSovereignOpsAuditOutboxRecord) + + /** + * Resolves a record's durable provenance: V2 → [SovereignOpsAuditOutboxGovernance.Governed], + * V1 → [SovereignOpsAuditOutboxGovernance.Legacy], absent → + * [SovereignOpsAuditOutboxGovernance.NoRecord]. + * + * Provenance is never nullable: a missing identity is ambiguous between legacy and absent, so + * the two cases are modelled as distinct results instead of being collapsed into `null`. + */ + suspend fun governanceById(id: String): SovereignOpsAuditOutboxGovernance +} + +/** + * Classification of a record's durable provenance, resolved from record existence first. + * + * `null` identity is ambiguous on its own: it means legacy for a record that exists, and it says + * nothing at all for a record that does not. Callers must not read provenance off the identity + * lookup alone, and must not treat an absent record as legacy. + */ +@ExperimentalTramaiInternalApi +sealed interface SovereignOpsAuditOutboxGovernance { + data class Governed( + val record: GovernedSovereignOpsAuditOutboxRecord, + ) : SovereignOpsAuditOutboxGovernance + + data class Legacy( + val record: SovereignOpsAuditOutboxRecord, + ) : SovereignOpsAuditOutboxGovernance + + data object NoRecord : SovereignOpsAuditOutboxGovernance +} diff --git a/tramai-spring-boot-starter-sovereign-ops/src/test/kotlin/dev/tramai/spring/sovereign/ops/ApprovalGatewayAutoConfigurationTest.kt b/tramai-spring-boot-starter-sovereign-ops/src/test/kotlin/dev/tramai/spring/sovereign/ops/ApprovalGatewayAutoConfigurationTest.kt index 1ecb765c3..a6f14f880 100644 --- a/tramai-spring-boot-starter-sovereign-ops/src/test/kotlin/dev/tramai/spring/sovereign/ops/ApprovalGatewayAutoConfigurationTest.kt +++ b/tramai-spring-boot-starter-sovereign-ops/src/test/kotlin/dev/tramai/spring/sovereign/ops/ApprovalGatewayAutoConfigurationTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + package dev.tramai.spring.sovereign.ops import dev.tramai.core.approval.ApprovalContinuation @@ -17,6 +19,18 @@ import dev.tramai.core.approval.gateway.ApprovalSubject import dev.tramai.core.approval.gateway.ApproverRole import dev.tramai.core.approval.gateway.ResumeToken import dev.tramai.core.approval.gateway.WorkflowRunId +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext import dev.tramai.engine.ResumeOperationReference @@ -31,22 +45,23 @@ import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutatio import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus -import java.time.Clock -import java.time.Instant import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Primary +import java.time.Clock +import java.time.Instant class ApprovalGatewayAutoConfigurationTest { - - private val contextRunner = ApplicationContextRunner() - .withConfiguration( - AutoConfigurations.of(ApprovalGatewayAutoConfiguration::class.java), - ) + private val contextRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(ApprovalGatewayAutoConfiguration::class.java), + ) // ── 1. Does not create default gateway without explicit opt-in ───── @@ -65,8 +80,7 @@ class ApprovalGatewayAutoConfigurationTest { contextRunner .withPropertyValues( "tramai.sovereign.ops.approval-gateway.non-transactional-fallback-enabled=true", - ) - .withUserConfiguration(FullGatewayConfig::class.java) + ).withUserConfiguration(FullGatewayConfig::class.java) .run { ctx -> assertThat(ctx).hasSingleBean(ApprovalGateway::class.java) assertThat(ctx.getBean(ApprovalGateway::class.java)) @@ -114,16 +128,17 @@ class ApprovalGatewayAutoConfigurationTest { .withUserConfiguration(RecordingAuditIntentConfig::class.java) .run { ctx -> val gateway = ctx.getBean(ApprovalGateway::class.java) - val recordingStore = ctx.getBean(RecordingApprovalRequestMutationStore::class.java) + val recordingStore = ctx.getBean(RecordingMutationStore::class.java) runBlocking { gateway.requestApproval( subject = ApprovalSubject("test-claim"), - recommendation = ApprovalRecommendation( - type = "test", - summary = "test recommendation", - payload = emptyMap(), - ), + recommendation = + ApprovalRecommendation( + type = "test", + summary = "test recommendation", + payload = emptyMap(), + ), requiredRole = ApproverRole("reviewer"), workflowRunId = WorkflowRunId("wf-test"), ) @@ -137,6 +152,47 @@ class ApprovalGatewayAutoConfigurationTest { } } + // ── 1b. A governed run fails closed before the mutation store is touched ── + + @Test + fun `transactional gateway rejects a governed run without calling the mutation store`() { + contextRunner + .withUserConfiguration(RecordingAuditIntentConfig::class.java) + .run { ctx -> + val gateway = ctx.getBean(ApprovalGateway::class.java) + val recordingStore = ctx.getBean(RecordingMutationStore::class.java) + + val thrown = + runBlocking { + try { + withContext(GovernedRunScope(governedRunIdentity())) { + gateway.requestApproval( + subject = ApprovalSubject("governed-claim"), + recommendation = + ApprovalRecommendation( + type = "test", + summary = "test recommendation", + payload = emptyMap(), + ), + requiredRole = ApproverRole("reviewer"), + workflowRunId = WorkflowRunId("wf-governed"), + ) + } + null + } catch (e: GovernedRunContinuityException) { + e + } + } + + assertThat(thrown).isNotNull + assertThat(thrown!!.message).contains("governed-run-1") + // Fail closed: the persistence contract carries no governed identity, so no + // mutation is attempted and no un-attributed request can be written. + assertThat(recordingStore.calls).isZero() + assertThat(recordingStore.lastAuditIntent).isNull() + } + } + // ── 2. Backs off when custom gateway exists ─────────────────────── @Test @@ -145,8 +201,7 @@ class ApprovalGatewayAutoConfigurationTest { .withUserConfiguration( FullGatewayConfig::class.java, CustomApprovalGatewayConfig::class.java, - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(ApprovalGateway::class.java) val gateway = ctx.getBean(ApprovalGateway::class.java) assertThat(gateway).isInstanceOf(CustomApprovalGateway::class.java) @@ -206,34 +261,68 @@ private val twoDigest = Sha256Digest.of("sha256:22222222222222222222222222222222 private class GatewayTestSuspendedInvocationStore : SuspendedInvocationStore { private val store = mutableMapOf() + override suspend fun create( metadata: SuspendedInvocationMetadata, replayEnvelope: SensitiveReplayEnvelope, - ) { store[metadata.approvalId] = metadata } + ) { + store[metadata.approvalId] = metadata + } override suspend fun get(approvalId: String): SuspendedInvocationMetadata? = store[approvalId] + override suspend fun revealReplayEnvelope(approvalId: String): SensitiveReplayEnvelope? = null + override suspend fun remove(approvalId: String): SuspendedInvocationMetadata? = store.remove(approvalId) } private class GatewayTestApprovalContinuationStore : ApprovalContinuationStore { private val store = mutableMapOf() - override suspend fun create(continuation: ApprovalContinuation, arguments: SensitiveToolArguments): ApprovalContinuation { + + override suspend fun create( + continuation: ApprovalContinuation, + arguments: SensitiveToolArguments, + ): ApprovalContinuation { store[continuation.approvalId] = continuation return continuation } + override suspend fun get(approvalId: String): ApprovalContinuation? = store[approvalId] - override suspend fun claimForExecution(approvalId: String, expectedVersion: Long, claimedBy: String): ClaimedApprovalContinuation = - throw UnsupportedOperationException("stub") - override suspend fun complete(approvalId: String, expectedVersion: Long, completedBy: String): ApprovalContinuation = - throw UnsupportedOperationException("stub") - override suspend fun expire(approvalId: String, expectedVersion: Long): ApprovalContinuation = - throw UnsupportedOperationException("stub") - override suspend fun cancel(approvalId: String, expectedVersion: Long): ApprovalContinuation = - throw UnsupportedOperationException("stub") - override suspend fun findStaleClaimed(claimedBefore: Instant, limit: Int): List = emptyList() - override suspend fun forceCancelClaimed(approvalId: String, expectedVersion: Long, cancelledBy: String, reasonCode: String): ApprovalContinuation = - throw UnsupportedOperationException("stub") + + override suspend fun claimForExecution( + approvalId: String, + expectedVersion: Long, + claimedBy: String, + ): ClaimedApprovalContinuation = throw UnsupportedOperationException("stub") + + override suspend fun complete( + approvalId: String, + expectedVersion: Long, + completedBy: String, + ): ApprovalContinuation = throw UnsupportedOperationException("stub") + + override suspend fun expire( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation = throw UnsupportedOperationException("stub") + + override suspend fun cancel( + approvalId: String, + expectedVersion: Long, + ): ApprovalContinuation = throw UnsupportedOperationException("stub") + + override suspend fun findStaleClaimed( + claimedBefore: Instant, + limit: Int, + ): List = emptyList() + + override suspend fun forceCancelClaimed( + approvalId: String, + expectedVersion: Long, + cancelledBy: String, + reasonCode: String, + ): ApprovalContinuation = throw UnsupportedOperationException("stub") + override suspend fun sweepExpired(): Int = 0 } @@ -248,41 +337,39 @@ private class TestApprovalGatewayRequestFactory : ApprovalGatewayRequestFactory val wfRunId = workflowRunId?.value ?: "wf-run-1" val sensitiveArgs = SensitiveToolArguments.of("{}") return ApprovalGatewayPersistenceRequest( - approvalRequest = ApprovalRequest( - approvalId = "auto-test", - binding = dev.tramai.core.approval.ApprovalBinding( + approvalRequest = testApprovalRequest(now, wfRunId), + continuation = + ApprovalContinuation( + approvalId = "auto-test", workflowRunId = wfRunId, - toolName = "test-tool", + correlationId = "corr", + toolCallId = "tc", + toolName = "test", argumentsDigest = zeroDigest, policyVersion = "v1", workflowDigest = oneDigest, - approvalTokenDigest = twoDigest, + status = ApprovalContinuationStatus.PENDING, + createdAt = now, + approvalExpiresAt = now.plusSeconds(3600), + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, ), - status = ApprovalStatus.PENDING, - requestedBy = "test", - requestedAt = now, - expiresAt = now.plusSeconds(3600), - decidedBy = null, decidedAt = null, decisionComment = null, - consumedBy = null, consumedAt = null, version = 0L, - ), - continuation = ApprovalContinuation( - approvalId = "auto-test", workflowRunId = wfRunId, - correlationId = "corr", toolCallId = "tc", toolName = "test", - argumentsDigest = zeroDigest, policyVersion = "v1", - workflowDigest = oneDigest, status = ApprovalContinuationStatus.PENDING, - createdAt = now, approvalExpiresAt = now.plusSeconds(3600), - claimedBy = null, claimedAt = null, completedAt = null, version = 0L, - ), sensitiveArguments = sensitiveArgs, - suspendedInvocationMetadata = SuspendedInvocationMetadata( - approvalId = "auto-test", toolCallId = "tc", toolName = "test", - toolCallIndex = 0, correlationId = "corr", - identity = EngineExecutionIdentity(wfRunId, "corr", oneDigest, "v1", "test"), - securityContext = ExecutionSecurityContext(), - operationReference = ResumeOperationReference("t.S", "m", "()V", zeroDigest), - replayEnvelopeDigest = zeroDigest, - toolReference = ResumeToolReference("test", zeroDigest), - ), + suspendedInvocationMetadata = + SuspendedInvocationMetadata( + approvalId = "auto-test", + toolCallId = "tc", + toolName = "test", + toolCallIndex = 0, + correlationId = "corr", + identity = EngineExecutionIdentity(wfRunId, "corr", oneDigest, "v1", "test"), + securityContext = ExecutionSecurityContext(), + operationReference = ResumeOperationReference("t.S", "m", "()V", zeroDigest), + replayEnvelopeDigest = zeroDigest, + toolReference = ResumeToolReference("test", zeroDigest), + ), replayEnvelope = SensitiveReplayEnvelope.of(emptyList()), resumeToken = ResumeToken("public-token"), ) @@ -291,8 +378,11 @@ private class TestApprovalGatewayRequestFactory : ApprovalGatewayRequestFactory private open class FullGatewayConfig { @Bean open fun testApprovalStore(): ApprovalStore = StubApprovalStore() + @Bean open fun testApprovalContinuationStore(): ApprovalContinuationStore = GatewayTestApprovalContinuationStore() + @Bean open fun testSuspendedInvocationStore(): SuspendedInvocationStore = GatewayTestSuspendedInvocationStore() + @Bean open fun testGatewayRequestFactory(): ApprovalGatewayRequestFactory = TestApprovalGatewayRequestFactory() } @@ -330,19 +420,67 @@ private open class TransactionalWithAuditIntentConfig : TransactionalGatewayConf } } +private fun testApprovalRequest( + now: Instant, + workflowRunId: String, +): dev.tramai.core.approval.ApprovalRequest = + dev.tramai.core.approval.ApprovalRequest( + approvalId = "auto-test", + binding = + dev.tramai.core.approval.ApprovalBinding( + workflowRunId = workflowRunId, + toolName = "test-tool", + argumentsDigest = zeroDigest, + policyVersion = "v1", + workflowDigest = oneDigest, + approvalTokenDigest = twoDigest, + ), + status = ApprovalStatus.PENDING, + requestedBy = "test", + requestedAt = now, + expiresAt = now.plusSeconds(3600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) + +private fun governedRunIdentity(): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId("governed-run-1"), + ) + /** * Recording mutation store that captures the last audit intent passed to * [createApprovalRequest] for verification in the recording test. */ -private class RecordingApprovalRequestMutationStore( +private class RecordingMutationStore( var lastAuditIntent: SovereignOpsAuditOutboxRecord? = null, ) : SovereignOpsApprovalRequestMutationStore { + /** Number of times [createApprovalRequest] was invoked. */ + var calls: Int = 0 + private set + override suspend fun createApprovalRequest( request: ApprovalGatewayPersistenceRequest, auditIntent: SovereignOpsAuditOutboxRecord?, inboxMetadata: dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadata?, resumeCredential: ApprovalResumeCredentialRecord?, ): SovereignOpsApprovalRequestMutationResult { + calls++ lastAuditIntent = auditIntent return SovereignOpsApprovalRequestMutationResult.Created( approvalId = request.approvalRequest.approvalId, @@ -355,11 +493,9 @@ private class RecordingApprovalRequestMutationStore( private open class RecordingAuditIntentConfig { @Bean @Primary - open fun testApprovalMutationStore(): RecordingApprovalRequestMutationStore = - RecordingApprovalRequestMutationStore() + open fun testApprovalMutationStore(): RecordingMutationStore = RecordingMutationStore() - @Bean open fun testGatewayRequestFactory(): ApprovalGatewayRequestFactory = - TestApprovalGatewayRequestFactory() + @Bean open fun testGatewayRequestFactory(): ApprovalGatewayRequestFactory = TestApprovalGatewayRequestFactory() @Bean open fun testAuditIntentFactory(): ApprovalGatewayAuditIntentFactory = ApprovalGatewayAuditIntentFactory { _, _, _, _ -> @@ -379,7 +515,9 @@ private open class RecordingAuditIntentConfig { private open class StoresOnlyConfig { @Bean open fun testApprovalStore(): ApprovalStore = StubApprovalStore() + @Bean open fun testApprovalContinuationStore(): ApprovalContinuationStore = GatewayTestApprovalContinuationStore() + @Bean open fun testSuspendedInvocationStore(): SuspendedInvocationStore = GatewayTestSuspendedInvocationStore() } @@ -390,19 +528,25 @@ private open class CustomApprovalGatewayConfig { private open class MissingApprovalStoreConfig { @Bean open fun testApprovalContinuationStore(): ApprovalContinuationStore = GatewayTestApprovalContinuationStore() + @Bean open fun testSuspendedInvocationStore(): SuspendedInvocationStore = GatewayTestSuspendedInvocationStore() + @Bean open fun testGatewayRequestFactory(): ApprovalGatewayRequestFactory = TestApprovalGatewayRequestFactory() } private open class MissingContinuationStoreConfig { @Bean open fun testApprovalStore(): ApprovalStore = StubApprovalStore() + @Bean open fun testSuspendedInvocationStore(): SuspendedInvocationStore = GatewayTestSuspendedInvocationStore() + @Bean open fun testGatewayRequestFactory(): ApprovalGatewayRequestFactory = TestApprovalGatewayRequestFactory() } private open class MissingSuspendedInvocationStoreConfig { @Bean open fun testApprovalStore(): ApprovalStore = StubApprovalStore() + @Bean open fun testApprovalContinuationStore(): ApprovalContinuationStore = GatewayTestApprovalContinuationStore() + @Bean open fun testGatewayRequestFactory(): ApprovalGatewayRequestFactory = TestApprovalGatewayRequestFactory() } @@ -410,11 +554,21 @@ private open class MissingSuspendedInvocationStoreConfig { private class StubApprovalStore : ApprovalStore { override suspend fun create(request: ApprovalRequest): ApprovalRequest = request + override suspend fun get(approvalId: String): ApprovalRequest? = null - override suspend fun transition(approvalId: String, expectedVersion: Long, transition: ApprovalTransition): ApprovalRequest = - throw UnsupportedOperationException("stub") - override suspend fun consumeApprovedOrReplay(approvalId: String, expectedVersion: Long, presentedTokenDigest: Sha256Digest, consumedBy: String) = - throw UnsupportedOperationException("stub") + + override suspend fun transition( + approvalId: String, + expectedVersion: Long, + transition: ApprovalTransition, + ): ApprovalRequest = throw UnsupportedOperationException("stub") + + override suspend fun consumeApprovedOrReplay( + approvalId: String, + expectedVersion: Long, + presentedTokenDigest: Sha256Digest, + consumedBy: String, + ) = throw UnsupportedOperationException("stub") } private class CustomApprovalGateway : ApprovalGateway { diff --git a/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/FileSovereignOpsAuditOutboxCodec.kt b/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/FileSovereignOpsAuditOutboxCodec.kt new file mode 100644 index 000000000..69e9ffa5f --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/FileSovereignOpsAuditOutboxCodec.kt @@ -0,0 +1,203 @@ +package dev.tramai.spring.sovereign.persistence.file + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonNode +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.persistence.file.FileStoreCorruptionException +import dev.tramai.persistence.file.FileStorePermissionException +import dev.tramai.persistence.file.FileStoreUnsupportedFormatException +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord + +/** + * 0.7.1d: the single file-persistence codec for sovereign-ops audit outbox records. + * + * Persistence has exactly two shapes: + * + * - **V1** — the ordinary record with no governed attribution. Permanently legacy. + * - **V2** — the ordinary record plus the complete canonical [GovernedRunIdentity]. + * + * The identity is one non-optional nested object in V2, never five independently optional fields, so a + * partially attributed V2 cannot be constructed or decoded at all. An unknown schema version is an + * unsupported format, never an old record. + * + * Every file transition decodes through [decodeOutboxRecord], mutates only + * [DecodedOutboxRecord.record], and re-encodes through [encodeOutboxRecord] with the decoded identity + * untouched. The encoder chooses the shape from the identity's presence, so a transition can only drop + * attribution by first dropping it from the decoded value. + * + * The codec emits exactly two exception types: [FileStoreCorruptionException] for a damaged payload and + * [FileStoreUnsupportedFormatException] for an unknown version. The two must never be conflated. + */ +internal data class DecodedOutboxRecord( + val record: SovereignOpsAuditOutboxRecord, + val runIdentity: GovernedRunIdentity?, + val outboxRecordVersion: Long, +) + +/** + * Persisted form of the canonical identity: the complete deployment tuple plus the run identifier, + * which must agree with the ordinary record's `workflowRunId` on decode. + */ +internal data class PersistedGovernedRunIdentity( + @param:JsonProperty("runId") val runId: String, + @param:JsonProperty("workloadId") val workloadId: String, + @param:JsonProperty("configurationId") val configurationId: String, + @param:JsonProperty("configurationVersion") val configurationVersion: String, + @param:JsonProperty("environmentId") val environmentId: String, + @param:JsonProperty("deploymentId") val deploymentId: String, +) { + fun toCore(): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configurationId), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId(environmentId), + deploymentId = DeploymentId(deploymentId), + ), + runId = RunId(runId), + ) +} + +internal fun GovernedRunIdentity.toPersisted(): PersistedGovernedRunIdentity = + PersistedGovernedRunIdentity( + runId = runId.value, + workloadId = deployment.workloadId.value, + configurationId = deployment.configuration.id.value, + configurationVersion = deployment.configuration.version.value, + environmentId = deployment.environmentId.value, + deploymentId = deployment.deploymentId.value, + ) + +/** + * V2 carrier: the ordinary record (reusing the V1 DTO verbatim, so the ordinary fields cannot drift + * between shapes) plus the complete identity. + */ +internal data class PersistedSovereignOpsAuditOutboxRecordV2( + @get:JsonProperty("schemaVersion") val schemaVersion: Int = 2, + @param:JsonProperty("record") val record: PersistedSovereignOpsAuditOutboxRecordV1, + @param:JsonProperty("governedRunIdentity") val governedRunIdentity: PersistedGovernedRunIdentity, +) { + fun toJson(): String = OPS_OUTBOX_JSON.writeValueAsString(this) +} + +/** + * Decodes any persisted outbox payload, dispatching on the declared schema version. + */ +internal fun decodeOutboxRecord(json: String): DecodedOutboxRecord { + val version = outboxSchemaVersion(json) + if (version != OFFICIAL_VERSION && version != GOVERNED_VERSION) { + throw FileStoreUnsupportedFormatException("unsupported-outbox-schema-version: $version") + } + return decodeKnownVersion(json, version) +} + +/** + * Decodes a known schema version, classifying malformed content as corruption. + * + * A V2 payload whose identity disagrees with the ordinary record's `workflowRunId` is corruption and + * fails closed; it is never read as legacy. + */ +private fun decodeKnownVersion( + json: String, + version: Int, +): DecodedOutboxRecord = + try { + if (version == OFFICIAL_VERSION) decodeLegacy(json) else decodeGoverned(json) + } catch (e: IllegalStateException) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } catch (e: IllegalArgumentException) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } + +private fun decodeLegacy(json: String): DecodedOutboxRecord { + val v1 = PersistedSovereignOpsAuditOutboxRecordV1.fromJson(json) + return DecodedOutboxRecord( + record = v1.toDomain(), + runIdentity = null, + outboxRecordVersion = v1.outboxRecordVersion, + ) +} + +private fun decodeGoverned(json: String): DecodedOutboxRecord { + val v2 = strictOpsOutboxReadValue(json) + val identity = v2.governedRunIdentity.toCore() + val record = v2.record.toDomain() + if (identity.runId.value != record.workflowRunId) { + throw FileStoreCorruptionException( + "governed-outbox-attribution-mismatch: " + + "'${record.workflowRunId}' != canonical runId '${identity.runId.value}'", + ) + } + return DecodedOutboxRecord( + record = record, + runIdentity = identity, + outboxRecordVersion = v2.record.outboxRecordVersion, + ) +} + +/** + * Encodes a decoded record, choosing the shape from the identity's presence: absent → V1, present → V2. + */ +internal fun encodeOutboxRecord( + decoded: DecodedOutboxRecord, + outboxRecordVersion: Long, +): String { + val persisted = decoded.record.toPersistedV1(outboxRecordVersion) + return if (decoded.runIdentity == null) { + persisted.toJson() + } else { + PersistedSovereignOpsAuditOutboxRecordV2( + record = persisted, + governedRunIdentity = decoded.runIdentity.toPersisted(), + ).toJson() + } +} + +/** + * Reads only the declared schema version, leaving the payload untouched for the real decode. + * + * Only an integral, in-range JSON number is a schema declaration: anything else — missing, null, + * text, float, out of int range — is corruption. `asInt()` coerces instead (`"2"` and `2.9` to 2, + * `"abc"` to 0), which turns a damaged payload into a wrong answer: a bogus V1/V2 decode, or a + * false "unsupported version" report for a payload that is simply damaged. + */ +private fun outboxSchemaVersion(json: String): Int { + val version = readJsonTree(json)?.get("schemaVersion") + val declared = version?.takeIf { it.isIntegralNumber && it.canConvertToInt() }?.intValue() + return declared ?: throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD) +} + +/** + * Strict tree read: a payload that is not valid JSON at all is corruption, not an unknown version. + */ +private fun readJsonTree(json: String): JsonNode? = + try { + OPS_OUTBOX_JSON.readTree(json) + } catch (e: JsonProcessingException) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } + +/** + * Raises a file-permission failure with the validators' exact exception type and message. + * + * Hosted here rather than beside its callers because the legacy store file sits at its + * function-count ceiling and must not be restructured for a reporting concern. + */ +internal fun permissionFailure(message: String): Nothing = throw FileStorePermissionException(message) + +private const val OFFICIAL_VERSION = 1 +private const val GOVERNED_VERSION = 2 diff --git a/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/FileSovereignOpsAuditOutboxStore.kt b/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/FileSovereignOpsAuditOutboxStore.kt index 1d3b4e56d..dbd87f7be 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/FileSovereignOpsAuditOutboxStore.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/FileSovereignOpsAuditOutboxStore.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + package dev.tramai.spring.sovereign.persistence.file import com.fasterxml.jackson.annotation.JsonInclude @@ -7,12 +9,15 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.readValue +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi import dev.tramai.persistence.file.AesGcmFileEncryption import dev.tramai.persistence.file.EncryptedFileEnvelopeV1 import dev.tramai.persistence.file.FileStoreCorruptionException import dev.tramai.persistence.file.FileStorePermissionException import dev.tramai.persistence.file.FileStoreSha256 import dev.tramai.persistence.file.FileStoreUnsupportedFormatException +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxGovernance import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore @@ -33,18 +38,20 @@ import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantReadWriteLock import javax.crypto.SecretKey -private val OPS_OUTBOX_JSON: ObjectMapper = JsonMapper.builder() - .addModule(KotlinModule.Builder().build()) - .serializationInclusion(JsonInclude.Include.NON_ABSENT) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) - .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) - .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, false) - .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true) - .build() +internal val OPS_OUTBOX_JSON: ObjectMapper = + JsonMapper + .builder() + .addModule(KotlinModule.Builder().build()) + .serializationInclusion(JsonInclude.Include.NON_ABSENT) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) + .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) + .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, false) + .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true) + .build() private const val MAX_JSON_SIZE = 10_485_760 -private inline fun strictOpsOutboxReadValue(json: String): T { +internal inline fun strictOpsOutboxReadValue(json: String): T { require(json.length <= MAX_JSON_SIZE) { "json-payload-too-large" } return try { OPS_OUTBOX_JSON.readValue(json.trim()) @@ -84,37 +91,35 @@ internal data class PersistedSovereignOpsAuditOutboxRecordV1( fun toJson(): String = OPS_OUTBOX_JSON.writeValueAsString(this) companion object { - fun fromJson(json: String): PersistedSovereignOpsAuditOutboxRecordV1 = - strictOpsOutboxReadValue(json) + fun fromJson(json: String): PersistedSovereignOpsAuditOutboxRecordV1 = strictOpsOutboxReadValue(json) } } -internal fun SovereignOpsAuditOutboxRecord.toPersistedV1( - outboxRecordVersion: Long, -): PersistedSovereignOpsAuditOutboxRecordV1 = PersistedSovereignOpsAuditOutboxRecordV1( - schemaVersion = 1, - outboxId = outboxId, - aggregateType = aggregateType, - aggregateIdDigest = aggregateIdDigest, - operation = operation, - eventKey = eventKey, - actor = actor, - workflowRunId = workflowRunId, - correlationId = correlationId, - approvalStatus = approvalStatus, - approvalVersion = approvalVersion, - reasonDigest = reasonDigest, - reasonLength = reasonLength, - status = status.name, - attemptCount = attemptCount, - lastErrorCode = lastErrorCode, - claimedBy = claimedBy, - claimedAt = claimedAt?.toString(), - claimExpiresAt = claimExpiresAt?.toString(), - createdAt = createdAt.toString(), - emittedAt = emittedAt?.toString(), - outboxRecordVersion = outboxRecordVersion, -) +internal fun SovereignOpsAuditOutboxRecord.toPersistedV1(version: Long): PersistedSovereignOpsAuditOutboxRecordV1 = + PersistedSovereignOpsAuditOutboxRecordV1( + schemaVersion = 1, + outboxId = outboxId, + aggregateType = aggregateType, + aggregateIdDigest = aggregateIdDigest, + operation = operation, + eventKey = eventKey, + actor = actor, + workflowRunId = workflowRunId, + correlationId = correlationId, + approvalStatus = approvalStatus, + approvalVersion = approvalVersion, + reasonDigest = reasonDigest, + reasonLength = reasonLength, + status = status.name, + attemptCount = attemptCount, + lastErrorCode = lastErrorCode, + claimedBy = claimedBy, + claimedAt = claimedAt?.toString(), + claimExpiresAt = claimExpiresAt?.toString(), + createdAt = createdAt.toString(), + emittedAt = emittedAt?.toString(), + outboxRecordVersion = version, + ) internal fun PersistedSovereignOpsAuditOutboxRecordV1.toDomain(): SovereignOpsAuditOutboxRecord { require(schemaVersion == 1) { "unsupported-outbox-schema-version" } @@ -156,7 +161,6 @@ class FileSovereignOpsAuditOutboxStore internal constructor( private val claimLeaseDuration: Duration = SovereignOpsAuditOutboxRecord.DEFAULT_CLAIM_EXPIRY, ) : SovereignOpsAuditOutboxStore, AutoCloseable { - companion object { private const val RECORD_TYPE = STORAGE_NAME private const val OUTBOX_DIR = STORAGE_NAME @@ -177,128 +181,132 @@ class FileSovereignOpsAuditOutboxStore internal constructor( rebuildIndex() } - private fun storePath(outboxId: String): Path = - outboxDir.resolve("${recordKeyDigest(outboxId)}$FILE_EXTENSION") + private fun storePath(outboxId: String): Path = outboxDir.resolve("${recordKeyDigest(outboxId)}$FILE_EXTENSION") - private fun recordKeyDigest(outboxId: String): String = - FileStoreSha256.digest(RECORD_TYPE, outboxId) + private fun recordKeyDigest(outboxId: String): String = FileStoreSha256.digest(RECORD_TYPE, outboxId) - private fun getLockForDigest(digest: String): ReentrantLock = - recordLocks.computeIfAbsent(digest) { ReentrantLock() } + private fun getLockForDigest(key: String): ReentrantLock = recordLocks.computeIfAbsent(key) { ReentrantLock() } - private fun getLockForOutboxId(outboxId: String): ReentrantLock = - getLockForDigest(recordKeyDigest(outboxId)) + private fun getLockForOutboxId(outboxId: String): ReentrantLock = getLockForDigest(recordKeyDigest(outboxId)) - fun rebuildIndex() = lease.withOpenOperation { - eventKeyIndex.clear() - if (!Files.exists(outboxDir, LinkOption.NOFOLLOW_LINKS)) return@withOpenOperation - validateManagedDirectory(outboxDir, STORAGE_NAME) - for (entry in committedEntries()) { - val digest = digestFromPath(entry) - validatePersistedDto(readCurrentByDigest(entry, digest), digest) - .also { eventKeyIndex[it.eventKey] = it.outboxId } + fun rebuildIndex() = + lease.withOpenOperation { + eventKeyIndex.clear() + if (!Files.exists(outboxDir, LinkOption.NOFOLLOW_LINKS)) return@withOpenOperation + validateManagedDirectory(outboxDir, STORAGE_NAME) + for (entry in committedEntries()) { + val digest = digestFromPath(entry) + val decoded = validatePersistedRecord(readCurrentByDigest(entry, digest), digest) + eventKeyIndex[decoded.record.eventKey] = decoded.record.outboxId + } } - } override fun isDurable(): Boolean = true - override suspend fun append( - record: SovereignOpsAuditOutboxRecord, - ): SovereignOpsAuditOutboxRecord = lease.withOpenOperation { - validateManagedDirectory(outboxDir, STORAGE_NAME) - require(record.outboxId.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-id" } - require(record.eventKey.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-event-key" } - require(record.status == SovereignOpsAuditOutboxStatus.PREPARED) { - "tramai-sovereign-ops-outbox-invalid-status" - } - - appendLock.lock() - try { - require(!Files.exists(storePath(record.outboxId), LinkOption.NOFOLLOW_LINKS)) { - "tramai-sovereign-ops-outbox-duplicate-id" - } - require(!eventKeyIndex.containsKey(record.eventKey)) { - "tramai-sovereign-ops-outbox-duplicate-event-key" + override suspend fun append(record: SovereignOpsAuditOutboxRecord): SovereignOpsAuditOutboxRecord = + lease.withOpenOperation { + validateManagedDirectory(outboxDir, STORAGE_NAME) + require(record.outboxId.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-id" } + require(record.eventKey.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-event-key" } + require(record.status == SovereignOpsAuditOutboxStatus.PREPARED) { + "tramai-sovereign-ops-outbox-invalid-status" } - createAtomically(record.toPersistedV1(outboxRecordVersion = 0L)) - eventKeyIndex[record.eventKey] = record.outboxId - record - } finally { - appendLock.unlock() + appendLock.lock() + try { + require(!Files.exists(storePath(record.outboxId), LinkOption.NOFOLLOW_LINKS)) { + "tramai-sovereign-ops-outbox-duplicate-id" + } + require(!eventKeyIndex.containsKey(record.eventKey)) { + "tramai-sovereign-ops-outbox-duplicate-event-key" + } + + createAtomically(record.toPersistedV1(version = 0L).toJson(), record.outboxId) + eventKeyIndex[record.eventKey] = record.outboxId + record + } finally { + appendLock.unlock() + } } - } override suspend fun markReadyForDispatch( outboxId: String, expectedStatus: SovereignOpsAuditOutboxStatus, - ): SovereignOpsAuditOutboxRecord = mutate(outboxId) { dto -> - require(expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED) { - ERROR_OUTBOX_STATUS_MISMATCH - } - require(dto.status == expectedStatus.name) { - ERROR_OUTBOX_STATUS_MISMATCH + ): SovereignOpsAuditOutboxRecord = + mutate(outboxId) { decoded -> + require(expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED) { + ERROR_OUTBOX_STATUS_MISMATCH + } + require(decoded.record.status == expectedStatus) { + ERROR_OUTBOX_STATUS_MISMATCH + } + decoded.record.copy(status = SovereignOpsAuditOutboxStatus.PENDING) } - dto.toDomain().copy(status = SovereignOpsAuditOutboxStatus.PENDING) - } override suspend fun claimPending( claimedBy: String, limit: Int, now: Instant, - ): List = lease.withOpenOperation { - if (limit <= 0) return@withOpenOperation emptyList() - validateManagedDirectory(outboxDir, STORAGE_NAME) - - val claimed = mutableListOf() - for (entry in committedEntries()) { - if (claimed.size >= limit) break - val digest = digestFromPath(entry) - val lock = getLockForDigest(digest) - lock.lock() - try { - val dto = validatePersistedDto(readCurrentByDigest(entry, digest), digest) - val record = dto.toDomain() - if (!record.isDispatchable(now)) continue - - val updated = record.copy( - status = SovereignOpsAuditOutboxStatus.EMITTING, - attemptCount = record.attemptCount + 1, - claimedBy = claimedBy, - claimedAt = now, - claimExpiresAt = now.plus(claimLeaseDuration), - lastErrorCode = null, - ) - writeCurrent(record.outboxId, updated.toPersistedV1(nextVersion(dto))) - claimed += updated - } finally { - lock.unlock() + ): List = + lease.withOpenOperation { + if (limit <= 0) return@withOpenOperation emptyList() + validateManagedDirectory(outboxDir, STORAGE_NAME) + + val claimed = mutableListOf() + for (entry in committedEntries()) { + if (claimed.size >= limit) break + val digest = digestFromPath(entry) + val lock = getLockForDigest(digest) + lock.lock() + try { + val decoded = validatePersistedRecord(readCurrentByDigest(entry, digest), digest) + val record = decoded.record + if (!record.isDispatchable(now)) continue + + val updated = + record.copy( + status = SovereignOpsAuditOutboxStatus.EMITTING, + attemptCount = record.attemptCount + 1, + claimedBy = claimedBy, + claimedAt = now, + claimExpiresAt = now.plus(claimLeaseDuration), + lastErrorCode = null, + ) + writeCurrent( + record.outboxId, + decoded.copy(record = updated), + nextVersion(decoded.outboxRecordVersion), + ) + claimed += updated + } finally { + lock.unlock() + } } + claimed } - claimed - } override suspend fun markEmitted( outboxId: String, expectedStatus: SovereignOpsAuditOutboxStatus, expectedAttemptCount: Int, emittedAt: Instant, - ): SovereignOpsAuditOutboxRecord = mutate(outboxId) { dto -> - require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { - ERROR_OUTBOX_STATUS_MISMATCH - } - require(dto.status == expectedStatus.name) { - ERROR_OUTBOX_STATUS_MISMATCH - } - val record = dto.toDomain() - check(record.attemptCount == expectedAttemptCount) { - ERROR_OUTBOX_CONCURRENT_UPDATE + ): SovereignOpsAuditOutboxRecord = + mutate(outboxId) { decoded -> + require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { + ERROR_OUTBOX_STATUS_MISMATCH + } + require(decoded.record.status == expectedStatus) { + ERROR_OUTBOX_STATUS_MISMATCH + } + val record = decoded.record + check(record.attemptCount == expectedAttemptCount) { + ERROR_OUTBOX_CONCURRENT_UPDATE + } + record.copy( + status = SovereignOpsAuditOutboxStatus.EMITTED, + emittedAt = emittedAt, + ) } - record.copy( - status = SovereignOpsAuditOutboxStatus.EMITTED, - emittedAt = emittedAt, - ) - } override suspend fun markFailed( outboxId: String, @@ -306,43 +314,99 @@ class FileSovereignOpsAuditOutboxStore internal constructor( expectedAttemptCount: Int, errorCode: String, retryable: Boolean, - ): SovereignOpsAuditOutboxRecord = mutate(outboxId) { dto -> - if (retryable) { - require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { - ERROR_OUTBOX_STATUS_MISMATCH + ): SovereignOpsAuditOutboxRecord = + mutate(outboxId) { decoded -> + if (retryable) { + require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { + ERROR_OUTBOX_STATUS_MISMATCH + } + } else { + require( + expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING || + expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED, + ) { + ERROR_OUTBOX_STATUS_MISMATCH + } } - } else { - require( - expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING || - expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED - ) { + require(decoded.record.status == expectedStatus) { ERROR_OUTBOX_STATUS_MISMATCH } + val record = decoded.record + check(record.attemptCount == expectedAttemptCount) { + ERROR_OUTBOX_CONCURRENT_UPDATE + } + val targetStatus = + if (retryable) { + SovereignOpsAuditOutboxStatus.FAILED_RETRYABLE + } else { + SovereignOpsAuditOutboxStatus.FAILED_PERMANENT + } + record.copy( + status = targetStatus, + lastErrorCode = errorCode, + ) } - require(dto.status == expectedStatus.name) { - ERROR_OUTBOX_STATUS_MISMATCH - } - val record = dto.toDomain() - check(record.attemptCount == expectedAttemptCount) { - ERROR_OUTBOX_CONCURRENT_UPDATE - } - val targetStatus = if (retryable) { - SovereignOpsAuditOutboxStatus.FAILED_RETRYABLE - } else { - SovereignOpsAuditOutboxStatus.FAILED_PERMANENT + + /** + * 0.7.1d: appends a governed record, persisting the ordinary outbox fields and the complete + * canonical identity as one encrypted atomic write. No second file, no identity sidecar. + */ + internal suspend fun appendGoverned(entry: GovernedSovereignOpsAuditOutboxRecord) { + lease.withOpenOperation { + validateManagedDirectory(outboxDir, STORAGE_NAME) + val record = entry.record + require(record.outboxId.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-id" } + require(record.eventKey.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-event-key" } + require(record.status == SovereignOpsAuditOutboxStatus.PREPARED) { + "tramai-sovereign-ops-outbox-invalid-status" + } + appendLock.lock() + try { + require(!Files.exists(storePath(record.outboxId), LinkOption.NOFOLLOW_LINKS)) { + "tramai-sovereign-ops-outbox-duplicate-id" + } + require(!eventKeyIndex.containsKey(record.eventKey)) { + "tramai-sovereign-ops-outbox-duplicate-event-key" + } + val decoded = DecodedOutboxRecord(record, entry.runIdentity, 0L) + createAtomically(encodeOutboxRecord(decoded, 0L), record.outboxId) + eventKeyIndex[record.eventKey] = record.outboxId + } finally { + appendLock.unlock() + } } - record.copy( - status = targetStatus, - lastErrorCode = errorCode, - ) } + /** + * 0.7.1d: resolves provenance from record existence first, so an absent record is never mistaken + * for a legacy one. + */ + internal suspend fun governanceById(outboxId: String): SovereignOpsAuditOutboxGovernance = + lease.withOpenOperation { + val lock = getLockForOutboxId(outboxId) + lock.lock() + try { + val decoded = readCurrent(outboxId) + if (decoded == null) { + SovereignOpsAuditOutboxGovernance.NoRecord + } else if (decoded.runIdentity == null) { + SovereignOpsAuditOutboxGovernance.Legacy(decoded.record) + } else { + SovereignOpsAuditOutboxGovernance.Governed( + GovernedSovereignOpsAuditOutboxRecord(decoded.record, decoded.runIdentity), + ) + } + } finally { + lock.unlock() + } + } + override suspend fun get(outboxId: String): SovereignOpsAuditOutboxRecord? = lease.withOpenOperation { val lock = getLockForOutboxId(outboxId) lock.lock() try { - readCurrent(outboxId)?.toDomain() + readCurrent(outboxId)?.record } finally { lock.unlock() } @@ -354,38 +418,38 @@ class FileSovereignOpsAuditOutboxStore internal constructor( val lock = getLockForOutboxId(outboxId) lock.lock() try { - readCurrent(outboxId)?.toDomain() + readCurrent(outboxId)?.record } finally { lock.unlock() } } override suspend fun listPending(limit: Int): List = - listRecords(limit) { it.status == SovereignOpsAuditOutboxStatus.PENDING.name } + listRecords(limit) { it.status == SovereignOpsAuditOutboxStatus.PENDING } override suspend fun listByStatus( status: SovereignOpsAuditOutboxStatus, limit: Int, - ): List = - listRecords(limit) { it.status == status.name } + ): List = listRecords(limit) { it.status == status } override suspend fun listExpiredEmitting( now: Instant, limit: Int, ): List = listRecords(limit) { - it.status == SovereignOpsAuditOutboxStatus.EMITTING.name && - it.claimExpiresAt?.let { claimExpiresAt -> Instant.parse(claimExpiresAt).isBefore(now) } == true + it.status == SovereignOpsAuditOutboxStatus.EMITTING && + it.claimExpiresAt?.isBefore(now) == true } - fun verifyAll() = lease.withOpenOperation { - if (!Files.exists(outboxDir, LinkOption.NOFOLLOW_LINKS)) return@withOpenOperation - validateManagedDirectory(outboxDir, STORAGE_NAME) - for (entry in committedEntries()) { - val digest = digestFromPath(entry) - validatePersistedDto(readCurrentByDigest(entry, digest), digest).toDomain() + fun verifyAll() = + lease.withOpenOperation { + if (!Files.exists(outboxDir, LinkOption.NOFOLLOW_LINKS)) return@withOpenOperation + validateManagedDirectory(outboxDir, STORAGE_NAME) + for (entry in committedEntries()) { + val digest = digestFromPath(entry) + validatePersistedRecord(readCurrentByDigest(entry, digest), digest) + } } - } override fun close() { lease.close() @@ -393,93 +457,96 @@ class FileSovereignOpsAuditOutboxStore internal constructor( private fun mutate( outboxId: String, - update: (PersistedSovereignOpsAuditOutboxRecordV1) -> SovereignOpsAuditOutboxRecord, - ): SovereignOpsAuditOutboxRecord = lease.withOpenOperation { - validateManagedDirectory(outboxDir, STORAGE_NAME) - val lock = getLockForOutboxId(outboxId) - lock.lock() - try { - val dto = readCurrent(outboxId) - ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") - val updated = update(dto) - writeCurrent(outboxId, updated.toPersistedV1(nextVersion(dto))) - updated - } finally { - lock.unlock() + update: (DecodedOutboxRecord) -> SovereignOpsAuditOutboxRecord, + ): SovereignOpsAuditOutboxRecord = + lease.withOpenOperation { + validateManagedDirectory(outboxDir, STORAGE_NAME) + val lock = getLockForOutboxId(outboxId) + lock.lock() + try { + val decoded = + readCurrent(outboxId) + ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") + // The updater may only replace the ordinary record; the decoded identity is carried + // around the mutation, so a transition can never rewrite a governed V2 payload as V1. + val updated = update(decoded) + writeCurrent(outboxId, decoded.copy(record = updated), nextVersion(decoded.outboxRecordVersion)) + updated + } finally { + lock.unlock() + } } - } - private fun readCurrent(outboxId: String): PersistedSovereignOpsAuditOutboxRecordV1? { + private fun readCurrent(outboxId: String): DecodedOutboxRecord? { val path = storePath(outboxId) if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return null val digest = recordKeyDigest(outboxId) - return validatePersistedDto(readCurrentByDigest(path, digest), digest) + return validatePersistedRecord(readCurrentByDigest(path, digest), digest) } private fun readCurrentByDigest( path: Path, digest: String, - ): PersistedSovereignOpsAuditOutboxRecordV1 { + ): DecodedOutboxRecord { validateRegularFile(path, STORAGE_NAME) - val plaintext = try { - readAndDecrypt(path, digest) - } catch (e: FileStoreCorruptionException) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } - return try { - PersistedSovereignOpsAuditOutboxRecordV1.fromJson(String(plaintext, Charsets.UTF_8)) - } catch (e: Exception) { - throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) - } + val plaintext = + try { + readAndDecrypt(path, digest) + } catch (e: FileStoreCorruptionException) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } catch (e: Exception) { + throw FileStoreCorruptionException(ERROR_CORRUPTED_RECORD, e) + } + // Decoding sits outside the envelope-failure catch above, so the codec's own error contract + // (corruption vs unsupported format) reaches the caller unchanged. + return decodeOutboxRecord(String(plaintext, Charsets.UTF_8)) } - private fun validatePersistedDto( - dto: PersistedSovereignOpsAuditOutboxRecordV1, + private fun validatePersistedRecord( + decoded: DecodedOutboxRecord, digest: String, - ): PersistedSovereignOpsAuditOutboxRecordV1 { - if (dto.schemaVersion != 1) { - throw FileStoreUnsupportedFormatException("unsupported-outbox-schema-version") - } - if (FileStoreSha256.digest(RECORD_TYPE, dto.outboxId) != digest) { + ): DecodedOutboxRecord { + if (FileStoreSha256.digest(RECORD_TYPE, decoded.record.outboxId) != digest) { throw FileStoreCorruptionException("ops-audit-outbox-id-filename-digest-mismatch") } - try { - dto.toDomain() - } catch (e: Exception) { - throw FileStoreCorruptionException("ops-audit-outbox-domain-conversion-failed", e) - } - return dto + return decoded } private fun writeCurrent( outboxId: String, - dto: PersistedSovereignOpsAuditOutboxRecordV1, + decoded: DecodedOutboxRecord, + outboxRecordVersion: Long, ) { atomicEncryptWrite( targetPath = storePath(outboxId), recordKeyDigest = recordKeyDigest(outboxId), - plaintextBytes = dto.toJson().toByteArray(Charsets.UTF_8), + plaintextBytes = encodeOutboxRecord(decoded, outboxRecordVersion).toByteArray(Charsets.UTF_8), replaceExisting = true, ) } - private fun createAtomically(dto: PersistedSovereignOpsAuditOutboxRecordV1) { + private fun createAtomically( + json: String, + outboxId: String, + ) { atomicEncryptWrite( - targetPath = storePath(dto.outboxId), - recordKeyDigest = recordKeyDigest(dto.outboxId), - plaintextBytes = dto.toJson().toByteArray(Charsets.UTF_8), + targetPath = storePath(outboxId), + recordKeyDigest = recordKeyDigest(outboxId), + plaintextBytes = json.toByteArray(Charsets.UTF_8), replaceExisting = false, ) } - private fun readAndDecrypt(path: Path, expectedRecordKeyDigest: String): ByteArray { - val envelope = try { - EncryptedFileEnvelopeV1.fromJson(boundedReadText(path)) - } catch (e: Exception) { - throw FileStoreCorruptionException("ops-audit-outbox-envelope-corrupted", e) - } + private fun readAndDecrypt( + path: Path, + expectedRecordKeyDigest: String, + ): ByteArray { + val envelope = + try { + EncryptedFileEnvelopeV1.fromJson(boundedReadText(path)) + } catch (e: Exception) { + throw FileStoreCorruptionException("ops-audit-outbox-envelope-corrupted", e) + } return AesGcmFileEncryption.decrypt( key = encryptionKey, envelope = envelope, @@ -495,21 +562,23 @@ class FileSovereignOpsAuditOutboxStore internal constructor( plaintextBytes: ByteArray, replaceExisting: Boolean, ) { - val (nonceBase64, ciphertextBase64) = AesGcmFileEncryption.encrypt( - key = encryptionKey, - recordType = RECORD_TYPE, - recordKeyDigest = recordKeyDigest, - keyId = KEY_ID, - plaintextBytes = plaintextBytes, - ) - val envelope = EncryptedFileEnvelopeV1( - envelopeVersion = 1, - recordType = RECORD_TYPE, - recordKeyDigest = recordKeyDigest, - keyId = KEY_ID, - nonceBase64 = nonceBase64, - ciphertextBase64 = ciphertextBase64, - ) + val (nonceBase64, ciphertextBase64) = + AesGcmFileEncryption.encrypt( + key = encryptionKey, + recordType = RECORD_TYPE, + recordKeyDigest = recordKeyDigest, + keyId = KEY_ID, + plaintextBytes = plaintextBytes, + ) + val envelope = + EncryptedFileEnvelopeV1( + envelopeVersion = 1, + recordType = RECORD_TYPE, + recordKeyDigest = recordKeyDigest, + keyId = KEY_ID, + nonceBase64 = nonceBase64, + ciphertextBase64 = ciphertextBase64, + ) val bytes = envelope.toJson().toByteArray(Charsets.UTF_8) if (replaceExisting) { val temp = tempSibling(targetPath) @@ -538,25 +607,26 @@ class FileSovereignOpsAuditOutboxStore internal constructor( private fun listRecords( limit: Int, - predicate: (PersistedSovereignOpsAuditOutboxRecordV1) -> Boolean, - ): List = lease.withOpenOperation { - if (limit <= 0) return@withOpenOperation emptyList() - validateManagedDirectory(outboxDir, STORAGE_NAME) - val results = mutableListOf() - for (entry in committedEntries()) { - if (results.size >= limit) break - val digest = digestFromPath(entry) - val lock = getLockForDigest(digest) - lock.lock() - try { - val dto = validatePersistedDto(readCurrentByDigest(entry, digest), digest) - if (predicate(dto)) results += dto.toDomain() - } finally { - lock.unlock() + predicate: (SovereignOpsAuditOutboxRecord) -> Boolean, + ): List = + lease.withOpenOperation { + if (limit <= 0) return@withOpenOperation emptyList() + validateManagedDirectory(outboxDir, STORAGE_NAME) + val results = mutableListOf() + for (entry in committedEntries()) { + if (results.size >= limit) break + val digest = digestFromPath(entry) + val lock = getLockForDigest(digest) + lock.lock() + try { + val decoded = validatePersistedRecord(readCurrentByDigest(entry, digest), digest) + if (predicate(decoded.record)) results += decoded.record + } finally { + lock.unlock() + } } + results } - results - } private fun committedEntries(): List { val entries = mutableListOf() @@ -591,12 +661,12 @@ class FileSovereignOpsAuditOutboxStore internal constructor( status == SovereignOpsAuditOutboxStatus.EMITTING && expiresAt != null && expiresAt.isBefore(now) - ) + ) } - private fun nextVersion(dto: PersistedSovereignOpsAuditOutboxRecordV1): Long = + private fun nextVersion(currentVersion: Long): Long = try { - Math.addExact(dto.outboxRecordVersion, 1L) + Math.addExact(currentVersion, 1L) } catch (_: ArithmeticException) { throw IllegalStateException("tramai-sovereign-ops-outbox-version-overflow") } @@ -634,7 +704,10 @@ private val FILE_PERMS_0600: Set = private val RANDOM = SecureRandom() -private fun ensureManagedDirectory(path: Path, description: String) { +private fun ensureManagedDirectory( + path: Path, + description: String, +) { if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { validateManagedDirectory(path, description) return @@ -643,23 +716,29 @@ private fun ensureManagedDirectory(path: Path, description: String) { forceParentDirectory(path.parent) } -private fun validateManagedDirectory(path: Path, description: String) { - if (Files.isSymbolicLink(path)) throw FileStorePermissionException("$description-symlink-rejected") +private fun validateManagedDirectory( + path: Path, + description: String, +) { + if (Files.isSymbolicLink(path)) permissionFailure("$description-symlink-rejected") if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { - throw FileStorePermissionException("$description-not-directory") + permissionFailure("$description-not-directory") } if (Files.getPosixFilePermissions(path, LinkOption.NOFOLLOW_LINKS) != DIR_PERMS_0700) { - throw FileStorePermissionException("$description-permission-denied") + permissionFailure("$description-permission-denied") } } -private fun validateRegularFile(path: Path, description: String) { - if (Files.isSymbolicLink(path)) throw FileStorePermissionException("$description-symlink-rejected") +private fun validateRegularFile( + path: Path, + description: String, +) { + if (Files.isSymbolicLink(path)) permissionFailure("$description-symlink-rejected") if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { - throw FileStorePermissionException("$description-not-regular-file") + permissionFailure("$description-not-regular-file") } if (Files.getPosixFilePermissions(path, LinkOption.NOFOLLOW_LINKS) != FILE_PERMS_0600) { - throw FileStorePermissionException("$description-permission-denied") + permissionFailure("$description-permission-denied") } } @@ -678,23 +757,29 @@ private fun tempSibling(target: Path): Path { return stagingDir.resolve(".${target.fileName}.tmp.${RANDOM.nextInt(Int.MAX_VALUE)}") } -private fun writeFileWith0600(path: Path, bytes: ByteArray, createNew: Boolean) { - val options = if (createNew) { - setOf(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, StandardOpenOption.DSYNC) - } else { - setOf(StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.DSYNC) - } - FileChannel.open( - path, - options, - PosixFilePermissions.asFileAttribute(FILE_PERMS_0600), - ).use { channel -> - val buffer = ByteBuffer.wrap(bytes) - while (buffer.hasRemaining()) { - channel.write(buffer) +private fun writeFileWith0600( + path: Path, + bytes: ByteArray, + createNew: Boolean, +) { + val options = + if (createNew) { + setOf(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, StandardOpenOption.DSYNC) + } else { + setOf(StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.DSYNC) + } + FileChannel + .open( + path, + options, + PosixFilePermissions.asFileAttribute(FILE_PERMS_0600), + ).use { channel -> + val buffer = ByteBuffer.wrap(bytes) + while (buffer.hasRemaining()) { + channel.write(buffer) + } + channel.force(true) } - channel.force(true) - } } private fun forceParentDirectory(dir: Path?) { @@ -714,4 +799,4 @@ private const val ERROR_OUTBOX_STATUS_MISMATCH = "tramai-sovereign-ops-outbox-st private const val ERROR_OUTBOX_CONCURRENT_UPDATE = "tramai-sovereign-ops-outbox-concurrent-update" /** @see FileSovereignOpsAuditOutboxStore */ -private const val ERROR_CORRUPTED_RECORD = "ops-audit-outbox-record-corrupted" +internal const val ERROR_CORRUPTED_RECORD = "ops-audit-outbox-record-corrupted" diff --git a/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/GovernedFileSovereignOpsAuditOutboxStore.kt b/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/GovernedFileSovereignOpsAuditOutboxStore.kt new file mode 100644 index 000000000..3b5b9e074 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-file/src/main/kotlin/dev/tramai/spring/sovereign/persistence/file/GovernedFileSovereignOpsAuditOutboxStore.kt @@ -0,0 +1,35 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.persistence.file + +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxGovernance +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore + +/** + * 0.7.1d: governed capability over the released file outbox store. + * + * Composition rather than modification: [FileSovereignOpsAuditOutboxStore] keeps its released + * interface list untouched and stays structurally pristine, while this wrapper adds governed append + * and resolved governance reads on top of it. + * + * There is no second file and no identity sidecar. A governed record is the same single encrypted + * atomic record as a legacy one, and the codec chooses V1 or V2 from the identity's presence, so the + * durable authority stays in one place. + * + * Legacy behaviour is delegated transparently, so a caller holding this wrapper sees exactly the + * released store contract. + */ +@ExperimentalTramaiInternalApi +class GovernedFileSovereignOpsAuditOutboxStore( + private val delegate: FileSovereignOpsAuditOutboxStore, +) : SovereignOpsAuditOutboxStore by delegate, + GovernedSovereignOpsAuditOutboxStore { + override suspend fun appendGoverned(entry: GovernedSovereignOpsAuditOutboxRecord) { + delegate.appendGoverned(entry) + } + + override suspend fun governanceById(id: String): SovereignOpsAuditOutboxGovernance = delegate.governanceById(id) +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/FileGovernedSovereignOpsAuditOutboxStoreTest.kt b/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/FileGovernedSovereignOpsAuditOutboxStoreTest.kt new file mode 100644 index 000000000..c2ea25cef --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/FileGovernedSovereignOpsAuditOutboxStoreTest.kt @@ -0,0 +1,330 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.persistence.file + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.persistence.file.FileStoreCorruptionException +import dev.tramai.persistence.file.FileStoreUnsupportedFormatException +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxGovernance +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * 0.7.1d governed file outbox continuity. + * + * Every persistence transition must carry the complete canonical [GovernedRunIdentity] unchanged, and + * a governed V2 record must never be rewritten as V1. These discriminators observe the identity after + * each step, including across store close/reopen, so an accidental `copy(...)` downgrade is caught + * rather than merely exercised. + */ +class FileGovernedSovereignOpsAuditOutboxStoreTest { + private val rootDir: Path = Files.createTempDirectory("tramai-governed-outbox-").toAbsolutePath() + private val key: SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private fun caseDir(name: String): Path = Files.createDirectories(rootDir.resolve(name)) + + private fun fileStore(dir: Path): FileSovereignOpsAuditOutboxStore = FileSovereignOpsAuditOutboxStore(dir, key) + + private fun governed(fileStore: FileSovereignOpsAuditOutboxStore): GovernedFileSovereignOpsAuditOutboxStore = + GovernedFileSovereignOpsAuditOutboxStore(fileStore) + + // ---------- fixtures ---------- + + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256").digest(value.toByteArray()).joinToString("") { "%02x".format(it) } + + private fun identity( + workloadId: String = "wl-1", + runId: String = "run-1", + ): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("cfg-1"), + version = ConfigurationVersion("v1"), + ), + environmentId = EnvironmentId("env-1"), + deploymentId = DeploymentId("dep-1"), + ), + runId = RunId(runId), + ) + + private fun record( + identity: GovernedRunIdentity, + outboxId: String = UUID.randomUUID().toString(), + eventKey: String = "event-${UUID.randomUUID()}", + ): SovereignOpsAuditOutboxRecord = + SovereignOpsAuditOutboxRecord( + outboxId = outboxId, + aggregateIdDigest = sha256("aggregate"), + eventKey = eventKey, + actor = "tester", + workflowRunId = identity.runId.value, + correlationId = null, + approvalStatus = "DENIED", + approvalVersion = 1L, + reasonDigest = sha256("reason"), + reasonLength = 6, + ) + + // ---------- lifecycle ---------- + + @Test + fun `governed identity survives every transition and two restarts`() { + runBlocking { + val dir = caseDir("lifecycle") + val expected = identity() + val original = record(expected) + + var concrete = fileStore(dir) + var store = governed(concrete) + store.appendGoverned(GovernedSovereignOpsAuditOutboxRecord(original, expected)) + assertIdentity(expected, store, original.outboxId) + assertRecordFileCount(dir, 1) + + store.markReadyForDispatch(original.outboxId, SovereignOpsAuditOutboxStatus.PREPARED) + assertIdentity(expected, store, original.outboxId) + + concrete.close() + concrete = fileStore(dir) + store = governed(concrete) + assertIdentity(expected, store, original.outboxId) + + assertEquals(1, store.claimPending("worker-1", 1, Instant.parse("2026-09-14T10:00:00Z")).size) + assertIdentity(expected, store, original.outboxId) + + store.markFailed( + outboxId = original.outboxId, + expectedStatus = SovereignOpsAuditOutboxStatus.EMITTING, + expectedAttemptCount = 1, + errorCode = "transient", + retryable = true, + ) + assertIdentity(expected, store, original.outboxId) + + concrete.close() + concrete = fileStore(dir) + store = governed(concrete) + assertIdentity(expected, store, original.outboxId) + + assertEquals(1, store.claimPending("worker-2", 1, Instant.parse("2026-09-14T11:00:00Z")).size) + assertIdentity(expected, store, original.outboxId) + + store.markEmitted( + outboxId = original.outboxId, + expectedStatus = SovereignOpsAuditOutboxStatus.EMITTING, + expectedAttemptCount = 2, + emittedAt = Instant.parse("2026-09-14T11:05:00Z"), + ) + assertIdentity(expected, store, original.outboxId) + + concrete.verifyAll() + assertRecordFileCount(dir, 1) + } + } + + @Test + fun `legacy record reads as Legacy and an absent record as NoRecord`() { + runBlocking { + val store = governed(fileStore(caseDir("legacy"))) + val identity = identity() + val legacy = record(identity) + store.append(legacy) + + val legacyRecord = + when (val resolved = store.governanceById(legacy.outboxId)) { + is SovereignOpsAuditOutboxGovernance.Legacy -> resolved.record + else -> throw AssertionError("expected Legacy, got \$resolved") + } + assertEquals(legacy, legacyRecord) + + assertEquals( + SovereignOpsAuditOutboxGovernance.NoRecord, + store.governanceById(UUID.randomUUID().toString()), + ) + } + } + + @Test + fun `governed append keeps duplicate protection and writes no second file`() { + runBlocking { + val dir = caseDir("duplicates") + val store = governed(fileStore(dir)) + val identity = identity() + val original = record(identity) + store.appendGoverned(GovernedSovereignOpsAuditOutboxRecord(original, identity)) + assertRecordFileCount(dir, 1) + + val duplicateId = + assertFailsWith { + store.appendGoverned( + GovernedSovereignOpsAuditOutboxRecord(original.copy(eventKey = "other-event"), identity), + ) + } + assertEquals("tramai-sovereign-ops-outbox-duplicate-id", duplicateId.message) + + val duplicateEventKey = + assertFailsWith { + store.appendGoverned( + GovernedSovereignOpsAuditOutboxRecord(record(identity, eventKey = original.eventKey), identity), + ) + } + assertTrue(duplicateEventKey.message?.contains("duplicate") == true, "got ${duplicateEventKey.message}") + assertRecordFileCount(dir, 1) + } + } + + @Test + fun `verifyAll accepts valid V1 and V2`() { + runBlocking { + val dir = caseDir("mixed") + val concrete = fileStore(dir) + val store = governed(concrete) + val identity = identity() + store.append(record(identity)) + store.appendGoverned(GovernedSovereignOpsAuditOutboxRecord(record(identity), identity)) + concrete.verifyAll() + assertRecordFileCount(dir, 2) + } + } + + // ---------- codec fail-closed matrix ---------- + + @Test + fun `unknown schema version is unsupported format, never legacy`() { + assertFailsWith { + decodeOutboxRecord("""{"schemaVersion":3,"record":{},"governedRunIdentity":{}}""") + } + } + + @Test + fun `malformed payload is corruption`() { + assertFailsWith { decodeOutboxRecord("{ not json") } + } + + @Test + fun `a mistyped declared version is corruption, never a known or unsupported version`() { + runBlocking { + val identity = identity() + val json = governedJson(record(identity), identity) + val mistyped = listOf("\"2\"", "2.5", "\"two\"", "null", "99999999999") + + mistyped.forEach { declared -> + val tampered = json.replaceFirst("\"schemaVersion\":2", "\"schemaVersion\":$declared") + assertTrue(tampered != json, "tamper must change the payload") + assertFailsWith { decodeOutboxRecord(tampered) } + } + } + } + + @Test + fun `V2 whose identity disagrees with the run id fails closed`() { + runBlocking { + val identity = identity() + val json = governedJson(record(identity), identity) + val tampered = json.replace("\"runId\":\"run-1\"", "\"runId\":\"run-other\"") + assertTrue(tampered != json, "tamper must change the payload") + assertFailsWith { decodeOutboxRecord(tampered) } + } + } + + @Test + fun `partial V2 identity fails closed`() { + runBlocking { + val identity = identity() + val json = governedJson(record(identity), identity) + val tampered = json.replace(",\"deploymentId\":\"dep-1\"", "") + assertTrue(tampered != json, "tamper must change the payload") + assertFailsWith { decodeOutboxRecord(tampered) } + } + } + + @Test + fun `V1 payload decodes with no governed identity and re-encodes as V1`() { + runBlocking { + val v1Json = record(identity()).toPersistedV1(version = 0L).toJson() + val decoded = decodeOutboxRecord(v1Json) + assertEquals(null, decoded.runIdentity) + + val reEncoded = encodeOutboxRecord(decoded, 1L) + assertTrue(reEncoded.contains("\"schemaVersion\":1"), "a legacy record must never be re-encoded as V2") + assertEquals(decoded.record, decodeOutboxRecord(reEncoded).record) + } + } + + @Test + fun `a governed payload re-encodes as V2 with the identity intact`() { + runBlocking { + val identity = identity() + val decoded = decodeOutboxRecord(governedJson(record(identity), identity)) + assertEquals(identity, requireNotNull(decoded.runIdentity)) + + val reEncoded = encodeOutboxRecord(decoded, 1L) + assertTrue(reEncoded.contains("\"schemaVersion\":2"), "a governed record must stay V2 across rewrites") + + val roundTripped = decodeOutboxRecord(reEncoded) + assertEquals(identity, roundTripped.runIdentity) + assertEquals(decoded.record, roundTripped.record) + } + } + + // ---------- helpers ---------- + + private fun assertRecordFileCount( + dir: Path, + expected: Int, + ) { + val files = + Files.walk(dir).use { stream -> + stream.filter { Files.isRegularFile(it) }.count() + } + assertEquals(expected.toLong(), files, "expected $expected durable file(s) under $dir") + } + + private suspend fun assertIdentity( + expected: GovernedRunIdentity, + store: GovernedFileSovereignOpsAuditOutboxStore, + outboxId: String, + ) { + val persisted = + when (val resolved = store.governanceById(outboxId)) { + is SovereignOpsAuditOutboxGovernance.Governed -> resolved.record.runIdentity + else -> throw AssertionError("expected governed, got \$resolved") + } + assertEquals(expected, persisted, "the complete canonical identity must survive unchanged") + } + + private fun governedJson( + base: SovereignOpsAuditOutboxRecord, + identity: GovernedRunIdentity, + ): String = + encodeOutboxRecord( + DecodedOutboxRecord(base, identity, outboxRecordVersion = 0L), + outboxRecordVersion = 0L, + ) +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/GovernedFileSovereignOpsAuditOutboxStoreTckTest.kt b/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/GovernedFileSovereignOpsAuditOutboxStoreTckTest.kt new file mode 100644 index 000000000..abd217d79 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/GovernedFileSovereignOpsAuditOutboxStoreTckTest.kt @@ -0,0 +1,47 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.persistence.file + +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore +import dev.tramai.testing.persistence.outbox.SovereignOpsAuditOutboxStoreTck +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestInstance +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicLong +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.io.path.exists + +/** + * 0.7.1d: GovernedFileSovereignOpsAuditOutboxStore is a concrete SovereignOpsAuditOutboxStore + * implementation, so it must satisfy the shared outbox compatibility contract like any other store. + * + * Enrolling the wrapper is worth more than appeasing the guard: the released contract now runs through + * the delegated layer, which is what proves legacy behaviour stays transparent rather than merely + * claiming it in KDoc. + * + * The runner owns the encryption key and per-case isolation exactly like the plain store's runner. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GovernedFileSovereignOpsAuditOutboxStoreTckTest : SovereignOpsAuditOutboxStoreTck() { + private val rootDir: Path = Files.createTempDirectory("tramai-governed-outbox-tck-").toAbsolutePath() + private val testKey: SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private val caseCounter = AtomicLong(0) + + override fun createStore(): SovereignOpsAuditOutboxStore = + GovernedFileSovereignOpsAuditOutboxStore( + FileSovereignOpsAuditOutboxStore( + root = Files.createDirectories(rootDir.resolve("case-${caseCounter.incrementAndGet()}")), + key = testKey, + ), + ) + + @AfterAll + fun cleanup() { + if (rootDir.exists()) { + rootDir.toFile().deleteRecursively() + } + } +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/SovereignFilePersistenceAutoConfigurationTest.kt b/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/SovereignFilePersistenceAutoConfigurationTest.kt index 70828244e..4c81b2ef3 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/SovereignFilePersistenceAutoConfigurationTest.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-file/src/test/kotlin/dev/tramai/spring/sovereign/persistence/file/SovereignFilePersistenceAutoConfigurationTest.kt @@ -1,6 +1,7 @@ package dev.tramai.spring.sovereign.persistence.file import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalRequest import dev.tramai.core.approval.ApprovalStore import dev.tramai.engine.SuspendedInvocationStore import dev.tramai.persistence.file.FileBackedSovereignStores @@ -12,11 +13,6 @@ import dev.tramai.security.audit.InMemoryAuditStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore -import java.io.File -import java.nio.file.Files -import java.nio.file.Path -import java.time.Instant -import java.util.Base64 import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -24,9 +20,13 @@ import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Primary +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.Base64 class SovereignFilePersistenceAutoConfigurationTest { - @TempDir lateinit var tempDir: Path @@ -35,17 +35,22 @@ class SovereignFilePersistenceAutoConfigurationTest { Base64.getEncoder().encodeToString(ByteArray(32) { it.toByte() }) /** Properties for a valid file-persistence config using key-file. */ - private fun validFileProps(baseDir: Path, keyFile: Path): Map = mapOf( - "tramai.sovereign.persistence.type" to "file", - "tramai.sovereign.persistence.base-dir" to baseDir.toAbsolutePath().toString(), - "tramai.sovereign.persistence.encryption.key-file" to keyFile.toAbsolutePath().toString(), - ) - - private val contextRunner = ApplicationContextRunner() - .withConfiguration( - AutoConfigurations.of(SovereignFilePersistenceAutoConfiguration::class.java), + private fun validFileProps( + baseDir: Path, + keyFile: Path, + ): Map = + mapOf( + "tramai.sovereign.persistence.type" to "file", + "tramai.sovereign.persistence.base-dir" to baseDir.toAbsolutePath().toString(), + "tramai.sovereign.persistence.encryption.key-file" to keyFile.toAbsolutePath().toString(), ) + private val contextRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(SovereignFilePersistenceAutoConfiguration::class.java), + ) + // ── type=memory does not create file stores ──────────────────────── @Test @@ -65,8 +70,7 @@ class SovereignFilePersistenceAutoConfigurationTest { .withPropertyValues( "tramai.sovereign.persistence.type=file", "tramai.sovereign.persistence.encryption.key-file=/tmp/nonexistent", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -83,8 +87,7 @@ class SovereignFilePersistenceAutoConfigurationTest { .withPropertyValues( "tramai.sovereign.persistence.type=file", "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -103,8 +106,7 @@ class SovereignFilePersistenceAutoConfigurationTest { "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", "tramai.sovereign.persistence.encryption.key-env=MY_KEY", "tramai.sovereign.persistence.encryption.key-file=/tmp/some-key-file", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -122,8 +124,7 @@ class SovereignFilePersistenceAutoConfigurationTest { "tramai.sovereign.persistence.type=file", "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", "tramai.sovereign.persistence.encryption.key-env=TRAMAI_TEST_NONEXISTENT_KEY_98765", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -145,8 +146,7 @@ class SovereignFilePersistenceAutoConfigurationTest { "tramai.sovereign.persistence.type=file", "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -170,8 +170,7 @@ class SovereignFilePersistenceAutoConfigurationTest { "tramai.sovereign.persistence.type=file", "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -188,11 +187,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(FileBackedSovereignStores::class.java) assertThat(ctx).hasSingleBean(AuditStore::class.java) val store = ctx.getBean(AuditStore::class.java) @@ -209,11 +208,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(ApprovalStore::class.java) val store = ctx.getBean(ApprovalStore::class.java) assertThat(store).isExactlyInstanceOf( @@ -229,11 +228,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(ApprovalContinuationStore::class.java) val store = ctx.getBean(ApprovalContinuationStore::class.java) assertThat(store).isExactlyInstanceOf( @@ -249,15 +248,15 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(SuspendedInvocationStore::class.java) val store = ctx.getBean(SuspendedInvocationStore::class.java) assertThat(store).isExactlyInstanceOf( - dev.tramai.persistence.file.FileSuspendedInvocationStore::class.java, + dev.tramai.persistence.file.GovernedFileSuspendedInvocationStore::class.java, ) } } @@ -269,11 +268,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(SovereignOpsAuditOutboxStore::class.java) val store = ctx.getBean(SovereignOpsAuditOutboxStore::class.java) assertThat(store).isExactlyInstanceOf(FileSovereignOpsAuditOutboxStore::class.java) @@ -287,11 +286,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> val store = ctx.getBean(SovereignOpsAuditOutboxStore::class.java) assertThat(store.isDurable()).isTrue } @@ -304,24 +303,24 @@ class SovereignFilePersistenceAutoConfigurationTest { val dir = tempDir.resolve("base-starter-delegation") val keyFile = prepareKeyFile(dir) - val combinedRunner = ApplicationContextRunner() - .withConfiguration( - AutoConfigurations.of( - SovereignFilePersistenceAutoConfiguration::class.java, - dev.tramai.spring.sovereign.SovereignTramaiAutoConfiguration::class.java, - ), - ) - .withUserConfiguration(MinimalProviderConfig::class.java) - .withPropertyValues( - "tramai.sovereign.enabled=true", - "tramai.sovereign.allowed-models[0]=local-model", - "tramai.sovereign.allowed-providers[0]=test-provider", - "tramai.sovereign.provider-zones.test-provider=LOCAL", - "tramai.sovereign.models.local-model=test-provider", - "tramai.sovereign.persistence.type=file", - "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", - "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) + val combinedRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SovereignFilePersistenceAutoConfiguration::class.java, + dev.tramai.spring.sovereign.SovereignTramaiAutoConfiguration::class.java, + ), + ).withUserConfiguration(MinimalProviderConfig::class.java) + .withPropertyValues( + "tramai.sovereign.enabled=true", + "tramai.sovereign.allowed-models[0]=local-model", + "tramai.sovereign.allowed-providers[0]=test-provider", + "tramai.sovereign.provider-zones.test-provider=LOCAL", + "tramai.sovereign.models.local-model=test-provider", + "tramai.sovereign.persistence.type=file", + "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", + "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", + ) combinedRunner.run { ctx -> // AuditStore should be file-backed, NOT in-memory @@ -349,24 +348,24 @@ class SovereignFilePersistenceAutoConfigurationTest { val dir = tempDir.resolve("runtime-suspended") val keyFile = prepareKeyFile(dir) - val combinedRunner = ApplicationContextRunner() - .withConfiguration( - AutoConfigurations.of( - SovereignFilePersistenceAutoConfiguration::class.java, - dev.tramai.spring.sovereign.SovereignTramaiAutoConfiguration::class.java, - ), - ) - .withUserConfiguration(MinimalProviderConfig::class.java) - .withPropertyValues( - "tramai.sovereign.enabled=true", - "tramai.sovereign.allowed-models[0]=test-model", - "tramai.sovereign.allowed-providers[0]=test-provider", - "tramai.sovereign.provider-zones.test-provider=LOCAL", - "tramai.sovereign.models.test-model=test-provider", - "tramai.sovereign.persistence.type=file", - "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", - "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) + val combinedRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SovereignFilePersistenceAutoConfiguration::class.java, + dev.tramai.spring.sovereign.SovereignTramaiAutoConfiguration::class.java, + ), + ).withUserConfiguration(MinimalProviderConfig::class.java) + .withPropertyValues( + "tramai.sovereign.enabled=true", + "tramai.sovereign.allowed-models[0]=test-model", + "tramai.sovereign.allowed-providers[0]=test-provider", + "tramai.sovereign.provider-zones.test-provider=LOCAL", + "tramai.sovereign.models.test-model=test-provider", + "tramai.sovereign.persistence.type=file", + "tramai.sovereign.persistence.base-dir=${dir.toAbsolutePath()}", + "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", + ) combinedRunner.run { ctx -> // Runtime and tramai beans exist (proves builder path succeeded) @@ -382,7 +381,7 @@ class SovereignFilePersistenceAutoConfigurationTest { val store = ctx.getBean(SuspendedInvocationStore::class.java) assertThat(store) .isExactlyInstanceOf( - dev.tramai.persistence.file.FileSuspendedInvocationStore::class.java, + dev.tramai.persistence.file.GovernedFileSuspendedInvocationStore::class.java, ) } } @@ -397,11 +396,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomAuditStoreConfig::class.java) .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(AuditStore::class.java)).hasSize(1) val store = ctx.getBean(AuditStore::class.java) assertThat(store).isInstanceOf(CustomAuditStore::class.java) @@ -416,11 +415,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomApprovalStoreConfig::class.java) .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(ApprovalStore::class.java)).hasSize(1) val store = ctx.getBean(ApprovalStore::class.java) assertThat(store).isInstanceOf(CustomApprovalStore::class.java) @@ -435,11 +434,11 @@ class SovereignFilePersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomOutboxStoreConfig::class.java) .withPropertyValues( - *validFileProps(dir, keyFile).entries + *validFileProps(dir, keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(SovereignOpsAuditOutboxStore::class.java)).hasSize(1) val store = ctx.getBean(SovereignOpsAuditOutboxStore::class.java) assertThat(store).isInstanceOf(CustomOutboxStore::class.java) @@ -489,69 +488,66 @@ class CustomAuditStore : AuditStore { override suspend fun appendNext( auditStreamId: String, eventFactory: (AuditEvent?) -> AuditEvent, - ): AuditEvent = eventFactory( - AuditEvent( - schemaVersion = 1, - hashAlgorithm = dev.tramai.security.audit.AuditHashAlgorithm.SHA_256, - auditStreamId = auditStreamId, - eventId = "custom-test-event", - sequenceNumber = 1, - workflowRunId = null, - correlationId = null, - actor = "test", - enforcementPoint = "test", - decision = "permit", - policyVersion = null, - workflowDigest = null, - previousEventHash = null, - eventHash = "a".repeat(64), - timestamp = Instant.now(), - reasonCode = null, - ), - ) + ): AuditEvent = + eventFactory( + AuditEvent( + schemaVersion = 1, + hashAlgorithm = dev.tramai.security.audit.AuditHashAlgorithm.SHA_256, + auditStreamId = auditStreamId, + eventId = "custom-test-event", + sequenceNumber = 1, + workflowRunId = null, + correlationId = null, + actor = "test", + enforcementPoint = "test", + decision = "permit", + policyVersion = null, + workflowDigest = null, + previousEventHash = null, + eventHash = "a".repeat(64), + timestamp = Instant.now(), + reasonCode = null, + ), + ) + override suspend fun readStream(auditStreamId: String): List = emptyList() + override suspend fun latestEvent(auditStreamId: String): AuditEvent? = null } class CustomApprovalStore : ApprovalStore { private var created = false - override suspend fun create( - request: dev.tramai.core.approval.ApprovalRequest, - ): dev.tramai.core.approval.ApprovalRequest { + + override suspend fun create(request: ApprovalRequest): ApprovalRequest { created = true return request } - override suspend fun get(approvalId: String): dev.tramai.core.approval.ApprovalRequest? = null + + override suspend fun get(approvalId: String): ApprovalRequest? = null + override suspend fun transition( approvalId: String, expectedVersion: Long, transition: dev.tramai.core.approval.ApprovalTransition, - ): dev.tramai.core.approval.ApprovalRequest { - throw UnsupportedOperationException("custom stub") - } + ): ApprovalRequest = throw UnsupportedOperationException("custom stub") + override suspend fun consumeApprovedOrReplay( approvalId: String, expectedVersion: Long, presentedTokenDigest: dev.tramai.core.approval.Sha256Digest, consumedBy: String, - ): dev.tramai.core.approval.ApprovalConsumptionReceipt { - throw UnsupportedOperationException("custom stub") - } + ): dev.tramai.core.approval.ApprovalConsumptionReceipt = throw UnsupportedOperationException("custom stub") } class CustomOutboxStore : SovereignOpsAuditOutboxStore { override fun isDurable(): Boolean = true - override suspend fun append( - record: SovereignOpsAuditOutboxRecord, - ): SovereignOpsAuditOutboxRecord = record + override suspend fun append(record: SovereignOpsAuditOutboxRecord): SovereignOpsAuditOutboxRecord = record override suspend fun markReadyForDispatch( outboxId: String, expectedStatus: SovereignOpsAuditOutboxStatus, - ): SovereignOpsAuditOutboxRecord { - throw UnsupportedOperationException("custom stub") - } + ): SovereignOpsAuditOutboxRecord = throw UnsupportedOperationException("custom stub") override suspend fun claimPending( claimedBy: String, @@ -564,9 +560,7 @@ class CustomOutboxStore : SovereignOpsAuditOutboxStore { expectedStatus: SovereignOpsAuditOutboxStatus, expectedAttemptCount: Int, emittedAt: Instant, - ): SovereignOpsAuditOutboxRecord { - throw UnsupportedOperationException("custom stub") - } + ): SovereignOpsAuditOutboxRecord = throw UnsupportedOperationException("custom stub") override suspend fun markFailed( outboxId: String, @@ -574,9 +568,7 @@ class CustomOutboxStore : SovereignOpsAuditOutboxStore { expectedAttemptCount: Int, errorCode: String, retryable: Boolean, - ): SovereignOpsAuditOutboxRecord { - throw UnsupportedOperationException("custom stub") - } + ): SovereignOpsAuditOutboxRecord = throw UnsupportedOperationException("custom stub") override suspend fun get(outboxId: String): SovereignOpsAuditOutboxRecord? = null @@ -597,8 +589,8 @@ class CustomOutboxStore : SovereignOpsAuditOutboxStore { class StubModelProvider : dev.tramai.core.provider.ModelProvider { override fun providerId(): String = "test-provider" - override suspend fun complete( - request: dev.tramai.core.model.ModelRequest, - ): dev.tramai.core.model.ModelResponse = - dev.tramai.core.model.ModelResponse(content = "stub response") + + override suspend fun complete(request: dev.tramai.core.model.ModelRequest): dev.tramai.core.model.ModelResponse = + dev.tramai.core.model + .ModelResponse(content = "stub response") } diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/api/tramai-spring-boot-starter-sovereign-persistence-jdbc.api b/tramai-spring-boot-starter-sovereign-persistence-jdbc/api/tramai-spring-boot-starter-sovereign-persistence-jdbc.api index 821b014f7..907dd1d60 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-jdbc/api/tramai-spring-boot-starter-sovereign-persistence-jdbc.api +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/api/tramai-spring-boot-starter-sovereign-persistence-jdbc.api @@ -65,6 +65,12 @@ public final class dev/tramai/spring/sovereign/persistence/jdbc/EncryptedPayload public fun toString ()Ljava/lang/String; } +public final class dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsApprovalRequestMutationStore : dev/tramai/spring/sovereign/ops/outbox/GovernedSovereignOpsApprovalRequestMutationStore, dev/tramai/spring/sovereign/ops/outbox/SovereignOpsApprovalRequestMutationStore { + public fun (Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStore;)V + public fun createApprovalRequest (Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Ldev/tramai/spring/sovereign/ops/inbox/ApprovalInboxMetadata;Ldev/tramai/core/approval/gateway/ApprovalResumeCredentialRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun createGovernedApprovalRequest (Ldev/tramai/engine/approval/ApprovalGatewayPersistenceRequest;Ldev/tramai/core/identity/GovernedRunIdentity;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Ldev/tramai/spring/sovereign/ops/inbox/ApprovalInboxMetadata;Ldev/tramai/core/approval/gateway/ApprovalResumeCredentialRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class dev/tramai/spring/sovereign/persistence/jdbc/JdbcApprovalResumeCredentialStore : dev/tramai/core/approval/gateway/ApprovalResumeCredentialStore { public fun (Lkotlin/jvm/functions/Function0;Ljavax/crypto/SecretKey;Ljava/lang/String;)V public synthetic fun (Lkotlin/jvm/functions/Function0;Ljavax/crypto/SecretKey;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -111,8 +117,8 @@ public abstract interface class dev/tramai/spring/sovereign/persistence/jdbc/Jdb } public final class dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalMutationStore : dev/tramai/spring/sovereign/ops/outbox/SovereignOpsApprovalMutationStore { - public fun (Ljavax/sql/DataSource;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;Ljava/time/Clock;II)V - public synthetic fun (Ljavax/sql/DataSource;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;Ljava/time/Clock;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljavax/sql/DataSource;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;Ljava/time/Clock;IILdev/tramai/engine/SuspendedInvocationStore;)V + public synthetic fun (Ljavax/sql/DataSource;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;Ljava/time/Clock;IILdev/tramai/engine/SuspendedInvocationStore;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun approveApprovalWithAuditIntent (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun denyApprovalWithAuditIntent (Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -167,7 +173,7 @@ public final class dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPer public final fun jdbcReplayEnvelopeCodec (Ljavax/crypto/SecretKey;Ldev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceProperties;)Ldev/tramai/persistence/jdbc/JdbcReplayEnvelopeCodec; public final fun missingJdbcDataSourceFailure ()Ljava/lang/Void; public final fun sovereignJdbcEncryptionKey (Ldev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceProperties;)Ljavax/crypto/SecretKey; - public final fun sovereignOpsApprovalMutationStore (Ljavax/sql/DataSource;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsApprovalMutationStore; + public final fun sovereignOpsApprovalMutationStore (Ljavax/sql/DataSource;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;Ldev/tramai/engine/SuspendedInvocationStore;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsApprovalMutationStore; public final fun sovereignOpsApprovalRequestMutationStore (Ljavax/sql/DataSource;Ldev/tramai/persistence/jdbc/JdbcReplayEnvelopeCodec;Ldev/tramai/persistence/jdbc/JdbcContinuationArgumentsCodec;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;Ljavax/crypto/SecretKey;Ldev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceProperties;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsApprovalRequestMutationStore; public final fun sovereignOpsAuditOutboxStore (Ljavax/sql/DataSource;Ldev/tramai/spring/sovereign/persistence/jdbc/JdbcOpsAuditOutboxPayloadCodec;Ldev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceProperties;)Ldev/tramai/spring/sovereign/ops/outbox/SovereignOpsAuditOutboxStore; public final fun sovereignOpsWorkerLeaseStore (Ljavax/sql/DataSource;)Ldev/tramai/spring/sovereign/ops/lease/SovereignOpsWorkerLeaseStore; diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsApprovalRequestMutationStore.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsApprovalRequestMutationStore.kt new file mode 100644 index 000000000..2a87d62f5 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsApprovalRequestMutationStore.kt @@ -0,0 +1,41 @@ +package dev.tramai.spring.sovereign.persistence.jdbc + +import dev.tramai.core.approval.gateway.ApprovalResumeCredentialRecord +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadata +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsApprovalRequestMutationStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationResult +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord + +/** + * Governed mutation capability for the JDBC approval-request store (0.7.1d1, P3). + * + * Composition, not a second durable authority: [createGovernedApprovalRequest] forwards into the + * same single transaction the store already runs, so the approval attribution and the suspension + * identity are written together or not at all. + * + * [JdbcSovereignOpsApprovalRequestMutationStore] deliberately does not declare this interface: + * widening a released type changes the analyzer identity it is baselined under. Same decision the + * governed JDBC suspension store makes. + */ +class GovernedJdbcSovereignOpsApprovalRequestMutationStore( + private val delegate: JdbcSovereignOpsApprovalRequestMutationStore, +) : SovereignOpsApprovalRequestMutationStore by delegate, + GovernedSovereignOpsApprovalRequestMutationStore { + override suspend fun createGovernedApprovalRequest( + request: ApprovalGatewayPersistenceRequest, + identity: GovernedRunIdentity, + auditIntent: SovereignOpsAuditOutboxRecord?, + inboxMetadata: ApprovalInboxMetadata?, + resumeCredential: ApprovalResumeCredentialRecord?, + ): SovereignOpsApprovalRequestMutationResult = + delegate.createGovernedApprovalRequest( + request = request, + identity = identity, + auditIntent = auditIntent, + inboxMetadata = inboxMetadata, + resumeCredential = resumeCredential, + ) +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsAuditOutboxStore.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsAuditOutboxStore.kt new file mode 100644 index 000000000..78420f3a8 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsAuditOutboxStore.kt @@ -0,0 +1,35 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.persistence.jdbc + +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxGovernance +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore + +/** + * 0.7.1d: governed capability over the released JDBC outbox store. + * + * Composition rather than modification, for the same reason as the suspension wrapper: + * [JdbcSovereignOpsAuditOutboxStore] keeps its released interface list untouched and stays baselined + * as it is. + * + * There is no side table and no identity column. A governed record is one row of `audit_outbox` whose + * encrypted payload is V2, written in the same transaction as the row itself, so a crash can never + * leave half a governed record and the attribution is never promoted into plaintext columns. + * + * Legacy behaviour is delegated transparently, so a caller holding this wrapper sees exactly the + * released store contract. + */ +@ExperimentalTramaiInternalApi +class GovernedJdbcSovereignOpsAuditOutboxStore( + private val delegate: JdbcSovereignOpsAuditOutboxStore, +) : SovereignOpsAuditOutboxStore by delegate, + GovernedSovereignOpsAuditOutboxStore { + override suspend fun appendGoverned(entry: GovernedSovereignOpsAuditOutboxRecord) { + delegate.appendGoverned(entry) + } + + override suspend fun governanceById(id: String): SovereignOpsAuditOutboxGovernance = delegate.governanceById(id) +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalMutationStore.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalMutationStore.kt index e6e046fd1..9f0654991 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalMutationStore.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalMutationStore.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + package dev.tramai.spring.sovereign.persistence.jdbc import com.fasterxml.jackson.annotation.JsonIgnoreProperties @@ -14,10 +16,15 @@ import dev.tramai.core.approval.SafeActorIdPolicy import dev.tramai.core.approval.Sha256Digest import dev.tramai.core.exception.ApprovalStoreNotFoundException import dev.tramai.core.exception.IllegalApprovalTransitionException +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.spring.sovereign.ops.outbox.ApprovalRunAttribution +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalMutationResult import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus +import dev.tramai.spring.sovereign.ops.outbox.resolveApprovalRunAttribution import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.sql.Connection @@ -36,12 +43,13 @@ class JdbcSovereignOpsApprovalMutationStore( private val clock: Clock = Clock.systemUTC(), private val maxIdLength: Int = 256, private val maxCommentLength: Int = 4096, + private val suspendedInvocations: SuspendedInvocationStore? = null, ) : SovereignOpsApprovalMutationStore { - - private val mapper: ObjectMapper = ObjectMapper() - .registerKotlinModule() - .registerModule(JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + private val mapper: ObjectMapper = + ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) override suspend fun denyApprovalWithAuditIntent( approvalId: String, @@ -49,8 +57,8 @@ class JdbcSovereignOpsApprovalMutationStore( actor: String, reason: String, auditIntent: SovereignOpsAuditOutboxRecord, - ): SovereignOpsApprovalMutationResult { - return mutateApprovalWithAuditIntent( + ): SovereignOpsApprovalMutationResult = + mutateApprovalWithAuditIntent( approvalId = approvalId, expectedVersion = expectedVersion, actor = actor, @@ -58,7 +66,6 @@ class JdbcSovereignOpsApprovalMutationStore( auditIntent = auditIntent, transition = ApprovalTransition.Deny(decidedBy = actor, comment = reason), ) - } override suspend fun approveApprovalWithAuditIntent( approvalId: String, @@ -66,8 +73,8 @@ class JdbcSovereignOpsApprovalMutationStore( actor: String, reason: String, auditIntent: SovereignOpsAuditOutboxRecord, - ): SovereignOpsApprovalMutationResult { - return mutateApprovalWithAuditIntent( + ): SovereignOpsApprovalMutationResult = + mutateApprovalWithAuditIntent( approvalId = approvalId, expectedVersion = expectedVersion, actor = actor, @@ -75,7 +82,6 @@ class JdbcSovereignOpsApprovalMutationStore( auditIntent = auditIntent, transition = ApprovalTransition.Approve(decidedBy = actor, comment = reason), ) - } private suspend fun mutateApprovalWithAuditIntent( approvalId: String, @@ -105,12 +111,17 @@ class JdbcSovereignOpsApprovalMutationStore( } // ── Transactional mutation ──────────────────────────────────── + // 0.7.1d: resolved from the approval's durable suspension BEFORE the transaction opens, so a + // resolution failure fails closed with the approval untouched and the outbox transaction keeps + // its shape. A governed decision must never be recorded as an unattributed V1 intent. + val governedIntent = resolveGovernedIntent(approvalId, auditIntent) return dataSource.connection.use { conn -> val previousAutoCommit = conn.autoCommit conn.autoCommit = false try { - val current = selectApprovalForUpdate(conn, approvalId) - ?: throw ApprovalStoreNotFoundException(approvalId) + val current = + selectApprovalForUpdate(conn, approvalId) + ?: throw ApprovalStoreNotFoundException(approvalId) // Guard: status if (current.status != ApprovalStatus.PENDING.name) { @@ -143,17 +154,19 @@ class JdbcSovereignOpsApprovalMutationStore( } // Insert outbox as PREPARED - val preparedPayload = mapper.writeValueAsBytes(auditIntent.toPersistedOutbox()) - val preparedEncrypted = payloadCodec.encode(preparedPayload) - insertPreparedOutbox(conn, auditIntent, preparedEncrypted) + val preparedIntent = governedIntent?.record ?: auditIntent + val preparedEncrypted = + payloadCodec.encode(encodeOutboxRecord(preparedIntent, governedIntent?.runIdentity)) + insertPreparedOutbox(conn, preparedIntent, preparedEncrypted) // Update approval val decidedAt = Timestamp.from(now) val nextVersion = incrementVersion(approvalId, expectedVersion) - val updatedMetadata = metadata.copy( - decidedBy = actor, - decisionComment = reason, - ) + val updatedMetadata = + metadata.copy( + decidedBy = actor, + decisionComment = reason, + ) val metadataJson = mapper.writeValueAsString(updatedMetadata) val targetStatus = transition.targetStatus() @@ -168,19 +181,21 @@ class JdbcSovereignOpsApprovalMutationStore( ) // Mark outbox PENDING - val pendingAuditIntent = auditIntent.copy( - approvalStatus = targetStatus.name, - approvalVersion = nextVersion, - status = SovereignOpsAuditOutboxStatus.PENDING, - ) - val pendingPayload = mapper.writeValueAsBytes(pendingAuditIntent.toPersistedOutbox()) - val pendingEncrypted = payloadCodec.encode(pendingPayload) + val pendingAuditIntent = + preparedIntent.copy( + approvalStatus = targetStatus.name, + approvalVersion = nextVersion, + status = SovereignOpsAuditOutboxStatus.PENDING, + ) + val pendingEncrypted = + payloadCodec.encode(encodeOutboxRecord(pendingAuditIntent, governedIntent?.runIdentity)) markPreparedOutboxPending(conn, pendingAuditIntent, pendingEncrypted) // Re-read for return value - val updated = selectApproval(conn, approvalId) - ?: throw ApprovalStoreNotFoundException(approvalId) + val updated = + selectApproval(conn, approvalId) + ?: throw ApprovalStoreNotFoundException(approvalId) val approval = mapToApprovalRequest(updated) conn.commit() @@ -200,16 +215,45 @@ class JdbcSovereignOpsApprovalMutationStore( } } + /** + * 0.7.1d: the durable intent this mutation records — governed when the approval's suspension + * carries the canonical identity, released V1 otherwise. + * + * The identity is read from the suspension record, the durable authority this slice established, + * never reconstructed from `ApprovalRequest.binding.workflowRunId`, which is a run identifier and + * not an authority. A governed intent whose identity disagrees with the audit intent's run id + * throws from [GovernedSovereignOpsAuditOutboxRecord] before any state changes. + */ + private suspend fun resolveGovernedIntent( + approvalId: String, + auditIntent: SovereignOpsAuditOutboxRecord, + ): GovernedSovereignOpsAuditOutboxRecord? { + val attribution = suspendedInvocations?.let { resolveApprovalRunAttribution(it, approvalId) } + return when (attribution) { + is ApprovalRunAttribution.Governed -> { + GovernedSovereignOpsAuditOutboxRecord(auditIntent, attribution.identity) + } + + ApprovalRunAttribution.LegacySuspension, ApprovalRunAttribution.NoSuspension, null -> { + null + } + } + } + // ── SQL helpers ────────────────────────────────────────────────── - private fun selectApprovalForUpdate(conn: Connection, approvalId: String): ApprovalRow? { - val sql = """ + private fun selectApprovalForUpdate( + conn: Connection, + approvalId: String, + ): ApprovalRow? { + val sql = + """ SELECT approval_id, status, created_at, decided_at, decision_actor_hash, decision_type, sanitized_metadata, version FROM approvals WHERE approval_id = ? FOR UPDATE - """.trimIndent() + """.trimIndent() return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, approvalId) stmt.executeQuery().use { rs -> @@ -218,13 +262,17 @@ class JdbcSovereignOpsApprovalMutationStore( } } - private fun selectApproval(conn: Connection, approvalId: String): ApprovalRow? { - val sql = """ + private fun selectApproval( + conn: Connection, + approvalId: String, + ): ApprovalRow? { + val sql = + """ SELECT approval_id, status, created_at, decided_at, decision_actor_hash, decision_type, sanitized_metadata, version FROM approvals WHERE approval_id = ? - """.trimIndent() + """.trimIndent() return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, approvalId) stmt.executeQuery().use { rs -> @@ -233,30 +281,32 @@ class JdbcSovereignOpsApprovalMutationStore( } } - private fun mapApprovalRow(rs: ResultSet): ApprovalRow = ApprovalRow( - approvalId = rs.getString("approval_id"), - status = rs.getString("status"), - createdAt = rs.getObject("created_at", OffsetDateTime::class.java), - decidedAt = rs.getObject("decided_at", OffsetDateTime::class.java), - decisionActorHash = rs.getString("decision_actor_hash"), - decisionType = rs.getString("decision_type"), - sanitizedMetadataJson = rs.getString("sanitized_metadata"), - version = rs.getLong("version"), - ) + private fun mapApprovalRow(rs: ResultSet): ApprovalRow = + ApprovalRow( + approvalId = rs.getString("approval_id"), + status = rs.getString("status"), + createdAt = rs.getObject("created_at", OffsetDateTime::class.java), + decidedAt = rs.getObject("decided_at", OffsetDateTime::class.java), + decisionActorHash = rs.getString("decision_actor_hash"), + decisionType = rs.getString("decision_type"), + sanitizedMetadataJson = rs.getString("sanitized_metadata"), + version = rs.getLong("version"), + ) private fun insertPreparedOutbox( conn: Connection, record: SovereignOpsAuditOutboxRecord, encrypted: JdbcEncryptedAuditOutboxPayload, ) { - val sql = """ + val sql = + """ INSERT INTO audit_outbox ( outbox_id, event_key, status, correlation_key_hash, created_at, attempt_count, encrypted_payload, encryption_key_id, encryption_algorithm, encryption_nonce, payload_digest, version ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1) - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, record.outboxId) @@ -282,7 +332,8 @@ class JdbcSovereignOpsApprovalMutationStore( metadataJson: String, targetStatus: ApprovalStatus, ) { - val sql = """ + val sql = + """ UPDATE approvals SET status = ?, decided_at = ?, @@ -291,7 +342,7 @@ class JdbcSovereignOpsApprovalMutationStore( sanitized_metadata = ?::jsonb, version = version + 1 WHERE approval_id = ? AND version = ? AND status = 'PENDING' - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, targetStatus.name) @@ -313,7 +364,8 @@ class JdbcSovereignOpsApprovalMutationStore( record: SovereignOpsAuditOutboxRecord, encrypted: JdbcEncryptedAuditOutboxPayload, ) { - val sql = """ + val sql = + """ UPDATE audit_outbox SET status = 'PENDING', encrypted_payload = ?, @@ -323,7 +375,7 @@ class JdbcSovereignOpsApprovalMutationStore( payload_digest = ?, version = version + 1 WHERE outbox_id = ? AND status = 'PREPARED' - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setBytes(1, encrypted.ciphertext) @@ -344,14 +396,15 @@ class JdbcSovereignOpsApprovalMutationStore( return ApprovalRequest( approvalId = row.approvalId, - binding = ApprovalBinding( - workflowRunId = metadata.binding.workflowRunId, - toolName = metadata.binding.toolName, - argumentsDigest = Sha256Digest.of(metadata.binding.argumentsDigest), - policyVersion = metadata.binding.policyVersion, - workflowDigest = Sha256Digest.of(metadata.binding.workflowDigest), - approvalTokenDigest = Sha256Digest.of(metadata.binding.approvalTokenDigest), - ), + binding = + ApprovalBinding( + workflowRunId = metadata.binding.workflowRunId, + toolName = metadata.binding.toolName, + argumentsDigest = Sha256Digest.of(metadata.binding.argumentsDigest), + policyVersion = metadata.binding.policyVersion, + workflowDigest = Sha256Digest.of(metadata.binding.workflowDigest), + approvalTokenDigest = Sha256Digest.of(metadata.binding.approvalTokenDigest), + ), status = ApprovalStatus.valueOf(row.status), requestedBy = metadata.requestedBy, requestedAt = Instant.parse(metadata.requestedAt), @@ -374,7 +427,10 @@ class JdbcSovereignOpsApprovalMutationStore( // ── Validation ─────────────────────────────────────────────────── - private fun validateIdField(value: String, fieldName: String) { + private fun validateIdField( + value: String, + fieldName: String, + ) { val trimmed = value.trim() require(trimmed.isNotBlank()) { "$fieldName must not be blank" } require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } @@ -382,7 +438,10 @@ class JdbcSovereignOpsApprovalMutationStore( require(trimmed == value) { "$fieldName must not contain surrounding whitespace" } } - private fun incrementVersion(approvalId: String, version: Long): Long = + private fun incrementVersion( + approvalId: String, + version: Long, + ): Long = try { Math.addExact(version, 1L) } catch (_: ArithmeticException) { @@ -395,11 +454,17 @@ class JdbcSovereignOpsApprovalMutationStore( ): RuntimeException { val message = e.message ?: "" return when { - message.contains("uq_audit_outbox_event_key") || message.contains("audit_outbox_event_key_key") -> + message.contains("uq_audit_outbox_event_key") || message.contains("audit_outbox_event_key_key") -> { IllegalStateException("tramai-sovereign-ops-outbox-duplicate-event-key: ${record.eventKey}") - message.contains("audit_outbox_pkey") -> + } + + message.contains("audit_outbox_pkey") -> { IllegalStateException("tramai-sovereign-ops-outbox-duplicate-id: ${record.outboxId}") - else -> IllegalStateException("tramai-sovereign-ops-approval-mutation-database-failure", e) + } + + else -> { + IllegalStateException("tramai-sovereign-ops-approval-mutation-database-failure", e) + } } } diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStore.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStore.kt index 4fded6716..cad7330bb 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStore.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStore.kt @@ -1,6 +1,7 @@ package dev.tramai.spring.sovereign.persistence.jdbc import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule @@ -14,29 +15,36 @@ import dev.tramai.core.approval.ApprovalStatus import dev.tramai.core.approval.SafeActorIdPolicy import dev.tramai.core.approval.SensitiveToolArguments import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.gateway.ApprovalResumeCredentialRecord +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.GovernedRunIdentity import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolCall import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.ReplayEnvelopeDigestHelper import dev.tramai.engine.ResumeOperationReference import dev.tramai.engine.ResumeToolReference import dev.tramai.engine.SensitiveReplayEnvelope -import dev.tramai.engine.ReplayEnvelopeDigestHelper import dev.tramai.engine.SuspendedInvocationMetadata import dev.tramai.engine.TokenBudgetSnapshot -import dev.tramai.core.approval.gateway.ApprovalResumeCredentialRecord import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.engine.approval.ApprovalRunAttribution +import dev.tramai.engine.approval.decodeApprovalAttribution +import dev.tramai.engine.approval.encodeApprovalAttribution import dev.tramai.persistence.jdbc.JdbcContinuationArgumentsCodec import dev.tramai.persistence.jdbc.JdbcEncryptedContinuationArguments import dev.tramai.persistence.jdbc.JdbcEncryptedReplayEnvelope import dev.tramai.persistence.jdbc.JdbcReplayEnvelopeCodec import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadata import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadataPolicy +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsApprovalRequestMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationResult import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus +import kotlinx.coroutines.CancellationException import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.sql.Connection @@ -50,6 +58,17 @@ import java.time.ZoneOffset import javax.crypto.SecretKey import javax.sql.DataSource +/** + * The optional artifacts a creation may attach: the audit intent, the inbox metadata, and the resume + * credential. They travel together from the gateway boundary into the single transaction, and grouping + * them keeps the transaction body below the parameter ceiling. + */ +private data class CreationSideArtifacts( + val auditIntent: SovereignOpsAuditOutboxRecord?, + val inboxMetadata: ApprovalInboxMetadata?, + val resumeCredential: ApprovalResumeCredentialRecord?, +) + class JdbcSovereignOpsApprovalRequestMutationStore( private val dataSource: DataSource, private val replayEnvelopeCodec: JdbcReplayEnvelopeCodec, @@ -59,87 +78,240 @@ class JdbcSovereignOpsApprovalRequestMutationStore( private val encryptionKeyId: String, private val clock: Clock = Clock.systemUTC(), ) : SovereignOpsApprovalRequestMutationStore { - - private val mapper: ObjectMapper = ObjectMapper() - .registerKotlinModule() - .registerModule(JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + private val mapper: ObjectMapper = + ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) override suspend fun createApprovalRequest( request: ApprovalGatewayPersistenceRequest, auditIntent: SovereignOpsAuditOutboxRecord?, inboxMetadata: ApprovalInboxMetadata?, resumeCredential: ApprovalResumeCredentialRecord?, + ): SovereignOpsApprovalRequestMutationResult = + createApprovalRequestInternal( + request = request, + attribution = ApprovalRunAttribution.Ungoverned, + auditIntent = auditIntent, + inboxMetadata = inboxMetadata, + resumeCredential = resumeCredential, + ) + + /** + * Governed creation (0.7.1d1): the approval-row attribution and the suspension payload both come + * from ONE canonical [GovernedRunIdentity], written inside the SAME transaction as the approval, + * continuation, resume credential and inbox/audit artifacts. + * + * Takes the identity itself rather than a nullable value or a five-component snapshot, so the two + * identity-bearing records cannot be built from independently interpreted input. + * + * This is a privileged persistence boundary: it re-checks that the request's binding names the + * same run rather than trusting that the caller validated factory output. + */ + internal suspend fun createGovernedApprovalRequest( + request: ApprovalGatewayPersistenceRequest, + identity: GovernedRunIdentity, + auditIntent: SovereignOpsAuditOutboxRecord? = null, + inboxMetadata: ApprovalInboxMetadata? = null, + resumeCredential: ApprovalResumeCredentialRecord? = null, + ): SovereignOpsApprovalRequestMutationResult { + requireEveryCarrierNamesCanonicalRun(identity, request) + return createApprovalRequestInternal( + request = request, + attribution = ApprovalRunAttribution.Governed(identity), + auditIntent = auditIntent, + inboxMetadata = inboxMetadata, + resumeCredential = resumeCredential, + ) + } + + /** + * Every run-id carrier in the request must name the canonical run before this boundary writes + * anything. The suspension record already carried `SuspendedInvocationMetadata.identity.workflowRunId` + * before 0.7.1d; the governed payload adds `GovernedRunIdentity.runId`. Those two must never + * disagree, or this path would persist a V2 suspension that the canonical JDBC reader rejects as + * corruption. The factory is identity-untrusted input translation, so all carriers are checked + * together rather than trusting that one correct binding implies the others. + */ + private fun requireEveryCarrierNamesCanonicalRun( + identity: GovernedRunIdentity, + request: ApprovalGatewayPersistenceRequest, + ) { + val canonicalRunId = identity.runId.value + val carriers = + listOf( + "approval binding" to request.approvalRequest.binding.workflowRunId, + "continuation" to request.continuation.workflowRunId, + "suspended invocation metadata" to + request.suspendedInvocationMetadata.identity.workflowRunId, + ) + val mismatched = carriers.filter { (_, runId) -> runId != canonicalRunId } + if (mismatched.isNotEmpty()) { + throw GovernedRunContinuityException( + "Governed approval creation for run '$canonicalRunId' was given " + + mismatched.joinToString { (carrier, runId) -> "$carrier naming run '$runId'" } + + ": every governed run-id carrier must name the same run", + ) + } + } + + /** + * The ONE transaction body behind both entry points: one rollback path, one primary-key-conflict + * path, one insert ordering. The entry points only distinguish attribution. + * + * [attribution] is resolved before the trust boundary by the gateway, so it is a value this layer + * may interpret directly. [ApprovalRunAttribution.Ungoverned] keeps the released behaviour + * byte-for-byte: no reserved keys are written and no attribution is decoded on the existing-row + * paths. + */ + private suspend fun createApprovalRequestInternal( + request: ApprovalGatewayPersistenceRequest, + attribution: ApprovalRunAttribution, + auditIntent: SovereignOpsAuditOutboxRecord?, + inboxMetadata: ApprovalInboxMetadata?, + resumeCredential: ApprovalResumeCredentialRecord?, ): SovereignOpsApprovalRequestMutationResult { + // The canonical identity, when governed: this SAME value feeds the approval-row attribution + // and the suspended-invocation payload. + val governedIdentity = (attribution as? ApprovalRunAttribution.Governed)?.identity validateRequest(request, auditIntent) inboxMetadata?.let(ApprovalInboxMetadataPolicy::validate) return dataSource.connection.use { conn -> val previousAutoCommit = conn.autoCommit conn.autoCommit = false + // The failure the caller will observe. Cleanup runs in `finally`, and this is what tells it + // whether a cleanup failure is a secondary detail or the operation's only real outcome. + var primaryFailure: Exception? = null + val sideArtifacts = CreationSideArtifacts(auditIntent, inboxMetadata, resumeCredential) try { - val existing = selectApproval(conn, request.approvalRequest.approvalId) - if (existing != null) { - conn.commit() - return@use SovereignOpsApprovalRequestMutationResult.Existing(existing) - } - - insertApproval(conn, request.approvalRequest, inboxMetadata) - insertSuspendedInvocation( - conn = conn, - metadata = request.suspendedInvocationMetadata, - replayEnvelope = request.replayEnvelope, - ) - insertContinuation( + return@use runTransaction( conn = conn, - continuation = request.continuation, - sensitiveArguments = request.sensitiveArguments, - ) - - if (resumeCredential != null) { - insertResumeCredential(conn, resumeCredential) - } - - if (auditIntent != null) { - val preparedPayload = mapper.writeValueAsBytes(auditIntent.toPersistedOutbox()) - val preparedEncrypted = outboxPayloadCodec.encode(preparedPayload) - insertPreparedOutbox(conn, auditIntent, preparedEncrypted) - - val pendingAuditIntent = auditIntent.copy( - status = SovereignOpsAuditOutboxStatus.PENDING, - ) - val pendingPayload = mapper.writeValueAsBytes(pendingAuditIntent.toPersistedOutbox()) - val pendingEncrypted = outboxPayloadCodec.encode(pendingPayload) - markPreparedOutboxPending(conn, pendingAuditIntent, pendingEncrypted) - } - - conn.commit() - SovereignOpsApprovalRequestMutationResult.Created( - approvalId = request.approvalRequest.approvalId, - correlationId = request.suspendedInvocationMetadata.correlationId, - resumeToken = request.resumeToken, + request = request, + attribution = attribution, + sideArtifacts = sideArtifacts, ) + } catch (e: CancellationException) { + primaryFailure = e + rollbackSuppressing(conn, e) + throw e } catch (e: SQLException) { - conn.rollback() - if (isApprovalPrimaryKeyViolation(e)) { - val existing = selectApprovalAfterRollback(request.approvalRequest.approvalId) - if (existing != null) { - return@use SovereignOpsApprovalRequestMutationResult.Existing(existing) - } - } - throw IllegalStateException( - "tramai-sovereign-ops-approval-request-mutation-database-failure", - e, + primaryFailure = e + rollbackSuppressing(conn, e) + return@use recoverOrThrowDatabaseFailure( + error = e, + recover = { + recoverExistingAfterPrimaryKeyRace(e, request.approvalRequest.approvalId, governedIdentity) + }, + onPrimaryResolved = { primaryFailure = it }, ) } catch (e: Exception) { - conn.rollback() + primaryFailure = e + rollbackSuppressing(conn, e) throw e } finally { - conn.autoCommit = previousAutoCommit + restoreAutoCommitSuppressing( + conn = conn, + previousAutoCommit = previousAutoCommit, + primary = primaryFailure, + ) } } } + /** + * The transaction body: existing-row reconciliation first, then the four inserts and the audit + * artifacts under one commit. Failures propagate untouched to the caller's cleanup handling. + */ + private fun runTransaction( + conn: Connection, + request: ApprovalGatewayPersistenceRequest, + attribution: ApprovalRunAttribution, + sideArtifacts: CreationSideArtifacts, + ): SovereignOpsApprovalRequestMutationResult { + // Derived here rather than passed: the same canonical identity rule that governs the outer + // boundary applies to both the existing-row check and the suspended-invocation payload. + val governedIdentity = (attribution as? ApprovalRunAttribution.Governed)?.identity + val existing = selectApproval(conn, request.approvalRequest.approvalId) + if (existing != null) { + conn.commit() + return SovereignOpsApprovalRequestMutationResult.Existing( + requireExistingIdentityMatches(existing, governedIdentity), + ) + } + + insertApproval(conn, request.approvalRequest, sideArtifacts.inboxMetadata, attribution) + insertSuspendedInvocation( + conn = conn, + metadata = request.suspendedInvocationMetadata, + replayEnvelope = request.replayEnvelope, + governedIdentity = governedIdentity, + ) + insertContinuation( + conn = conn, + continuation = request.continuation, + sensitiveArguments = request.sensitiveArguments, + ) + + sideArtifacts.resumeCredential?.let { insertResumeCredential(conn, it) } + + writeAuditOutboxArtifacts(conn, sideArtifacts.auditIntent) + + conn.commit() + return SovereignOpsApprovalRequestMutationResult.Created( + approvalId = request.approvalRequest.approvalId, + correlationId = request.suspendedInvocationMetadata.correlationId, + resumeToken = request.resumeToken, + ) + } + + /** + * Writes the prepared outbox row and then its pending transition inside the caller's + * transaction: like every other artifact of a creation, the two rows land together or not at + * all. + */ + private fun writeAuditOutboxArtifacts( + conn: Connection, + auditIntent: SovereignOpsAuditOutboxRecord?, + ) { + if (auditIntent == null) return + val preparedPayload = mapper.writeValueAsBytes(auditIntent.toPersistedOutbox()) + val preparedEncrypted = outboxPayloadCodec.encode(preparedPayload) + insertPreparedOutbox(conn, auditIntent, preparedEncrypted) + + val pendingAuditIntent = + auditIntent.copy( + status = SovereignOpsAuditOutboxStatus.PENDING, + ) + val pendingPayload = mapper.writeValueAsBytes(pendingAuditIntent.toPersistedOutbox()) + val pendingEncrypted = outboxPayloadCodec.encode(pendingPayload) + markPreparedOutboxPending(conn, pendingAuditIntent, pendingEncrypted) + } + + /** + * Recovery for the loser of a creation race: a primary-key violation means a concurrent writer + * committed the row first, so the loser re-reads it on its own connection and applies the SAME + * reconciliation as the initial SELECT — a concurrent run must never adopt the winner's identity. + * + * Returns null for anything else, so the caller keeps its database-failure wrapping, and lets + * continuity/corruption exceptions from [requireExistingIdentityMatches] propagate unchanged. + */ + private fun recoverExistingAfterPrimaryKeyRace( + error: SQLException, + approvalId: String, + governedIdentity: GovernedRunIdentity?, + ): SovereignOpsApprovalRequestMutationResult.Existing? { + if (!isApprovalPrimaryKeyViolation(error)) return null + + val raced = selectApprovalAfterRollback(approvalId) + return raced?.let { + SovereignOpsApprovalRequestMutationResult.Existing( + requireExistingIdentityMatches(it, governedIdentity), + ) + } + } + private fun validateRequest( request: ApprovalGatewayPersistenceRequest, auditIntent: SovereignOpsAuditOutboxRecord?, @@ -188,10 +360,11 @@ class JdbcSovereignOpsApprovalRequestMutationStore( // replay-envelope messages and verify it matches the metadata digest. // This mirrors JdbcSuspendedInvocationStore.validateReplayEnvelopeDigest. val replayMessages = request.replayEnvelope.revealForResume().messages - val canonicalDigest = ReplayEnvelopeDigestHelper.compute( - request.suspendedInvocationMetadata.operationReference, - replayMessages, - ) + val canonicalDigest = + ReplayEnvelopeDigestHelper.compute( + request.suspendedInvocationMetadata.operationReference, + replayMessages, + ) require(canonicalDigest == request.suspendedInvocationMetadata.replayEnvelopeDigest) { "tramai-sovereign-ops-replay-envelope-digest-mismatch: " + "canonical=$canonicalDigest, provided=${request.suspendedInvocationMetadata.replayEnvelopeDigest}" @@ -239,40 +412,52 @@ class JdbcSovereignOpsApprovalRequestMutationStore( } } - private fun insertApproval(conn: Connection, request: ApprovalRequest, inboxMetadata: ApprovalInboxMetadata?) { + private fun insertApproval( + conn: Connection, + request: ApprovalRequest, + inboxMetadata: ApprovalInboxMetadata?, + attribution: ApprovalRunAttribution, + ) { val now = clock.instant() val nowOdt = OffsetDateTime.ofInstant(now, ZoneOffset.UTC) - val inbox = inboxMetadata?.let { meta -> - InboxMetadata( - requiredRole = meta.requiredRole?.value, - riskLevel = meta.riskLevel, - subjectType = meta.subjectType, - subjectId = meta.subjectId, - recommendationType = meta.recommendationType, + val inbox = + inboxMetadata?.let { meta -> + InboxMetadata( + requiredRole = meta.requiredRole?.value, + riskLevel = meta.riskLevel, + subjectType = meta.subjectType, + subjectId = meta.subjectId, + recommendationType = meta.recommendationType, + ) + } + val reservedAttribution = + (attribution as? ApprovalRunAttribution.Governed)?.let { encodeApprovalAttribution(it.identity) } + val metadata = + ApprovalMetadata( + binding = + BindingMetadata( + workflowRunId = request.binding.workflowRunId, + toolName = request.binding.toolName, + argumentsDigest = request.binding.argumentsDigest.value, + policyVersion = request.binding.policyVersion, + workflowDigest = request.binding.workflowDigest.value, + approvalTokenDigest = request.binding.approvalTokenDigest.value, + ), + requestedBy = request.requestedBy, + expiresAt = request.expiresAt.toString(), + requestedAt = request.requestedAt.toString(), + decidedBy = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + inbox = inbox, + attribution = reservedAttribution, ) - } - val metadata = ApprovalMetadata( - binding = BindingMetadata( - workflowRunId = request.binding.workflowRunId, - toolName = request.binding.toolName, - argumentsDigest = request.binding.argumentsDigest.value, - policyVersion = request.binding.policyVersion, - workflowDigest = request.binding.workflowDigest.value, - approvalTokenDigest = request.binding.approvalTokenDigest.value, - ), - requestedBy = request.requestedBy, - expiresAt = request.expiresAt.toString(), - requestedAt = request.requestedAt.toString(), - decidedBy = null, - decisionComment = null, - consumedBy = null, - consumedAt = null, - inbox = inbox, - ) - val sql = """ + val sql = + """ INSERT INTO approvals (approval_id, status, created_at, sanitized_metadata, version) VALUES (?, 'PENDING', ?, ?::jsonb, 0) - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, request.approvalId) stmt.setObject(2, nowOdt) @@ -285,13 +470,18 @@ class JdbcSovereignOpsApprovalRequestMutationStore( conn: Connection, metadata: SuspendedInvocationMetadata, replayEnvelope: SensitiveReplayEnvelope, + governedIdentity: GovernedRunIdentity?, ) { - val payload = SuspendedPayload( - metadata = SuspendedPayloadMetadata.fromDomain(metadata, mapper), - persistedMessages = replayEnvelope.revealForResume().messages.map { it.toPersisted() }, - ) + val payload = + SuspendedPayload( + metadata = SuspendedPayloadMetadata.fromDomain(metadata, mapper), + payloadVersion = if (governedIdentity == null) null else GOVERNED_PAYLOAD_VERSION, + governedRunIdentity = governedIdentity?.let { PayloadGovernedRunIdentity.fromDomain(it) }, + persistedMessages = replayEnvelope.revealForResume().messages.map { it.toPersisted() }, + ) val encrypted = replayEnvelopeCodec.encode(mapper.writeValueAsBytes(payload)) - val sql = """ + val sql = + """ INSERT INTO suspended_invocations ( invocation_id, status, service_key, operation_key, descriptor_hash, replay_envelope_digest, encrypted_replay_envelope, @@ -303,7 +493,7 @@ class JdbcSovereignOpsApprovalRequestMutationStore( ?, ?, ?, ?, 1, ? ) - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, metadata.approvalId) stmt.setString(2, metadata.operationReference.serviceInterface) @@ -325,10 +515,12 @@ class JdbcSovereignOpsApprovalRequestMutationStore( continuation: ApprovalContinuation, sensitiveArguments: SensitiveToolArguments, ) { - val encrypted = continuationArgumentsCodec.encode( - sensitiveArguments.reveal().toByteArray(Charsets.UTF_8), - ) - val sql = """ + val encrypted = + continuationArgumentsCodec.encode( + sensitiveArguments.reveal().toByteArray(Charsets.UTF_8), + ) + val sql = + """ INSERT INTO approval_continuations ( approval_id, status, version, created_at, approval_expires_at, workflow_run_id, correlation_id, tool_call_id, tool_name, @@ -342,7 +534,7 @@ class JdbcSovereignOpsApprovalRequestMutationStore( ?, ?, ?, ?, ? ) - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, continuation.approvalId) stmt.setObject(2, OffsetDateTime.ofInstant(continuation.createdAt, ZoneOffset.UTC)) @@ -370,36 +562,89 @@ class JdbcSovereignOpsApprovalRequestMutationStore( stmt.setString(15, encrypted.payloadDigest) } - private fun selectApproval(conn: Connection, approvalId: String): ApprovalRequest? { - val sql = """ + /** + * Reconciles an existing approval row with the creating request's canonical identity. + * + * The legacy path returns the row exactly as before: no attribution is decoded, so an ungoverned + * caller keeps the released idempotency semantics even if a metadata block were malformed. + * + * The governed path treats durable attribution as authoritative: only the canonical identity may + * be adopted, an un-attributed or foreign row aborts [GovernedRunContinuityException], and + * partial/malformed attribution propagates as corruption instead of being reclassified. + */ + private fun requireExistingIdentityMatches( + selected: SelectedApproval, + governedIdentity: GovernedRunIdentity?, + ): ApprovalRequest { + if (governedIdentity == null) return selected.approval + val persisted = + decodeApprovalAttribution( + workflowRunId = selected.approval.binding.workflowRunId, + metadata = selected.rawAttribution ?: emptyMap(), + ) + if (persisted == ApprovalRunAttribution.Governed(governedIdentity)) return selected.approval + val attributed = persisted as? ApprovalRunAttribution.Governed + val reason = + if (attributed != null) { + "is attributed to run '${attributed.identity.runId.value}'" + } else { + "carries no governed attribution" + } + throw GovernedRunContinuityException( + "Existing approval '${selected.approval.approvalId}' $reason, so run " + + "'${governedIdentity.runId.value}' cannot adopt it", + ) + } + + private fun selectApproval( + conn: Connection, + approvalId: String, + ): SelectedApproval? { + val sql = + """ SELECT approval_id, status, created_at, decided_at, decision_actor_hash, decision_type, sanitized_metadata, version FROM approvals WHERE approval_id = ? - """.trimIndent() + """.trimIndent() return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, approvalId) stmt.executeQuery().use { rs -> - if (rs.next()) mapToApprovalRequest(rs) else null + if (rs.next()) mapSelectedApproval(rs) else null } } } - private fun selectApprovalAfterRollback(approvalId: String): ApprovalRequest? = + private fun selectApprovalAfterRollback(approvalId: String): SelectedApproval? = dataSource.connection.use { conn -> selectApproval(conn, approvalId) } - private fun mapToApprovalRequest(rs: ResultSet): ApprovalRequest { + private fun mapSelectedApproval(rs: ResultSet): SelectedApproval { val metadata = mapper.readValue(rs.getString("sanitized_metadata")) - return ApprovalRequest( + return SelectedApproval( + approval = mapApprovalRequest(rs, metadata), + rawAttribution = metadata.attribution, + ) + } + + /** + * Maps the row's columns to [ApprovalRequest]. [metadata] is the already-parsed + * `sanitized_metadata` column, so a row is decoded exactly once. + */ + private fun mapApprovalRequest( + rs: ResultSet, + metadata: ApprovalMetadata, + ): ApprovalRequest = + ApprovalRequest( approvalId = rs.getString("approval_id"), - binding = ApprovalBinding( - workflowRunId = metadata.binding.workflowRunId, - toolName = metadata.binding.toolName, - argumentsDigest = Sha256Digest.of(metadata.binding.argumentsDigest), - policyVersion = metadata.binding.policyVersion, - workflowDigest = Sha256Digest.of(metadata.binding.workflowDigest), - approvalTokenDigest = Sha256Digest.of(metadata.binding.approvalTokenDigest), - ), + binding = + ApprovalBinding( + workflowRunId = metadata.binding.workflowRunId, + toolName = metadata.binding.toolName, + argumentsDigest = Sha256Digest.of(metadata.binding.argumentsDigest), + policyVersion = metadata.binding.policyVersion, + workflowDigest = Sha256Digest.of(metadata.binding.workflowDigest), + approvalTokenDigest = Sha256Digest.of(metadata.binding.approvalTokenDigest), + ), status = ApprovalStatus.valueOf(rs.getString("status")), requestedBy = metadata.requestedBy, requestedAt = Instant.parse(metadata.requestedAt), @@ -411,21 +656,21 @@ class JdbcSovereignOpsApprovalRequestMutationStore( consumedAt = metadata.consumedAt?.let(Instant::parse), version = rs.getLong("version"), ) - } private fun insertPreparedOutbox( conn: Connection, record: SovereignOpsAuditOutboxRecord, encrypted: JdbcEncryptedAuditOutboxPayload, ) { - val sql = """ + val sql = + """ INSERT INTO audit_outbox ( outbox_id, event_key, status, correlation_key_hash, created_at, attempt_count, encrypted_payload, encryption_key_id, encryption_algorithm, encryption_nonce, payload_digest, version ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1) - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, record.outboxId) stmt.setString(2, record.eventKey) @@ -446,7 +691,8 @@ class JdbcSovereignOpsApprovalRequestMutationStore( record: SovereignOpsAuditOutboxRecord, encrypted: JdbcEncryptedAuditOutboxPayload, ) { - val sql = """ + val sql = + """ UPDATE audit_outbox SET status = 'PENDING', encrypted_payload = ?, @@ -456,7 +702,7 @@ class JdbcSovereignOpsApprovalRequestMutationStore( payload_digest = ?, version = version + 1 WHERE outbox_id = ? AND status = 'PREPARED' - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setBytes(1, encrypted.ciphertext) stmt.setString(2, encrypted.keyId) @@ -474,15 +720,20 @@ class JdbcSovereignOpsApprovalRequestMutationStore( conn: Connection, credential: ApprovalResumeCredentialRecord, ) { - val plaintext = credential.resumeToken.revealForInternalResume().value.encodeToByteArray() + val plaintext = + credential.resumeToken + .revealForInternalResume() + .value + .encodeToByteArray() val encrypted = DefaultJdbcPayloadCrypto.encrypt(plaintext, encryptionKey, encryptionKeyId) - val sql = """ + val sql = + """ INSERT INTO tramai_approval_resume_credentials (approval_id, workflow_run_id, encrypted_resume_token, encryption_key_id, encryption_algorithm, encryption_nonce, payload_digest, created_at, expires_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, credential.approvalId.value) stmt.setString(2, credential.workflowRunId.value) @@ -498,19 +749,6 @@ class JdbcSovereignOpsApprovalRequestMutationStore( } } - private fun isApprovalPrimaryKeyViolation(error: SQLException): Boolean = - error.sqlState == "23505" && - (error.message?.contains("approvals_pkey", ignoreCase = true) == true || - error.message?.contains("approval_id", ignoreCase = true) == true) - - private fun validateIdField(value: String, fieldName: String) { - val trimmed = value.trim() - require(trimmed.isNotBlank()) { "$fieldName must not be blank" } - require(trimmed == value) { "$fieldName must not contain surrounding whitespace" } - require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } - require(trimmed.length <= 256) { "$fieldName exceeds maximum length of 256" } - } - private fun validateDigestField(value: String) { require(value.matches(Regex("^sha256:[0-9a-f]{64}$"))) { "tramai-sovereign-ops-digest-field-must-be-sha256" @@ -548,6 +786,8 @@ private data class ApprovalMetadata( val consumedBy: String?, val consumedAt: String?, val inbox: InboxMetadata? = null, + @field:JsonInclude(JsonInclude.Include.NON_NULL) + val attribution: Map? = null, ) private data class PersistedMessage( @@ -562,23 +802,78 @@ private data class PersistedToolCall( val argumentsJson: String, ) -private fun Message.toPersisted(): PersistedMessage = PersistedMessage( - role = role.name, - content = content, - toolCalls = toolCalls?.map { - PersistedToolCall( - id = it.id, - name = it.name, - argumentsJson = it.argumentsJson, - ) - }, -) +private fun Message.toPersisted(): PersistedMessage = + PersistedMessage( + role = role.name, + content = content, + toolCalls = + toolCalls?.map { + PersistedToolCall( + id = it.id, + name = it.name, + argumentsJson = it.argumentsJson, + ) + }, + ) +/** + * Combined payload that is serialised as JSON, then encrypted. + * + * The governed members sit at the ROOT, mirroring the canonical suspension store's `Payload`: its + * reader expects `payloadVersion` and `governedRunIdentity` as siblings of `metadata`, not inside it, + * and reflects the two types strictly enough that a nested shape fails to deserialize. + * + * Both are omitted when null, so an ungoverned payload keeps the released V1 byte shape (the canonical + * store's own `payloadVersion` is a non-null defaulting Int, which is why it always emits one and this + * payload deliberately does not). + */ private data class SuspendedPayload( val metadata: SuspendedPayloadMetadata, val persistedMessages: List, + @field:JsonInclude(JsonInclude.Include.NON_NULL) + val payloadVersion: Int? = null, + @field:JsonInclude(JsonInclude.Include.NON_NULL) + val governedRunIdentity: PayloadGovernedRunIdentity? = null, ) +/** Carries the raw attribution read from a row alongside the mapped request. */ +private data class SelectedApproval( + val approval: ApprovalRequest, + val rawAttribution: Map?, +) + +/** Semantic payload version marking a suspension that MUST carry a governed identity. */ +private const val GOVERNED_PAYLOAD_VERSION: Int = 2 + +/** + * Persisted canonical run identity (0.7.1d1), carried INSIDE the encrypted suspension payload so + * workload/configuration/environment/deployment attribution is never promoted into a plaintext query + * column — the same confidentiality decision the standalone JDBC suspension store makes. + * + * Deliberately replicated rather than widening that store's private API: the payload shape is proven + * equivalent by test instead of being shared through a new public surface. + */ +private data class PayloadGovernedRunIdentity( + val workloadId: String, + val configurationId: String, + val configurationVersion: String, + val environmentId: String, + val deploymentId: String, + val runId: String, +) { + companion object { + fun fromDomain(identity: GovernedRunIdentity): PayloadGovernedRunIdentity = + PayloadGovernedRunIdentity( + workloadId = identity.deployment.workloadId.value, + configurationId = identity.deployment.configuration.id.value, + configurationVersion = identity.deployment.configuration.version.value, + environmentId = identity.deployment.environmentId.value, + deploymentId = identity.deployment.deploymentId.value, + runId = identity.runId.value, + ) + } +} + private data class SuspendedPayloadMetadata( val approvalId: String, val toolCallId: String, @@ -612,34 +907,119 @@ private data class SuspendedPayloadMetadata( fun fromDomain( metadata: SuspendedInvocationMetadata, mapper: ObjectMapper, - ): SuspendedPayloadMetadata = SuspendedPayloadMetadata( - approvalId = metadata.approvalId, - toolCallId = metadata.toolCallId, - toolName = metadata.toolName, - toolCallIndex = metadata.toolCallIndex, - correlationId = metadata.correlationId, - identityWorkflowRunId = metadata.identity.workflowRunId, - identityCorrelationId = metadata.identity.correlationId, - identityWorkflowDigest = metadata.identity.workflowDigest.value, - identityPolicyVersion = metadata.identity.policyVersion, - identityActorId = metadata.identity.actorId, - securityDataClassification = metadata.securityContext.dataClassification?.name, - securityClassificationSource = metadata.securityContext.classificationSource?.name, - operationServiceInterface = metadata.operationReference.serviceInterface, - operationMethodName = metadata.operationReference.methodName, - operationJvmMethodDescriptor = metadata.operationReference.jvmMethodDescriptor, - operationResumeDefinitionDigest = metadata.operationReference.resumeDefinitionDigest.value, - replayEnvelopeDigest = metadata.replayEnvelopeDigest.value, - conversationId = metadata.conversationId, - historySize = metadata.historySize, - tokenBudgetTotalInputTokens = metadata.tokenBudgetSnapshot?.totalInputTokens, - tokenBudgetTotalOutputTokens = metadata.tokenBudgetSnapshot?.totalOutputTokens, - tokenBudgetTotalInputCost = metadata.tokenBudgetSnapshot?.totalInputCost, - tokenBudgetTotalOutputCost = metadata.tokenBudgetSnapshot?.totalOutputCost, - tokenBudgetWarnIfExceeded = metadata.tokenBudgetSnapshot?.warnIfExceeded, - toolReferenceName = metadata.toolReference.toolName, - toolReferenceDeclarationDigest = metadata.toolReference.declarationDigest.value, - toolSecurity = metadata.toolSecurity?.let { mapper.writeValueAsString(it) }, - ) + ): SuspendedPayloadMetadata = + SuspendedPayloadMetadata( + approvalId = metadata.approvalId, + toolCallId = metadata.toolCallId, + toolName = metadata.toolName, + toolCallIndex = metadata.toolCallIndex, + correlationId = metadata.correlationId, + identityWorkflowRunId = metadata.identity.workflowRunId, + identityCorrelationId = metadata.identity.correlationId, + identityWorkflowDigest = metadata.identity.workflowDigest.value, + identityPolicyVersion = metadata.identity.policyVersion, + identityActorId = metadata.identity.actorId, + securityDataClassification = metadata.securityContext.dataClassification?.name, + securityClassificationSource = metadata.securityContext.classificationSource?.name, + operationServiceInterface = metadata.operationReference.serviceInterface, + operationMethodName = metadata.operationReference.methodName, + operationJvmMethodDescriptor = metadata.operationReference.jvmMethodDescriptor, + operationResumeDefinitionDigest = metadata.operationReference.resumeDefinitionDigest.value, + replayEnvelopeDigest = metadata.replayEnvelopeDigest.value, + conversationId = metadata.conversationId, + historySize = metadata.historySize, + tokenBudgetTotalInputTokens = metadata.tokenBudgetSnapshot?.totalInputTokens, + tokenBudgetTotalOutputTokens = metadata.tokenBudgetSnapshot?.totalOutputTokens, + tokenBudgetTotalInputCost = metadata.tokenBudgetSnapshot?.totalInputCost, + tokenBudgetTotalOutputCost = metadata.tokenBudgetSnapshot?.totalOutputCost, + tokenBudgetWarnIfExceeded = metadata.tokenBudgetSnapshot?.warnIfExceeded, + toolReferenceName = metadata.toolReference.toolName, + toolReferenceDeclarationDigest = metadata.toolReference.declarationDigest.value, + toolSecurity = metadata.toolSecurity?.let { mapper.writeValueAsString(it) }, + ) } } + +/** + * Rolls back without ever replacing [primary]: a rollback failure is attached rather than thrown, + * so the reason the transaction failed stays the reason the caller sees. + */ +private fun rollbackSuppressing( + conn: Connection, + primary: Exception, +) { + try { + conn.rollback() + } catch (e: SQLException) { + primary.addSuppressed(e) + } +} + +/** + * Restores the connection's original autoCommit. A failure here is only the operation's real + * outcome when nothing else has failed: with a primary in flight it is attached to that primary, + * otherwise it propagates instead of being swallowed. + */ +private fun restoreAutoCommitSuppressing( + conn: Connection, + previousAutoCommit: Boolean, + primary: Exception?, +) { + try { + conn.autoCommit = previousAutoCommit + } catch (e: SQLException) { + if (primary != null) { + primary.addSuppressed(e) + } else { + throw e + } + } +} + +/** + * Reconciles a primary-key race, or throws the caller-visible database failure. + * [onPrimaryResolved] receives the wrapper — or null once recovery succeeded — so the caller's + * cleanup attaches its own failures to the exception the caller will actually observe. + */ +private fun recoverOrThrowDatabaseFailure( + error: SQLException, + recover: () -> SovereignOpsApprovalRequestMutationResult.Existing?, + onPrimaryResolved: (Exception?) -> Unit, +): SovereignOpsApprovalRequestMutationResult.Existing { + val recovered = recover() + if (recovered != null) { + onPrimaryResolved(null) + return recovered + } + + val wrapped = + IllegalStateException( + "tramai-sovereign-ops-approval-request-mutation-database-failure", + error, + ) + onPrimaryResolved(wrapped) + throw wrapped +} + +private const val DUPLICATE_KEY_SQL_STATE = "23505" + +/** Maximum accepted length of a persisted attribution field value. */ +private const val MAX_ATTRIBUTION_FIELD_LENGTH = 256 + +private fun isApprovalPrimaryKeyViolation(error: SQLException): Boolean = + error.sqlState == DUPLICATE_KEY_SQL_STATE && + ( + error.message?.contains("approvals_pkey", ignoreCase = true) == true || + error.message?.contains("approval_id", ignoreCase = true) == true + ) + +private fun validateIdField( + value: String, + fieldName: String, +) { + val trimmed = value.trim() + require(trimmed.isNotBlank()) { "$fieldName must not be blank" } + require(trimmed == value) { "$fieldName must not contain surrounding whitespace" } + require(trimmed.none { it.isISOControl() }) { "$fieldName must not contain control characters" } + require(trimmed.length <= MAX_ATTRIBUTION_FIELD_LENGTH) { "$fieldName exceeds maximum length of 256" } +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsAuditOutboxCodec.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsAuditOutboxCodec.kt new file mode 100644 index 000000000..66087e14a --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsAuditOutboxCodec.kt @@ -0,0 +1,215 @@ +package dev.tramai.spring.sovereign.persistence.jdbc + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.readValue +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord + +/** + * 0.7.1d: the single JDBC codec for sovereign-ops audit outbox payloads. + * + * Persistence has exactly two shapes: + * + * - **V1** — the ordinary record with no governed attribution, byte-for-byte the released shape. + * - **V2** — the ordinary record plus the complete canonical [GovernedRunIdentity]. + * + * The identity is one non-optional nested object in V2, never six independently optional fields, so a + * partially attributed V2 cannot be encoded and cannot be decoded. The stored version is required and + * authoritative: a payload with no declared version, or with one this codec does not know, is never + * read as an old record. That is deliberately stricter than + * [dev.tramai.persistence.jdbc.JdbcSuspendedInvocationStore]'s `payloadVersion`, which predates + * governed attribution and must treat an absent version as legacy; here an absent version would be a + * silent downgrade of a governed row, so it fails closed instead. + * + * Both writer paths — the outbox store's own transactions and the approval-mutation transactions that + * write outbox rows inline — encode through [encodeOutboxRecord] and decode through + * [decodeOutboxRecord]. Neither owns its own JSON shape, so the two cannot drift. + */ +internal data class DecodedOutboxRecord( + val record: SovereignOpsAuditOutboxRecord, + val runIdentity: GovernedRunIdentity?, +) + +/** + * Persisted form of the canonical identity: the complete deployment tuple plus the run identifier, + * which must agree with the ordinary record's `workflowRunId` on decode. + */ +internal data class PersistedGovernedRunIdentity( + @param:JsonProperty("runId") val runId: String, + @param:JsonProperty("workloadId") val workloadId: String, + @param:JsonProperty("configurationId") val configurationId: String, + @param:JsonProperty("configurationVersion") val configurationVersion: String, + @param:JsonProperty("environmentId") val environmentId: String, + @param:JsonProperty("deploymentId") val deploymentId: String, +) { + fun toCore(): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId(configurationId), + version = ConfigurationVersion(configurationVersion), + ), + environmentId = EnvironmentId(environmentId), + deploymentId = DeploymentId(deploymentId), + ), + runId = RunId(runId), + ) +} + +internal fun GovernedRunIdentity.toPersisted(): PersistedGovernedRunIdentity = + PersistedGovernedRunIdentity( + runId = runId.value, + workloadId = deployment.workloadId.value, + configurationId = deployment.configuration.id.value, + configurationVersion = deployment.configuration.version.value, + environmentId = deployment.environmentId.value, + deploymentId = deployment.deploymentId.value, + ) + +/** + * V2 carrier: the ordinary record (reusing the released V1 DTO verbatim, so the ordinary fields cannot + * drift between shapes) plus the complete identity. + */ +internal data class PersistedSovereignOpsAuditOutboxRecordV2( + @get:JsonProperty("schemaVersion") val schemaVersion: Int = 2, + @param:JsonProperty("record") val record: PersistedSovereignOpsAuditOutboxRecordV1, + @param:JsonProperty("governedRunIdentity") val governedRunIdentity: PersistedGovernedRunIdentity, +) + +/** + * The one JSON mapper for outbox payloads: strict on unknown properties, so an unrecognised field is a + * decode failure rather than a silently dropped one. + */ +internal val OPS_OUTBOX_JSON: ObjectMapper = + ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) + .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) + +/** + * Encodes a record, choosing the shape from the identity's presence: absent → V1, present → V2. + * + * A governed record can therefore lose its attribution only by a caller deliberately handing over a + * null identity; no status transition can drop it as a side effect. + */ +internal fun encodeOutboxRecord( + record: SovereignOpsAuditOutboxRecord, + runIdentity: GovernedRunIdentity?, +): ByteArray { + val persisted = record.toPersistedOutbox() + return if (runIdentity == null) { + OPS_OUTBOX_JSON.writeValueAsBytes(persisted) + } else { + OPS_OUTBOX_JSON.writeValueAsBytes( + PersistedSovereignOpsAuditOutboxRecordV2( + record = persisted, + governedRunIdentity = runIdentity.toPersisted(), + ), + ) + } +} + +/** + * Decodes any persisted outbox payload, dispatching on the declared schema version. + * + * The two failure modes stay distinct: an unreadable or internally inconsistent payload is corruption, + * while a version this codec does not know is an unsupported format — never an old record. + */ +internal fun decodeOutboxRecord(json: ByteArray): DecodedOutboxRecord { + val node = readJsonTree(json) + val version = outboxSchemaVersion(node) + check(version == OFFICIAL_VERSION || version == GOVERNED_VERSION) { + "audit-outbox-unsupported-schema-version: $version" + } + return try { + if (version == OFFICIAL_VERSION) decodeLegacy(node, json) else decodeGoverned(json) + } catch (e: IllegalArgumentException) { + throw IllegalStateException(ERROR_CORRUPTED_RECORD, e) + } catch (e: JsonProcessingException) { + throw IllegalStateException(ERROR_CORRUPTED_RECORD, e) + } +} + +/** + * Legacy V1: no attribution was persisted, and none is allowed in this shape. + */ +private fun decodeLegacy( + node: JsonNode, + json: ByteArray, +): DecodedOutboxRecord { + check(node.get("governedRunIdentity") == null) { + "$ERROR_CORRUPTED_RECORD: legacy payload carries a governed identity" + } + val v1 = OPS_OUTBOX_JSON.readValue(json) + return DecodedOutboxRecord(record = v1.toDomain(), runIdentity = null) +} + +/** + * Governed V2: the identity is mandatory and must name the same run as the ordinary record. + */ +private fun decodeGoverned(json: ByteArray): DecodedOutboxRecord { + val v2 = OPS_OUTBOX_JSON.readValue(json) + val identity = v2.governedRunIdentity.toCore() + val record = v2.record.toDomain() + check( + identity.deployment.workloadId.value + .isNotBlank() && identity.runId.value.isNotBlank(), + ) { + "$ERROR_CORRUPTED_RECORD: governed run identity is incomplete" + } + check(identity.runId.value == record.workflowRunId) { + "$ERROR_CORRUPTED_RECORD: governed attribution names another run: " + + "'${record.workflowRunId}' != canonical runId '${identity.runId.value}'" + } + return DecodedOutboxRecord(record = record, runIdentity = identity) +} + +/** + * Reads only the declared schema version, leaving the payload untouched for the real decode. + * + * Only an integral, in-range JSON number is a schema declaration: anything else — missing, null, + * text, float, out of int range — is corruption. `asInt()` coerces instead (`"2"` and `2.9` to 2, + * `"abc"` to 0), which turns a damaged payload into a wrong answer: a bogus V1/V2 decode, or a + * false "unsupported version" report for a payload that is simply damaged. + */ +private fun outboxSchemaVersion(node: JsonNode): Int { + val version = node.get("schemaVersion") + val declared = version?.takeIf { it.isIntegralNumber && it.canConvertToInt() }?.intValue() + return checkNotNull(declared) { "$ERROR_CORRUPTED_RECORD: no declared integral schema version" } +} + +/** + * Strict tree read: a payload that is not valid JSON at all is corruption, not an unknown version. + */ +private fun readJsonTree(json: ByteArray): JsonNode = + try { + checkNotNull(OPS_OUTBOX_JSON.readTree(json)) { "$ERROR_CORRUPTED_RECORD: empty payload" } + } catch (e: JsonProcessingException) { + throw IllegalStateException(ERROR_CORRUPTED_RECORD, e) + } + +/** Failure code for a payload that is damaged or internally inconsistent. */ +internal const val ERROR_CORRUPTED_RECORD: String = "audit-outbox-payload-corrupted" + +private const val OFFICIAL_VERSION = 1 +private const val GOVERNED_VERSION = 2 diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsAuditOutboxStore.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsAuditOutboxStore.kt index 957f7805a..2b8670dfd 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsAuditOutboxStore.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsAuditOutboxStore.kt @@ -1,11 +1,11 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + package dev.tramai.spring.sovereign.persistence.jdbc -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.fasterxml.jackson.module.kotlin.readValue -import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxGovernance import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore @@ -58,19 +58,11 @@ class JdbcSovereignOpsAuditOutboxStore( private val claimLeaseDuration: Duration = SovereignOpsAuditOutboxRecord.DEFAULT_CLAIM_EXPIRY, private val maxClaimLimit: Int = 500, ) : SovereignOpsAuditOutboxStore { - init { require(!claimLeaseDuration.isNegative) { "claimLeaseDuration must not be negative" } require(maxClaimLimit > 0) { "maxClaimLimit must be positive" } } - private val mapper: ObjectMapper = ObjectMapper() - .registerKotlinModule() - .registerModule(JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) - .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) - private companion object { /** * Maximum candidates considered by the blocking fallback in @@ -89,8 +81,44 @@ class JdbcSovereignOpsAuditOutboxStore( override fun isDurable(): Boolean = true - override suspend fun append( + override suspend fun append(record: SovereignOpsAuditOutboxRecord): SovereignOpsAuditOutboxRecord = + appendRecord(record, runIdentity = null) + + /** + * 0.7.1d: appends a governed record — the ordinary outbox fields and the complete canonical + * identity in one encrypted row of one transaction. No side table, no identity columns. + */ + internal suspend fun appendGoverned(entry: GovernedSovereignOpsAuditOutboxRecord) { + appendRecord(entry.record, entry.runIdentity) + } + + /** + * 0.7.1d: resolves durable provenance from record existence first, so an absent record is never + * mistaken for a legacy one and a governed row is never reported without its identity. + */ + internal suspend fun governanceById(outboxId: String): SovereignOpsAuditOutboxGovernance = + dataSource.connection.use { conn -> + val row = + selectById(conn, outboxId) + ?: return@use SovereignOpsAuditOutboxGovernance.NoRecord + val decoded = row.toDecoded() + validateQueryableColumns(decoded.record, row) + when (val identity = decoded.runIdentity) { + null -> { + SovereignOpsAuditOutboxGovernance.Legacy(decoded.record) + } + + else -> { + SovereignOpsAuditOutboxGovernance.Governed( + GovernedSovereignOpsAuditOutboxRecord(decoded.record, identity), + ) + } + } + } + + private suspend fun appendRecord( record: SovereignOpsAuditOutboxRecord, + runIdentity: GovernedRunIdentity?, ): SovereignOpsAuditOutboxRecord { require(record.outboxId.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-id" } require(record.eventKey.isNotBlank()) { "tramai-sovereign-ops-outbox-invalid-event-key" } @@ -101,8 +129,7 @@ class JdbcSovereignOpsAuditOutboxStore( return dataSource.connection.use { conn -> try { inOutboxTransaction(conn) { c -> - val payloadJson = mapper.writeValueAsBytes(record.toPersistedOutbox()) - val encrypted = payloadCodec.encode(payloadJson) + val encrypted = payloadCodec.encode(encodeOutboxRecord(record, runIdentity)) insertAppend(c, record, encrypted) record @@ -116,29 +143,31 @@ class JdbcSovereignOpsAuditOutboxStore( override suspend fun markReadyForDispatch( outboxId: String, expectedStatus: SovereignOpsAuditOutboxStatus, - ): SovereignOpsAuditOutboxRecord = dataSource.connection.use { conn -> - inOutboxTransaction(conn) { c -> - val row = selectForUpdate(c, outboxId) - ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") + ): SovereignOpsAuditOutboxRecord = + dataSource.connection.use { conn -> + inOutboxTransaction(conn) { c -> + val row = + selectForUpdate(c, outboxId) + ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") - val domain = row.toDomain() - validateQueryableColumns(domain, row) + val decoded = row.toDecoded() + val domain = decoded.record + validateQueryableColumns(domain, row) - require(expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED) { - "tramai-sovereign-ops-outbox-status-mismatch" - } - require(domain.status == expectedStatus) { - "tramai-sovereign-ops-outbox-status-mismatch" - } + require(expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED) { + "tramai-sovereign-ops-outbox-status-mismatch" + } + require(domain.status == expectedStatus) { + "tramai-sovereign-ops-outbox-status-mismatch" + } - val updated = domain.copy(status = SovereignOpsAuditOutboxStatus.PENDING) - val payloadJson = mapper.writeValueAsBytes(updated.toPersistedOutbox()) - val encrypted = payloadCodec.encode(payloadJson) + val updated = domain.copy(status = SovereignOpsAuditOutboxStatus.PENDING) + val encrypted = payloadCodec.encode(encodeOutboxRecord(updated, decoded.runIdentity)) - updateStatusAndPayload(c, outboxId, row.version, updated.status.name, encrypted) - updated + updateStatusAndPayload(c, outboxId, row.version, updated.status.name, encrypted) + updated + } } - } override suspend fun claimPending( claimedBy: String, @@ -164,26 +193,28 @@ class JdbcSovereignOpsAuditOutboxStore( selected = selectClaimableSerialized(c, actualLimit, now) } - val claimed = selected.map { row -> - val record = row.toDomain() - validateQueryableColumns(record, row) - require(record.isDispatchable(now)) { - "tramai-sovereign-ops-outbox-not-dispatchable" + val claimed = + selected.map { row -> + val decoded = row.toDecoded() + val record = decoded.record + validateQueryableColumns(record, row) + require(record.isDispatchable(now)) { + "tramai-sovereign-ops-outbox-not-dispatchable" + } + val updated = + record.copy( + status = SovereignOpsAuditOutboxStatus.EMITTING, + attemptCount = record.attemptCount + 1, + claimedBy = claimedBy, + claimedAt = now, + claimExpiresAt = claimExpiresAt, + lastErrorCode = null, + ) + val encrypted = payloadCodec.encode(encodeOutboxRecord(updated, decoded.runIdentity)) + + updateClaimed(c, updated.outboxId, encrypted, now, claimExpiresAt) + updated } - val updated = record.copy( - status = SovereignOpsAuditOutboxStatus.EMITTING, - attemptCount = record.attemptCount + 1, - claimedBy = claimedBy, - claimedAt = now, - claimExpiresAt = claimExpiresAt, - lastErrorCode = null, - ) - val payloadJson = mapper.writeValueAsBytes(updated.toPersistedOutbox()) - val encrypted = payloadCodec.encode(payloadJson) - - updateClaimed(c, updated.outboxId, encrypted, now, claimExpiresAt) - updated - } claimed } } @@ -194,59 +225,63 @@ class JdbcSovereignOpsAuditOutboxStore( expectedStatus: SovereignOpsAuditOutboxStatus, expectedAttemptCount: Int, emittedAt: Instant, - ): SovereignOpsAuditOutboxRecord = dataSource.connection.use { conn -> - inOutboxTransaction(conn) { conn -> - val row = selectForUpdate(conn, outboxId) - ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") - - val domain = row.toDomain() - validateQueryableColumns(domain, row) + ): SovereignOpsAuditOutboxRecord = + dataSource.connection.use { conn -> + inOutboxTransaction(conn) { conn -> + val row = + selectForUpdate(conn, outboxId) + ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") - require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { - "tramai-sovereign-ops-outbox-status-mismatch" - } - require(domain.status == expectedStatus) { - "tramai-sovereign-ops-outbox-status-mismatch" - } - check(domain.attemptCount == expectedAttemptCount) { - "tramai-sovereign-ops-outbox-concurrent-update" - } + val decoded = row.toDecoded() + val domain = decoded.record + validateQueryableColumns(domain, row) - val updated = domain.copy( - status = SovereignOpsAuditOutboxStatus.EMITTED, - emittedAt = emittedAt, - ) - val payloadJson = mapper.writeValueAsBytes(updated.toPersistedOutbox()) - val encrypted = payloadCodec.encode(payloadJson) - - val sql = """ - UPDATE audit_outbox - SET status = 'EMITTED', - dispatched_at = ?, - encrypted_payload = ?, - encryption_key_id = ?, - encryption_algorithm = ?, - encryption_nonce = ?, - payload_digest = ?, - version = version + 1 - WHERE outbox_id = ? AND version = ? - """.trimIndent() + require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { + "tramai-sovereign-ops-outbox-status-mismatch" + } + require(domain.status == expectedStatus) { + "tramai-sovereign-ops-outbox-status-mismatch" + } + check(domain.attemptCount == expectedAttemptCount) { + "tramai-sovereign-ops-outbox-concurrent-update" + } - conn.prepareStatement(sql).use { stmt -> - stmt.setTimestamp(1, Timestamp.from(emittedAt)) - stmt.setBytes(2, encrypted.ciphertext) - stmt.setString(3, encrypted.keyId) - stmt.setString(4, encrypted.algorithm) - stmt.setBytes(5, encrypted.nonce) - stmt.setString(6, encrypted.payloadDigest) - stmt.setString(7, outboxId) - stmt.setLong(8, row.version) - val updatedCount = stmt.executeUpdate() - require(updatedCount == 1) { "tramai-sovereign-ops-outbox-concurrent-update" } + val updated = + domain.copy( + status = SovereignOpsAuditOutboxStatus.EMITTED, + emittedAt = emittedAt, + ) + val encrypted = payloadCodec.encode(encodeOutboxRecord(updated, decoded.runIdentity)) + + val sql = + """ + UPDATE audit_outbox + SET status = 'EMITTED', + dispatched_at = ?, + encrypted_payload = ?, + encryption_key_id = ?, + encryption_algorithm = ?, + encryption_nonce = ?, + payload_digest = ?, + version = version + 1 + WHERE outbox_id = ? AND version = ? + """.trimIndent() + + conn.prepareStatement(sql).use { stmt -> + stmt.setTimestamp(1, Timestamp.from(emittedAt)) + stmt.setBytes(2, encrypted.ciphertext) + stmt.setString(3, encrypted.keyId) + stmt.setString(4, encrypted.algorithm) + stmt.setBytes(5, encrypted.nonce) + stmt.setString(6, encrypted.payloadDigest) + stmt.setString(7, outboxId) + stmt.setLong(8, row.version) + val updatedCount = stmt.executeUpdate() + require(updatedCount == 1) { "tramai-sovereign-ops-outbox-concurrent-update" } + } + updated } - updated } - } override suspend fun markFailed( outboxId: String, @@ -254,75 +289,80 @@ class JdbcSovereignOpsAuditOutboxStore( expectedAttemptCount: Int, errorCode: String, retryable: Boolean, - ): SovereignOpsAuditOutboxRecord = dataSource.connection.use { conn -> - inOutboxTransaction(conn) { conn -> - val row = selectForUpdate(conn, outboxId) - ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") + ): SovereignOpsAuditOutboxRecord = + dataSource.connection.use { conn -> + inOutboxTransaction(conn) { conn -> + val row = + selectForUpdate(conn, outboxId) + ?: throw IllegalStateException("tramai-sovereign-ops-outbox-not-found") - val domain = row.toDomain() - validateQueryableColumns(domain, row) + val decoded = row.toDecoded() + val domain = decoded.record + validateQueryableColumns(domain, row) - if (retryable) { - require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { - "tramai-sovereign-ops-outbox-status-mismatch" + if (retryable) { + require(expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING) { + "tramai-sovereign-ops-outbox-status-mismatch" + } + } else { + require( + expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING || + expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED, + ) { + "tramai-sovereign-ops-outbox-status-mismatch" + } } - } else { - require( - expectedStatus == SovereignOpsAuditOutboxStatus.EMITTING || - expectedStatus == SovereignOpsAuditOutboxStatus.PREPARED - ) { + require(domain.status == expectedStatus) { "tramai-sovereign-ops-outbox-status-mismatch" } - } - require(domain.status == expectedStatus) { - "tramai-sovereign-ops-outbox-status-mismatch" - } - check(domain.attemptCount == expectedAttemptCount) { - "tramai-sovereign-ops-outbox-concurrent-update" - } - - val targetStatus = if (retryable) { - SovereignOpsAuditOutboxStatus.FAILED_RETRYABLE - } else { - SovereignOpsAuditOutboxStatus.FAILED_PERMANENT - } + check(domain.attemptCount == expectedAttemptCount) { + "tramai-sovereign-ops-outbox-concurrent-update" + } - val updated = domain.copy( - status = targetStatus, - lastErrorCode = errorCode, - ) - val payloadJson = mapper.writeValueAsBytes(updated.toPersistedOutbox()) - val encrypted = payloadCodec.encode(payloadJson) - - val sql = """ - UPDATE audit_outbox - SET status = ?, - last_failure_type = ?, - encrypted_payload = ?, - encryption_key_id = ?, - encryption_algorithm = ?, - encryption_nonce = ?, - payload_digest = ?, - version = version + 1 - WHERE outbox_id = ? AND version = ? - """.trimIndent() + val targetStatus = + if (retryable) { + SovereignOpsAuditOutboxStatus.FAILED_RETRYABLE + } else { + SovereignOpsAuditOutboxStatus.FAILED_PERMANENT + } - conn.prepareStatement(sql).use { stmt -> - stmt.setString(1, targetStatus.name) - stmt.setString(2, errorCode) - stmt.setBytes(3, encrypted.ciphertext) - stmt.setString(4, encrypted.keyId) - stmt.setString(5, encrypted.algorithm) - stmt.setBytes(6, encrypted.nonce) - stmt.setString(7, encrypted.payloadDigest) - stmt.setString(8, outboxId) - stmt.setLong(9, row.version) - val updatedCount = stmt.executeUpdate() - require(updatedCount == 1) { "tramai-sovereign-ops-outbox-concurrent-update" } + val updated = + domain.copy( + status = targetStatus, + lastErrorCode = errorCode, + ) + val encrypted = payloadCodec.encode(encodeOutboxRecord(updated, decoded.runIdentity)) + + val sql = + """ + UPDATE audit_outbox + SET status = ?, + last_failure_type = ?, + encrypted_payload = ?, + encryption_key_id = ?, + encryption_algorithm = ?, + encryption_nonce = ?, + payload_digest = ?, + version = version + 1 + WHERE outbox_id = ? AND version = ? + """.trimIndent() + + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, targetStatus.name) + stmt.setString(2, errorCode) + stmt.setBytes(3, encrypted.ciphertext) + stmt.setString(4, encrypted.keyId) + stmt.setString(5, encrypted.algorithm) + stmt.setBytes(6, encrypted.nonce) + stmt.setString(7, encrypted.payloadDigest) + stmt.setString(8, outboxId) + stmt.setLong(9, row.version) + val updatedCount = stmt.executeUpdate() + require(updatedCount == 1) { "tramai-sovereign-ops-outbox-concurrent-update" } + } + updated } - updated } - } override suspend fun get(outboxId: String): SovereignOpsAuditOutboxRecord? = dataSource.connection.use { conn -> @@ -348,8 +388,7 @@ class JdbcSovereignOpsAuditOutboxStore( override suspend fun listByStatus( status: SovereignOpsAuditOutboxStatus, limit: Int, - ): List = - if (limit <= 0) emptyList() else listByExactStatus(status, limit) + ): List = if (limit <= 0) emptyList() else listByExactStatus(status, limit) override suspend fun listExpiredEmitting( now: Instant, @@ -381,26 +420,33 @@ class JdbcSovereignOpsAuditOutboxStore( } } - private fun OutboxRow.toDomain(): SovereignOpsAuditOutboxRecord { - val encrypted = JdbcEncryptedAuditOutboxPayload( - ciphertext = encryptedPayload, - keyId = encryptionKeyId, - algorithm = encryptionAlgorithm, - nonce = encryptionNonce, - payloadDigest = payloadDigest, - ) - val plaintext = try { - payloadCodec.decode(encrypted) - } catch (e: Exception) { - throw IllegalStateException("audit-outbox-payload-decryption-failed", e) - } - return try { - mapper.readValue(plaintext).toDomain() - } catch (e: Exception) { - throw IllegalStateException("audit-outbox-payload-deserialisation-failed", e) - } + /** + * 0.7.1d: decrypts and decodes one row's payload, keeping the canonical identity alongside the + * ordinary record so a transition re-encodes exactly the shape it read. + * + * Only the envelope step is wrapped here, so the codec's own failure contract (corruption vs + * unsupported schema version) reaches callers unchanged instead of being flattened. + */ + private fun OutboxRow.toDecoded(): DecodedOutboxRecord { + val encrypted = + JdbcEncryptedAuditOutboxPayload( + ciphertext = encryptedPayload, + keyId = encryptionKeyId, + algorithm = encryptionAlgorithm, + nonce = encryptionNonce, + payloadDigest = payloadDigest, + ) + val plaintext = + try { + payloadCodec.decode(encrypted) + } catch (e: Exception) { + throw IllegalStateException("audit-outbox-payload-decryption-failed", e) + } + return decodeOutboxRecord(plaintext) } + private fun OutboxRow.toDomain(): SovereignOpsAuditOutboxRecord = toDecoded().record + private fun validateQueryableColumns( domain: SovereignOpsAuditOutboxRecord, row: OutboxRow, @@ -472,15 +518,21 @@ class JdbcSovereignOpsAuditOutboxStore( when (status) { SovereignOpsAuditOutboxStatus.PENDING, SovereignOpsAuditOutboxStatus.FAILED_RETRYABLE, - -> true + -> { + true + } + SovereignOpsAuditOutboxStatus.EMITTING -> { val expiresAt = claimExpiresAt expiresAt != null && expiresAt.isBefore(now) } + SovereignOpsAuditOutboxStatus.PREPARED, SovereignOpsAuditOutboxStatus.EMITTED, SovereignOpsAuditOutboxStatus.FAILED_PERMANENT, - -> false + -> { + false + } } // ══════════════════════════════════════════════════════════════════ @@ -492,14 +544,15 @@ class JdbcSovereignOpsAuditOutboxStore( record: SovereignOpsAuditOutboxRecord, encrypted: JdbcEncryptedAuditOutboxPayload, ) { - val sql = """ + val sql = + """ INSERT INTO audit_outbox ( outbox_id, event_key, status, correlation_key_hash, created_at, attempt_count, encrypted_payload, encryption_key_id, encryption_algorithm, encryption_nonce, payload_digest, version ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1) - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, record.outboxId) @@ -520,16 +573,20 @@ class JdbcSovereignOpsAuditOutboxStore( // SQL — SELECT helpers // ══════════════════════════════════════════════════════════════════ - private val SELECT_COLUMNS = """ + private val selectColumns = + """ SELECT outbox_id, event_key, status, correlation_key_hash, created_at, claimed_at, dispatched_at, attempt_count, last_failure_type, next_attempt_at, encrypted_payload, encryption_key_id, encryption_algorithm, encryption_nonce, payload_digest, version - """.trimIndent() + """.trimIndent() - private fun selectForUpdate(conn: Connection, outboxId: String): OutboxRow? { - val sql = "$SELECT_COLUMNS FROM audit_outbox WHERE outbox_id = ? FOR UPDATE" + private fun selectForUpdate( + conn: Connection, + outboxId: String, + ): OutboxRow? { + val sql = "$selectColumns FROM audit_outbox WHERE outbox_id = ? FOR UPDATE" return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, outboxId) stmt.executeQuery().let { rs -> @@ -538,8 +595,11 @@ class JdbcSovereignOpsAuditOutboxStore( } } - private fun selectById(conn: Connection, outboxId: String): OutboxRow? { - val sql = "$SELECT_COLUMNS FROM audit_outbox WHERE outbox_id = ?" + private fun selectById( + conn: Connection, + outboxId: String, + ): OutboxRow? { + val sql = "$selectColumns FROM audit_outbox WHERE outbox_id = ?" return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, outboxId) stmt.executeQuery().let { rs -> @@ -548,8 +608,11 @@ class JdbcSovereignOpsAuditOutboxStore( } } - private fun selectByEventKey(conn: Connection, eventKey: String): OutboxRow? { - val sql = "$SELECT_COLUMNS FROM audit_outbox WHERE event_key = ?" + private fun selectByEventKey( + conn: Connection, + eventKey: String, + ): OutboxRow? { + val sql = "$selectColumns FROM audit_outbox WHERE event_key = ?" return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, eventKey) stmt.executeQuery().let { rs -> @@ -558,8 +621,12 @@ class JdbcSovereignOpsAuditOutboxStore( } } - private fun selectByStatus(conn: Connection, status: String, limit: Int): List { - val sql = "$SELECT_COLUMNS FROM audit_outbox WHERE status = ? ORDER BY created_at ASC LIMIT ?" + private fun selectByStatus( + conn: Connection, + status: String, + limit: Int, + ): List { + val sql = "$selectColumns FROM audit_outbox WHERE status = ? ORDER BY created_at ASC LIMIT ?" return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, status) stmt.setInt(2, limit) @@ -576,8 +643,9 @@ class JdbcSovereignOpsAuditOutboxStore( limit: Int, now: Instant, ): List { - val sql = """ - $SELECT_COLUMNS FROM audit_outbox + val sql = + """ + $selectColumns FROM audit_outbox WHERE status IN ('PENDING', 'FAILED_RETRYABLE') OR ( @@ -588,7 +656,7 @@ class JdbcSovereignOpsAuditOutboxStore( ORDER BY created_at ASC LIMIT ? FOR UPDATE SKIP LOCKED - """.trimIndent() + """.trimIndent() return conn.prepareStatement(sql).use { stmt -> stmt.setTimestamp(1, Timestamp.from(now)) @@ -652,7 +720,8 @@ class JdbcSovereignOpsAuditOutboxStore( now: Instant, limit: Int, ): List { - val sql = """ + val sql = + """ SELECT outbox_id FROM audit_outbox WHERE status IN ('PENDING', 'FAILED_RETRYABLE') @@ -663,7 +732,7 @@ class JdbcSovereignOpsAuditOutboxStore( ) ORDER BY created_at ASC LIMIT ? - """.trimIndent() + """.trimIndent() return conn.prepareStatement(sql).use { stmt -> stmt.setTimestamp(1, Timestamp.from(now)) @@ -676,8 +745,11 @@ class JdbcSovereignOpsAuditOutboxStore( } } - private fun selectByIdForUpdate(conn: Connection, outboxId: String): OutboxRow? { - val sql = "$SELECT_COLUMNS FROM audit_outbox WHERE outbox_id = ? FOR UPDATE" + private fun selectByIdForUpdate( + conn: Connection, + outboxId: String, + ): OutboxRow? { + val sql = "$selectColumns FROM audit_outbox WHERE outbox_id = ? FOR UPDATE" return conn.prepareStatement(sql).use { stmt -> stmt.setString(1, outboxId) stmt.executeQuery().let { rs -> @@ -691,14 +763,15 @@ class JdbcSovereignOpsAuditOutboxStore( now: Instant, limit: Int, ): List { - val sql = """ - $SELECT_COLUMNS FROM audit_outbox + val sql = + """ + $selectColumns FROM audit_outbox WHERE status = 'EMITTING' AND next_attempt_at IS NOT NULL AND next_attempt_at < ? ORDER BY created_at ASC LIMIT ? - """.trimIndent() + """.trimIndent() return conn.prepareStatement(sql).use { stmt -> stmt.setTimestamp(1, Timestamp.from(now)) @@ -722,7 +795,8 @@ class JdbcSovereignOpsAuditOutboxStore( newStatus: String, encrypted: JdbcEncryptedAuditOutboxPayload, ) { - val sql = """ + val sql = + """ UPDATE audit_outbox SET status = ?, encrypted_payload = ?, @@ -732,7 +806,7 @@ class JdbcSovereignOpsAuditOutboxStore( payload_digest = ?, version = version + 1 WHERE outbox_id = ? AND version = ? - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setString(1, newStatus) @@ -755,7 +829,8 @@ class JdbcSovereignOpsAuditOutboxStore( claimedAt: Instant, claimExpiresAt: Instant, ) { - val sql = """ + val sql = + """ UPDATE audit_outbox SET status = 'EMITTING', claimed_at = ?, @@ -769,7 +844,7 @@ class JdbcSovereignOpsAuditOutboxStore( payload_digest = ?, version = version + 1 WHERE outbox_id = ? - """.trimIndent() + """.trimIndent() conn.prepareStatement(sql).use { stmt -> stmt.setTimestamp(1, Timestamp.from(claimedAt)) @@ -809,35 +884,53 @@ class JdbcSovereignOpsAuditOutboxStore( ) private fun mapRow(rs: ResultSet): OutboxRow { - val createdAt = rs.getTimestamp("created_at")?.toInstant() - ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) } - ?: throw IllegalStateException("audit-outbox-missing-created-at") + val createdAt = + rs + .getTimestamp("created_at") + ?.toInstant() + ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) } + ?: throw IllegalStateException("audit-outbox-missing-created-at") return OutboxRow( - outboxId = rs.getString("outbox_id") - ?: throw IllegalStateException("audit-outbox-missing-outbox-id"), - eventKey = rs.getString("event_key") - ?: throw IllegalStateException("audit-outbox-missing-event-key"), - status = rs.getString("status") - ?: throw IllegalStateException("audit-outbox-missing-status"), + outboxId = + rs.getString("outbox_id") + ?: throw IllegalStateException("audit-outbox-missing-outbox-id"), + eventKey = + rs.getString("event_key") + ?: throw IllegalStateException("audit-outbox-missing-event-key"), + status = + rs.getString("status") + ?: throw IllegalStateException("audit-outbox-missing-status"), correlationKeyHash = rs.getString("correlation_key_hash"), createdAt = createdAt, - claimedAt = rs.getTimestamp("claimed_at")?.toInstant() - ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) }, - dispatchedAt = rs.getTimestamp("dispatched_at")?.toInstant() - ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) }, + claimedAt = + rs + .getTimestamp("claimed_at") + ?.toInstant() + ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) }, + dispatchedAt = + rs + .getTimestamp("dispatched_at") + ?.toInstant() + ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) }, attemptCount = rs.getInt("attempt_count"), lastFailureType = rs.getString("last_failure_type"), - nextAttemptAt = rs.getTimestamp("next_attempt_at")?.toInstant() - ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) }, + nextAttemptAt = + rs + .getTimestamp("next_attempt_at") + ?.toInstant() + ?.let { OffsetDateTime.ofInstant(it, ZoneOffset.UTC) }, encryptedPayload = rs.getBytes("encrypted_payload") ?: ByteArray(0), - encryptionKeyId = rs.getString("encryption_key_id") - ?: throw IllegalStateException("audit-outbox-missing-key-id"), - encryptionAlgorithm = rs.getString("encryption_algorithm") - ?: throw IllegalStateException("audit-outbox-missing-algorithm"), + encryptionKeyId = + rs.getString("encryption_key_id") + ?: throw IllegalStateException("audit-outbox-missing-key-id"), + encryptionAlgorithm = + rs.getString("encryption_algorithm") + ?: throw IllegalStateException("audit-outbox-missing-algorithm"), encryptionNonce = rs.getBytes("encryption_nonce") ?: ByteArray(0), - payloadDigest = rs.getString("payload_digest") - ?: throw IllegalStateException("audit-outbox-missing-payload-digest"), + payloadDigest = + rs.getString("payload_digest") + ?: throw IllegalStateException("audit-outbox-missing-payload-digest"), version = rs.getLong("version"), ) } @@ -846,16 +939,20 @@ class JdbcSovereignOpsAuditOutboxStore( // Exception mapping // ══════════════════════════════════════════════════════════════════ - private fun mapAppendException( - e: SQLException, - ): RuntimeException { + private fun mapAppendException(e: SQLException): RuntimeException { val message = e.message ?: "" return when { - message.contains("uq_audit_outbox_event_key") || message.contains("audit_outbox_event_key_key") -> + message.contains("uq_audit_outbox_event_key") || message.contains("audit_outbox_event_key_key") -> { IllegalArgumentException("tramai-sovereign-ops-outbox-duplicate-event-key") - message.contains("audit_outbox_pkey") -> + } + + message.contains("audit_outbox_pkey") -> { IllegalArgumentException("tramai-sovereign-ops-outbox-duplicate-id") - else -> IllegalStateException("tramai-sovereign-ops-outbox-database-failure", e) + } + + else -> { + IllegalStateException("tramai-sovereign-ops-outbox-database-failure", e) + } } } @@ -866,7 +963,10 @@ class JdbcSovereignOpsAuditOutboxStore( * autoCommit-restore failure — later cleanup failures are attached as * suppressed to the primary, never replacing it. */ - private fun inOutboxTransaction(conn: Connection, block: (Connection) -> T): T { + private fun inOutboxTransaction( + conn: Connection, + block: (Connection) -> T, + ): T { val previousAutoCommit = conn.autoCommit conn.autoCommit = false var primaryFailure: Exception? = null @@ -968,17 +1068,39 @@ internal fun PersistedSovereignOpsAuditOutboxRecordV1.toDomain(): SovereignOpsAu approvalVersion = approvalVersion, reasonDigest = reasonDigest, reasonLength = reasonLength, - createdAt = try { - Instant.parse(createdAt) - } catch (_: Exception) { - OffsetDateTime.parse(createdAt).toInstant() - }, + createdAt = + try { + Instant.parse(createdAt) + } catch (_: Exception) { + OffsetDateTime.parse(createdAt).toInstant() + }, status = SovereignOpsAuditOutboxStatus.valueOf(status), attemptCount = attemptCount, lastErrorCode = lastErrorCode, claimedBy = claimedBy, - claimedAt = claimedAt?.let { try { Instant.parse(it) } catch (_: Exception) { null } }, - claimExpiresAt = claimExpiresAt?.let { try { Instant.parse(it) } catch (_: Exception) { null } }, - emittedAt = emittedAt?.let { try { Instant.parse(it) } catch (_: Exception) { null } }, + claimedAt = + claimedAt?.let { + try { + Instant.parse(it) + } catch (_: Exception) { + null + } + }, + claimExpiresAt = + claimExpiresAt?.let { + try { + Instant.parse(it) + } catch (_: Exception) { + null + } + }, + emittedAt = + emittedAt?.let { + try { + Instant.parse(it) + } catch (_: Exception) { + null + } + }, ) } diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfiguration.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfiguration.kt index 0d3ba8bea..f7026ab4b 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfiguration.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/main/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfiguration.kt @@ -4,8 +4,7 @@ import dev.tramai.core.approval.ApprovalContinuationStore import dev.tramai.core.approval.ApprovalStore import dev.tramai.core.approval.gateway.ApprovalResumeCredentialStore import dev.tramai.engine.SuspendedInvocationStore -import dev.tramai.spring.sovereign.ops.ApprovedContinuationResumeQueue -import dev.tramai.spring.sovereign.ops.ApprovedContinuationResumeQueueStatusStore +import dev.tramai.persistence.jdbc.GovernedJdbcSuspendedInvocationStore import dev.tramai.persistence.jdbc.JdbcApprovalContinuationStore import dev.tramai.persistence.jdbc.JdbcApprovalStore import dev.tramai.persistence.jdbc.JdbcAuditPayloadCodec @@ -14,13 +13,12 @@ import dev.tramai.persistence.jdbc.JdbcContinuationArgumentsCodec import dev.tramai.persistence.jdbc.JdbcReplayEnvelopeCodec import dev.tramai.persistence.jdbc.JdbcSuspendedInvocationStore import dev.tramai.security.audit.AuditStore +import dev.tramai.spring.sovereign.ops.ApprovedContinuationResumeQueue +import dev.tramai.spring.sovereign.ops.ApprovedContinuationResumeQueueStatusStore import dev.tramai.spring.sovereign.ops.lease.SovereignOpsWorkerLeaseStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore -import javax.crypto.SecretKey -import javax.crypto.spec.SecretKeySpec -import javax.sql.DataSource import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnBean @@ -28,6 +26,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource /** * Spring Boot auto-configuration for JDBC-backed sovereign persistence. @@ -90,7 +91,6 @@ import org.springframework.context.annotation.Bean havingValue = "jdbc", ) class SovereignJdbcPersistenceAutoConfiguration { - // ── Fail-fast: missing DataSource ───────────────────────────────── /** @@ -103,11 +103,10 @@ class SovereignJdbcPersistenceAutoConfiguration { */ @Bean @ConditionalOnMissingBean(DataSource::class) - fun missingJdbcDataSourceFailure(): Nothing { + fun missingJdbcDataSourceFailure(): Nothing = throw IllegalStateException( "tramai-sovereign-jdbc-persistence-missing-datasource", ) - } // ── Encryption key ──────────────────────────────────────────────── @@ -119,9 +118,7 @@ class SovereignJdbcPersistenceAutoConfiguration { */ @Bean @ConditionalOnMissingBean(name = ["sovereignJdbcEncryptionKey"]) - fun sovereignJdbcEncryptionKey( - properties: SovereignJdbcPersistenceProperties, - ): SecretKey { + fun sovereignJdbcEncryptionKey(properties: SovereignJdbcPersistenceProperties): SecretKey { val rawKey = SovereignJdbcKeyLoader.load(properties) return SecretKeySpec(rawKey, "AES") } @@ -136,32 +133,28 @@ class SovereignJdbcPersistenceAutoConfiguration { fun jdbcAuditPayloadCodec( @Qualifier("sovereignJdbcEncryptionKey") key: SecretKey, properties: SovereignJdbcPersistenceProperties, - ): JdbcAuditPayloadCodec = - DefaultJdbcAuditPayloadCodec(key, properties.encryption.keyId) + ): JdbcAuditPayloadCodec = DefaultJdbcAuditPayloadCodec(key, properties.encryption.keyId) @Bean @ConditionalOnMissingBean fun jdbcReplayEnvelopeCodec( @Qualifier("sovereignJdbcEncryptionKey") key: SecretKey, properties: SovereignJdbcPersistenceProperties, - ): JdbcReplayEnvelopeCodec = - DefaultJdbcSuspendedInvocationPayloadCodec(key, properties.encryption.keyId) + ): JdbcReplayEnvelopeCodec = DefaultJdbcSuspendedInvocationPayloadCodec(key, properties.encryption.keyId) @Bean @ConditionalOnMissingBean fun jdbcContinuationArgumentsCodec( @Qualifier("sovereignJdbcEncryptionKey") key: SecretKey, properties: SovereignJdbcPersistenceProperties, - ): JdbcContinuationArgumentsCodec = - DefaultJdbcApprovalContinuationPayloadCodec(key, properties.encryption.keyId) + ): JdbcContinuationArgumentsCodec = DefaultJdbcApprovalContinuationPayloadCodec(key, properties.encryption.keyId) @Bean @ConditionalOnMissingBean fun jdbcOpsAuditOutboxPayloadCodec( @Qualifier("sovereignJdbcEncryptionKey") key: SecretKey, properties: SovereignJdbcPersistenceProperties, - ): JdbcOpsAuditOutboxPayloadCodec = - DefaultJdbcOpsAuditOutboxPayloadCodec(key, properties.encryption.keyId) + ): JdbcOpsAuditOutboxPayloadCodec = DefaultJdbcOpsAuditOutboxPayloadCodec(key, properties.encryption.keyId) // ── Store beans ─────────────────────────────────────────────────── // @@ -171,9 +164,7 @@ class SovereignJdbcPersistenceAutoConfiguration { @Bean @ConditionalOnMissingBean @ConditionalOnBean(DataSource::class) - fun approvalStore( - dataSource: DataSource, - ): ApprovalStore = JdbcApprovalStore(dataSource) + fun approvalStore(dataSource: DataSource): ApprovalStore = JdbcApprovalStore(dataSource) @Bean @ConditionalOnMissingBean @@ -189,7 +180,11 @@ class SovereignJdbcPersistenceAutoConfiguration { fun suspendedInvocationStore( dataSource: DataSource, replayEnvelopeCodec: JdbcReplayEnvelopeCodec, - ): SuspendedInvocationStore = JdbcSuspendedInvocationStore(dataSource, replayEnvelopeCodec) + ): SuspendedInvocationStore = + run { + val store = JdbcSuspendedInvocationStore(dataSource, replayEnvelopeCodec) + GovernedJdbcSuspendedInvocationStore(store) + } @Bean @ConditionalOnMissingBean @@ -222,8 +217,13 @@ class SovereignJdbcPersistenceAutoConfiguration { fun sovereignOpsApprovalMutationStore( dataSource: DataSource, outboxPayloadCodec: JdbcOpsAuditOutboxPayloadCodec, + suspendedInvocations: SuspendedInvocationStore, ): SovereignOpsApprovalMutationStore = - JdbcSovereignOpsApprovalMutationStore(dataSource, outboxPayloadCodec) + JdbcSovereignOpsApprovalMutationStore( + dataSource, + outboxPayloadCodec, + suspendedInvocations = suspendedInvocations, + ) @Bean @ConditionalOnMissingBean @@ -236,22 +236,29 @@ class SovereignJdbcPersistenceAutoConfiguration { @Qualifier("sovereignJdbcEncryptionKey") encryptionKey: SecretKey, properties: SovereignJdbcPersistenceProperties, ): SovereignOpsApprovalRequestMutationStore = - JdbcSovereignOpsApprovalRequestMutationStore( - dataSource = dataSource, - replayEnvelopeCodec = replayEnvelopeCodec, - continuationArgumentsCodec = continuationArgumentsCodec, - outboxPayloadCodec = outboxPayloadCodec, - encryptionKey = encryptionKey, - encryptionKeyId = properties.encryption.keyId, + // Exposed through the governed wrapper so the default wiring satisfies the governed + // capability the transactional gateway requires. The wrapper composes the same store and + // forwards into the same single transaction; the delegate still declares only the released + // SPI, so its analyzer identity is unchanged. + GovernedJdbcSovereignOpsApprovalRequestMutationStore( + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayEnvelopeCodec, + continuationArgumentsCodec = continuationArgumentsCodec, + outboxPayloadCodec = outboxPayloadCodec, + encryptionKey = encryptionKey, + encryptionKeyId = properties.encryption.keyId, + ), ) @Bean @ConditionalOnMissingBean @ConditionalOnBean(DataSource::class) - fun sovereignOpsWorkerLeaseStore( - dataSource: DataSource, - ): SovereignOpsWorkerLeaseStore = - JdbcSovereignOpsWorkerLeaseStore(dataSource) + fun sovereignOpsWorkerLeaseStore(dataSource: DataSource): SovereignOpsWorkerLeaseStore = + run { + val store = JdbcSovereignOpsWorkerLeaseStore(dataSource) + store + } @Bean @ConditionalOnMissingBean @@ -270,16 +277,12 @@ class SovereignJdbcPersistenceAutoConfiguration { @Bean @ConditionalOnMissingBean @ConditionalOnBean(DataSource::class) - fun approvedContinuationResumeQueue( - dataSource: DataSource, - ): ApprovedContinuationResumeQueue = + fun approvedContinuationResumeQueue(dataSource: DataSource): ApprovedContinuationResumeQueue = JdbcApprovedContinuationResumeQueue(dataSource) @Bean @ConditionalOnMissingBean @ConditionalOnBean(DataSource::class) - fun approvedContinuationResumeQueueStatusStore( - dataSource: DataSource, - ): ApprovedContinuationResumeQueueStatusStore = + fun approvedContinuationResumeQueueStatusStore(dataSource: DataSource): ApprovedContinuationResumeQueueStatusStore = JdbcApprovedContinuationResumeQueueStatusStore(dataSource) } diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsAuditOutboxStoreTckTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsAuditOutboxStoreTckTest.kt new file mode 100644 index 000000000..2755637ce --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/GovernedJdbcSovereignOpsAuditOutboxStoreTckTest.kt @@ -0,0 +1,125 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.persistence.jdbc + +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore +import dev.tramai.testing.persistence.outbox.SovereignOpsAuditOutboxStoreTck +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import java.security.MessageDigest +import java.security.SecureRandom +import java.sql.Connection +import java.sql.DriverManager +import java.time.Duration +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource + +/** + * 0.7.1d: [GovernedJdbcSovereignOpsAuditOutboxStore] is a concrete [SovereignOpsAuditOutboxStore] + * implementation, so it must satisfy the shared outbox compatibility contract like any other store. + * + * Enrolling the wrapper is worth more than appeasing the enrollment guard: the released contract now + * runs through the governed delegation layer, which is what proves legacy behaviour stays transparent + * and that identity-aware encoding changed nothing a released caller can observe. + * + * The runner owns the datasource, schema, and per-case isolation exactly like the plain store's runner. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GovernedJdbcSovereignOpsAuditOutboxStoreTckTest : SovereignOpsAuditOutboxStoreTck() { + private lateinit var postgres: PostgreSQLContainer + private lateinit var dataSource: DataSource + private lateinit var setupConnection: Connection + + private val testAesKey = ByteArray(16).also { SecureRandom().nextBytes(it) } + + private val testCodec = + object : JdbcOpsAuditOutboxPayloadCodec { + private val algorithm = "AES/GCM/NoPadding" + private val tagLength = 128 + + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload { + val cipher = Cipher.getInstance(algorithm) + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(testAesKey, "AES"), GCMParameterSpec(tagLength, nonce)) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(plaintext) + .joinToString("") { "%02x".format(it) } + return JdbcEncryptedAuditOutboxPayload( + ciphertext = cipher.doFinal(plaintext), + keyId = "governed-tck-key-1", + algorithm = algorithm, + nonce = nonce, + payloadDigest = "sha256:$digest", + ) + } + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray { + val cipher = Cipher.getInstance(algorithm) + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(testAesKey, "AES"), + GCMParameterSpec(tagLength, envelope.nonce), + ) + return cipher.doFinal(envelope.ciphertext) + } + } + + @BeforeAll + fun setUpAll() { + postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("governed_outbox_tck") + .withUsername("test") + .withPassword("test") + postgres.start() + dataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + setupConnection = DriverManager.getConnection(postgres.jdbcUrl, postgres.username, postgres.password) + for ( + migration in + listOf( + "V1__sovereign_persistence.sql", + "V4__audit_outbox_hardening.sql", + ) + ) { + val sql = + this::class.java.classLoader + .getResource("tramai/persistence/jdbc/postgres/$migration") + ?.readText() + ?: error("Migration not found: $migration") + setupConnection.createStatement().use { stmt -> stmt.execute(sql) } + } + } + + @AfterAll + fun tearDownAll() { + runCatching { setupConnection.close() } + runCatching { postgres.stop() } + } + + override fun createStore(): SovereignOpsAuditOutboxStore { + // Fresh isolated storage per case: previous cases' records must not leak into the next case. + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> stmt.execute("TRUNCATE TABLE audit_outbox CASCADE") } + } + return GovernedJdbcSovereignOpsAuditOutboxStore( + JdbcSovereignOpsAuditOutboxStore( + dataSource = dataSource, + payloadCodec = testCodec, + claimLeaseDuration = Duration.ofMinutes(5), + ), + ) + } +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcGovernedApprovalMutationStoreTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcGovernedApprovalMutationStoreTest.kt new file mode 100644 index 000000000..d9cec0e35 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcGovernedApprovalMutationStoreTest.kt @@ -0,0 +1,438 @@ +package dev.tramai.spring.sovereign.persistence.jdbc + +import com.fasterxml.jackson.databind.ObjectMapper +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.engine.GovernedSuspendedInvocation +import dev.tramai.persistence.jdbc.GovernedJdbcSuspendedInvocationStore +import dev.tramai.persistence.jdbc.JdbcSuspendedInvocationStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus +import dev.tramai.testing.persistence.engine.SuspendedInvocationFixtures +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import java.lang.reflect.Proxy +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.sql.Connection +import java.sql.SQLException +import java.sql.Timestamp +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource + +/** + * 0.7.1d: the approval-mutation transaction must record governed attribution without changing its + * shape — one native transaction, `INSERT audit_outbox(PREPARED)` → `UPDATE approvals` → + * `UPDATE audit_outbox(PENDING)` → `COMMIT`, with the same canonical identity in both outbox writes. + * + * The `encode`-recording codec is what makes that claim checkable: it captures the exact plaintexts + * the store encrypts, so "V2 PREPARED then V2 PENDING with the same identity" is asserted on the + * bytes in the transaction rather than inferred from the returned value. + * + * The three injected statements are the discriminator for rollback: failing the insert, the approval + * update, or the final outbox update must leave the approval untouched and no outbox row behind — the + * half-governed mutation this slice exists to prevent. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JdbcGovernedApprovalMutationStoreTest { + private val key: SecretKey = SecretKeySpec(ByteArray(16).also { SecureRandom().nextBytes(it) }, "AES") + private val recordedPlaintexts = mutableListOf() + + private lateinit var dataSource: DataSource + private lateinit var suspended: GovernedJdbcSuspendedInvocationStore + + @BeforeAll + fun startPostgres() { + postgres.start() + dataSource = createDataSource() + migrate() + } + + @AfterAll + fun stopPostgres() { + postgres.stop() + } + + @BeforeEach + fun setUp() { + truncateTables() + recordedPlaintexts.clear() + suspended = + GovernedJdbcSuspendedInvocationStore( + JdbcSuspendedInvocationStore(dataSource, DefaultJdbcSuspendedInvocationPayloadCodec(key, "test-key")), + ) + } + + // ── The transaction shape ──────────────────────────────────────── + + @Test + fun `a governed decision records V2 for both outbox writes and keeps the approval transition`() { + runBlocking { + val identity = identity(RUN_ID) + insertApproval(APPROVAL_ID, workflowRunId = RUN_ID) + suspendGoverned(APPROVAL_ID, identity) + val intent = auditIntent(APPROVAL_ID, workflowRunId = RUN_ID) + + val result = store(dataSource).denyApprovalWithAuditIntent(APPROVAL_ID, 1, "admin", "reason", intent) + + assertThat(result.approval.status).isEqualTo(ApprovalStatus.DENIED) + assertThat(selectApprovalStatus(APPROVAL_ID)).isEqualTo("DENIED") + assertThat(outboxStatus(intent.outboxId)).isEqualTo("PENDING") + assertThat(recordedPlaintexts).hasSize(2) + assertThat(decoded(0).runIdentity).isEqualTo(identity) + assertThat(decoded(1).runIdentity).isEqualTo(identity) + assertThat(decoded(0).record.status).isEqualTo(SovereignOpsAuditOutboxStatus.PREPARED) + assertThat(decoded(1).record.status).isEqualTo(SovereignOpsAuditOutboxStatus.PENDING) + } + } + + @Test + fun `a legacy decision keeps the released V1 shape for both writes`() { + runBlocking { + insertApproval(APPROVAL_ID, workflowRunId = RUN_ID) + val intent = auditIntent(APPROVAL_ID, workflowRunId = null) + + store(dataSource).denyApprovalWithAuditIntent(APPROVAL_ID, 1, "admin", "reason", intent) + + assertThat(recordedPlaintexts).hasSize(2) + assertThat(decoded(0).runIdentity).isNull() + assertThat(decoded(1).runIdentity).isNull() + } + } + + @Test + fun `a governed decision whose intent names another run fails closed before mutating`() { + runBlocking { + insertApproval(APPROVAL_ID, workflowRunId = RUN_ID) + suspendGoverned(APPROVAL_ID, identity(RUN_ID)) + val intent = auditIntent(APPROVAL_ID, workflowRunId = null) + + assertThatThrownBy { + runBlocking { + store(dataSource).denyApprovalWithAuditIntent(APPROVAL_ID, 1, "admin", "reason", intent) + } + }.isInstanceOf(IllegalArgumentException::class.java) + + assertThat(selectApprovalStatus(APPROVAL_ID)).isEqualTo("PENDING") + assertThat(selectApprovalVersion(APPROVAL_ID)).isEqualTo(1L) + assertThat(countOutboxRows()).isZero() + } + } + + // ── Injected failures inside the transaction ───────────────────── + + @Test + fun `a failed outbox insert leaves the approval untouched`() { + runBlocking { + insertApproval(APPROVAL_ID, workflowRunId = RUN_ID) + suspendGoverned(APPROVAL_ID, identity(RUN_ID)) + val intent = auditIntent(APPROVAL_ID, workflowRunId = RUN_ID) + val failing = store(dataSourceFailingOn("INSERT INTO audit_outbox")) + + assertMutationFailed(failing, intent) + assertThat(countOutboxRows()).isZero() + } + } + + @Test + fun `a failed approval update rolls the prepared outbox record back`() { + runBlocking { + insertApproval(APPROVAL_ID, workflowRunId = RUN_ID) + suspendGoverned(APPROVAL_ID, identity(RUN_ID)) + val intent = auditIntent(APPROVAL_ID, workflowRunId = RUN_ID) + val failing = store(dataSourceFailingOn("UPDATE approvals")) + + assertMutationFailed(failing, intent) + assertThat(countOutboxRows()).isZero() + } + } + + @Test + fun `a failed final outbox update rolls back both the approval and the prepared record`() { + runBlocking { + insertApproval(APPROVAL_ID, workflowRunId = RUN_ID) + suspendGoverned(APPROVAL_ID, identity(RUN_ID)) + val intent = auditIntent(APPROVAL_ID, workflowRunId = RUN_ID) + val failing = store(dataSourceFailingOn("UPDATE audit_outbox")) + + assertMutationFailed(failing, intent) + assertThat(countOutboxRows()).isZero() + assertThat(selectApprovalStatus(APPROVAL_ID)).isEqualTo("PENDING") + assertThat(selectApprovalVersion(APPROVAL_ID)).isEqualTo(1L) + } + } + + // ── Helpers ────────────────────────────────────────────────────── + + private suspend fun assertMutationFailed( + failing: JdbcSovereignOpsApprovalMutationStore, + intent: SovereignOpsAuditOutboxRecord, + ) { + assertThatThrownBy { + runBlocking { + failing.denyApprovalWithAuditIntent(APPROVAL_ID, 1, "admin", "reason", intent) + } + }.isInstanceOf(IllegalStateException::class.java) + assertThat(selectApprovalStatus(APPROVAL_ID)).isEqualTo("PENDING") + assertThat(selectApprovalVersion(APPROVAL_ID)).isEqualTo(1L) + } + + /** The store under test, with a codec that records every plaintext the transaction encodes. */ + private fun store(source: DataSource): JdbcSovereignOpsApprovalMutationStore = + JdbcSovereignOpsApprovalMutationStore( + dataSource = source, + payloadCodec = RecordingOutboxCodec(key, recordedPlaintexts), + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + suspendedInvocations = suspended, + ) + + private fun decoded(index: Int): DecodedOutboxRecord = decodeOutboxRecord(recordedPlaintexts[index]) + + private fun suspendGoverned( + approvalId: String, + identity: GovernedRunIdentity, + ) { + val (metadata, envelope) = SuspendedInvocationFixtures.record(approvalId = approvalId) + val governed = metadata.copy(identity = metadata.identity.copy(workflowRunId = identity.runId.value)) + runBlocking { suspended.createGoverned(GovernedSuspendedInvocation(governed, identity), envelope) } + } + + private fun auditIntent( + approvalId: String, + workflowRunId: String?, + ): SovereignOpsAuditOutboxRecord = + SovereignOpsAuditOutboxRecord( + outboxId = "outbox-$approvalId", + eventKey = "key-$approvalId", + aggregateIdDigest = sha256Hex(approvalId), + actor = "admin", + workflowRunId = workflowRunId, + correlationId = null, + approvalStatus = "PENDING", + approvalVersion = 1, + reasonDigest = sha256Hex("reason"), + reasonLength = 6, + createdAt = BASE_NOW, + ) + + /** + * A DataSource whose connections delegate to the real database but reject one statement with a + * [SQLException], injected at the point the production code prepares it — so the failure lands + * inside the transaction and the rollback path is the code under test (same idiom as the + * outbox store's `dataSourceWithFailures`). + */ + private fun dataSourceFailingOn(fragment: String): DataSource { + val real = dataSource + val connection = { delegate: Connection -> + Proxy.newProxyInstance(Connection::class.java.classLoader, arrayOf(Connection::class.java)) { + _, + connMethod, + connArgs, + -> + val sql = connArgs?.firstOrNull() as? String + if (connMethod.name == "prepareStatement" && sql?.contains(fragment) == true) { + throw SQLException("injected failure at: $fragment") + } + connMethod.invoke(delegate, *(connArgs ?: emptyArray())) + } + } + return Proxy.newProxyInstance(DataSource::class.java.classLoader, arrayOf(DataSource::class.java)) { + _, + method, + args, + -> + if (method.name == "getConnection" && args.isNullOrEmpty()) { + connection(real.connection) + } else { + method.invoke(real, *(args ?: emptyArray())) + } + } as DataSource + } + + private fun insertApproval( + approvalId: String, + workflowRunId: String, + ) { + val metadata = + mapOf( + "binding" to + mapOf( + "workflowRunId" to workflowRunId, + "toolName" to "test-tool", + "argumentsDigest" to "sha256:${"a".repeat(64)}", + "policyVersion" to "1.0.0", + "workflowDigest" to "sha256:${"b".repeat(64)}", + "approvalTokenDigest" to "sha256:${"c".repeat(64)}", + ), + "requestedBy" to "test-user", + "expiresAt" to BASE_NOW.plusSeconds(600).toString(), + "requestedAt" to BASE_NOW.toString(), + ) + val json = mapper.writeValueAsString(metadata) + dataSource.connection.use { conn -> + conn + .prepareStatement( + """ + INSERT INTO approvals (approval_id, status, created_at, sanitized_metadata, version) + VALUES (?, 'PENDING', ?, ?::jsonb, 1) + """.trimIndent(), + ).use { stmt -> + stmt.setString(1, approvalId) + stmt.setTimestamp(2, Timestamp.from(BASE_NOW)) + stmt.setString(3, json) + stmt.executeUpdate() + } + } + } + + private fun selectApprovalStatus(approvalId: String): String = + selectString("SELECT status FROM approvals WHERE approval_id = ?", approvalId) + + private fun selectApprovalVersion(approvalId: String): Long = + dataSource.connection.use { conn -> + conn.prepareStatement("SELECT version FROM approvals WHERE approval_id = ?").use { stmt -> + stmt.setString(1, approvalId) + stmt.executeQuery().use { rs -> + check(rs.next()) + rs.getLong(1) + } + } + } + + private fun outboxStatus(outboxId: String): String = selectString(OUTBOX_STATUS_SQL, outboxId) + + private fun countOutboxRows(): Int = + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.executeQuery("SELECT count(*) FROM audit_outbox").use { rs -> + rs.next() + rs.getInt(1) + } + } + } + + private fun selectString( + sql: String, + value: String, + ): String = + dataSource.connection.use { conn -> + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, value) + stmt.executeQuery().use { rs -> + check(rs.next()) + rs.getString(1) + } + } + } + + private fun truncateTables() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.execute("TRUNCATE TABLE audit_outbox, approvals, suspended_invocations CASCADE") + } + } + } + + private fun migrate() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + val v1 = + javaClass.classLoader + .getResourceAsStream("tramai/persistence/jdbc/postgres/V1__sovereign_persistence.sql") + ?.bufferedReader() + ?.readText() + ?: error("V1 migration not found") + stmt.execute(v1) + val v4 = + javaClass.classLoader + .getResourceAsStream("tramai/persistence/jdbc/postgres/V4__audit_outbox_hardening.sql") + ?.bufferedReader() + ?.readText() + ?: error("V4 migration not found") + runCatching { stmt.execute(v4) } + } + } + } + + private fun identity(runId: String): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId(runId), + ) + + private fun sha256Hex(input: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(input.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + private companion object { + private const val APPROVAL_ID = "approval-governed-1" + private const val OUTBOX_STATUS_SQL = "SELECT status FROM audit_outbox WHERE outbox_id = ?" + private const val RUN_ID = "governed-run-1" + private val BASE_NOW: Instant = Instant.parse("2026-01-01T00:00:00Z") + private val postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("governed_mutation_test") + .withUsername("test") + .withPassword("test") + + private val mapper = ObjectMapper() + + private fun createDataSource(): DataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + } +} + +/** + * Wraps the production payload codec and records every plaintext the store encrypts, so a test can + * assert the shape of each write inside the transaction instead of only the committed row. + */ +private class RecordingOutboxCodec( + key: SecretKey, + private val plaintexts: MutableList, +) : JdbcOpsAuditOutboxPayloadCodec { + private val delegate = DefaultJdbcOpsAuditOutboxPayloadCodec(key, "test-key") + + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload { + plaintexts += plaintext + return delegate.encode(plaintext) + } + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray = delegate.decode(envelope) +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcGovernedSovereignOpsAuditOutboxStoreTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcGovernedSovereignOpsAuditOutboxStoreTest.kt new file mode 100644 index 000000000..3deeaed5b --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcGovernedSovereignOpsAuditOutboxStoreTest.kt @@ -0,0 +1,475 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.persistence.jdbc + +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxGovernance +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import java.security.MessageDigest +import java.security.SecureRandom +import java.time.Duration +import java.time.Instant +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource +import kotlin.test.assertEquals + +/** + * 0.7.1d governed JDBC outbox continuity. + * + * The five proofs this file owns, each observed on the durable row rather than on the returned value: + * + * 1. V2 carries the complete identity, and V1 stays exactly legacy — decoded straight out of + * `encrypted_payload`, so the assertion cannot be satisfied by an in-memory copy. + * 2. Every released transition (ready, claim, failure, completion) retains the identity byte-equal. + * 3. A new store instance over the same database still resolves the same identity. + * 4. Attribution is never promoted into columns and never duplicated: one row, one payload shape. + * 5. Unsupported and malformed payloads fail closed and are never read as legacy. + */ + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JdbcGovernedSovereignOpsAuditOutboxStoreTest { + private lateinit var postgres: PostgreSQLContainer + private lateinit var dataSource: DataSource + private lateinit var store: GovernedJdbcSovereignOpsAuditOutboxStore + + private val testAesKey = ByteArray(16).also { SecureRandom().nextBytes(it) } + + private val testCodec = + object : JdbcOpsAuditOutboxPayloadCodec { + private val algorithm = "AES/GCM/NoPadding" + private val tagLength = 128 + + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload { + val cipher = Cipher.getInstance(algorithm) + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(testAesKey, "AES"), GCMParameterSpec(tagLength, nonce)) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(plaintext) + .joinToString("") { "%02x".format(it) } + return JdbcEncryptedAuditOutboxPayload( + ciphertext = cipher.doFinal(plaintext), + keyId = "governed-test-key-1", + algorithm = algorithm, + nonce = nonce, + payloadDigest = "sha256:$digest", + ) + } + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray { + val cipher = Cipher.getInstance(algorithm) + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(testAesKey, "AES"), + GCMParameterSpec(tagLength, envelope.nonce), + ) + return cipher.doFinal(envelope.ciphertext) + } + } + + @BeforeAll + fun startPostgres() { + postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("governed_outbox") + .withUsername("test") + .withPassword("test") + postgres.start() + dataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + migrate() + } + + @AfterAll + fun stopPostgres() { + postgres.stop() + } + + @BeforeEach + fun setUp() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> stmt.execute("TRUNCATE TABLE audit_outbox CASCADE") } + } + store = governedStore() + } + + // ---------- durable shape ---------- + + @Test + fun `governed append writes V2 into the same single row`() { + runBlocking { + val identity = identity() + val record = record(identity, outboxId = "governed-row") + + store.appendGoverned(GovernedSovereignOpsAuditOutboxRecord(record, identity)) + + assertEquals(1L, rowCount()) + val persisted = persistedRecord("governed-row") + assertEquals(identity, persisted.runIdentity) + assertEquals(record, persisted.record) + assertGoverned(store, "governed-row", identity) + } + } + + @Test + fun `legacy append stays V1 and is never reported as governed`() { + runBlocking { + val record = record(identity(), outboxId = "legacy-row") + store.append(record) + + assertEquals(null, persistedRecord("legacy-row").runIdentity) + val resolved = store.governanceById("legacy-row") + assertThat(resolved).isInstanceOf(SovereignOpsAuditOutboxGovernance.Legacy::class.java) + assertEquals(record, (resolved as SovereignOpsAuditOutboxGovernance.Legacy).record) + + store.markReadyForDispatch("legacy-row", SovereignOpsAuditOutboxStatus.PREPARED) + assertEquals(null, persistedRecord("legacy-row").runIdentity) + assertThat(store.governanceById("legacy-row")) + .isInstanceOf(SovereignOpsAuditOutboxGovernance.Legacy::class.java) + } + } + + @Test + fun `an absent record is NoRecord not Legacy`() { + runBlocking { + assertEquals(SovereignOpsAuditOutboxGovernance.NoRecord, store.governanceById("no-such-row")) + } + } + + @Test + fun `identity survives ready claim failure retry and completion`() { + runBlocking { + val identity = identity() + val record = record(identity, outboxId = "lifecycle") + store.appendGoverned(GovernedSovereignOpsAuditOutboxRecord(record, identity)) + assertGoverned(store, "lifecycle", identity) + + store.markReadyForDispatch("lifecycle", SovereignOpsAuditOutboxStatus.PREPARED) + assertGoverned(store, "lifecycle", identity) + + val claimed = store.claimPending("worker-1", 1, T0).single() + assertEquals(1, claimed.attemptCount) + assertGoverned(store, "lifecycle", identity) + + store.markFailed("lifecycle", SovereignOpsAuditOutboxStatus.EMITTING, 1, "transient", retryable = true) + assertGoverned(store, "lifecycle", identity) + + store.claimPending("worker-2", 1, T0.plusSeconds(3600)).single() + store.markEmitted( + outboxId = "lifecycle", + expectedStatus = SovereignOpsAuditOutboxStatus.EMITTING, + expectedAttemptCount = 2, + emittedAt = T0.plusSeconds(3700), + ) + assertGoverned(store, "lifecycle", identity) + assertEquals(1L, rowCount()) + } + } + + @Test + fun `a new store instance over the same database keeps the identity`() { + runBlocking { + val identity = identity() + store.appendGoverned( + GovernedSovereignOpsAuditOutboxRecord(record(identity, outboxId = "restart"), identity), + ) + store.markReadyForDispatch("restart", SovereignOpsAuditOutboxStatus.PREPARED) + + val reopened = governedStore() + assertGoverned(reopened, "restart", identity) + assertEquals(identity, persistedRecord("restart").runIdentity) + } + } + + @Test + fun `a governed row stays readable through the released store contract`() { + runBlocking { + val identity = identity() + val record = record(identity, outboxId = "released-read") + store.appendGoverned(GovernedSovereignOpsAuditOutboxRecord(record, identity)) + + val released = plainStore() + assertEquals(record, released.get("released-read")) + assertEquals(record, released.findByEventKey(record.eventKey)) + released.markReadyForDispatch("released-read", SovereignOpsAuditOutboxStatus.PREPARED) + assertEquals( + SovereignOpsAuditOutboxStatus.PENDING, + released.get("released-read")?.status, + ) + assertEquals(identity, persistedRecord("released-read").runIdentity) + } + } + + @Test + fun `governed append keeps the released duplicate codes`() { + runBlocking { + val identity = identity() + val record = record(identity, outboxId = "duplicate") + store.appendGoverned(GovernedSovereignOpsAuditOutboxRecord(record, identity)) + + assertThatThrownBy { + runBlocking { + store.appendGoverned( + GovernedSovereignOpsAuditOutboxRecord(record.copy(eventKey = "other"), identity), + ) + } + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("tramai-sovereign-ops-outbox-duplicate-id") + + assertThatThrownBy { + runBlocking { + store.appendGoverned( + GovernedSovereignOpsAuditOutboxRecord( + record(identity, outboxId = "other").copy(eventKey = record.eventKey), + identity, + ), + ) + } + }.isInstanceOf(IllegalArgumentException::class.java) + .hasMessage("tramai-sovereign-ops-outbox-duplicate-event-key") + assertEquals(1L, rowCount()) + } + } + + // ---------- codec fail-closed matrix ---------- + + @Test + fun `unknown schema version is unsupported never legacy`() { + val payload = governedJson() + val bumped = payload.replace("""{"schemaVersion":2,""", """{"schemaVersion":3,""") + assertThat(bumped).isNotEqualTo(payload) + assertThatThrownBy { decodeOutboxRecord(bumped.toByteArray()) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessage("audit-outbox-unsupported-schema-version: 3") + } + + @Test + fun `payload without a declared version is corruption`() { + val payload = governedJson().replace("""{"schemaVersion":2,""", "{") + assertThatThrownBy { decodeOutboxRecord(payload.toByteArray()) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("no declared integral schema version") + } + + @Test + fun `a mistyped declared version is corruption, never a known or unsupported version`() { + val payload = governedJson() + val mistyped = + mapOf( + "text" to "\"2\"", + "float" to "2.5", + "non-numeric text" to "\"two\"", + "null" to "null", + "out of int range" to "99999999999", + ) + mistyped.forEach { (kind, declared) -> + val tampered = payload.replace("""{"schemaVersion":2,""", """{"schemaVersion":$declared,""") + assertThat(tampered).describedAs("%s tamper must change the payload", kind).isNotEqualTo(payload) + assertThatThrownBy { decodeOutboxRecord(tampered.toByteArray()) } + .describedAs("a %s schema version must be corruption", kind) + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining(ERROR_CORRUPTED_RECORD) + } + } + + @Test + fun `malformed payload is corruption`() { + assertThatThrownBy { decodeOutboxRecord("{ not json".toByteArray()) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining(ERROR_CORRUPTED_RECORD) + } + + @Test + fun `partial V2 identity fails closed`() { + val payload = governedJson() + val tampered = payload.replace(""","deploymentId":"dep-1"""", "") + assertThat(tampered).isNotEqualTo(payload) + assertThatThrownBy { decodeOutboxRecord(tampered.toByteArray()) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining(ERROR_CORRUPTED_RECORD) + } + + @Test + fun `V2 identity naming another run fails closed`() { + val payload = governedJson() + val tampered = payload.replace(""""runId":"run-1"""", """"runId":"run-other"""") + assertThat(tampered).isNotEqualTo(payload) + assertThatThrownBy { decodeOutboxRecord(tampered.toByteArray()) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("governed attribution names another run") + } + + @Test + fun `V1 payload carrying a governed identity is corruption`() { + val payload = governedJson().replace("""{"schemaVersion":2,""", """{"schemaVersion":1,""") + assertThatThrownBy { decodeOutboxRecord(payload.toByteArray()) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("legacy payload carries a governed identity") + } + + @Test + fun `legacy record re-encodes as V1 and governed as V2`() { + val identity = identity() + val record = record(identity) + assertEquals(null, decodeOutboxRecord(encodeOutboxRecord(record, null)).runIdentity) + assertEquals(identity, decodeOutboxRecord(encodeOutboxRecord(record, identity)).runIdentity) + } + + // ---------- helpers ---------- + + private fun plainStore(): JdbcSovereignOpsAuditOutboxStore = + JdbcSovereignOpsAuditOutboxStore( + dataSource = dataSource, + payloadCodec = testCodec, + claimLeaseDuration = Duration.ofMinutes(5), + ) + + private fun governedStore(): GovernedOutboxStore = GovernedOutboxStore(plainStore()) + + private suspend fun assertGoverned( + subject: GovernedJdbcSovereignOpsAuditOutboxStore, + outboxId: String, + expected: GovernedRunIdentity, + ) { + val resolved = subject.governanceById(outboxId) + assertThat(resolved).isInstanceOf(SovereignOpsAuditOutboxGovernance.Governed::class.java) + assertEquals(expected, (resolved as SovereignOpsAuditOutboxGovernance.Governed).record.runIdentity) + } + + private fun rowCount(): Long = + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.executeQuery("SELECT count(*) FROM audit_outbox").use { rs -> + rs.next() + rs.getLong(1) + } + } + } + + /** + * Decodes the plaintext of the durable row itself, so the V1/V2 shape claims are checked against + * what is actually stored rather than against a round-tripped domain object. + */ + private fun persistedRecord(outboxId: String): DecodedOutboxRecord = + dataSource.connection.use { conn -> + conn + .prepareStatement( + """SELECT encrypted_payload, encryption_key_id, encryption_algorithm, + encryption_nonce, payload_digest + FROM audit_outbox WHERE outbox_id = ?""", + ).use { stmt -> + stmt.setString(1, outboxId) + stmt.executeQuery().use { rs -> + check(rs.next()) { "no durable row for $outboxId" } + val envelope = + JdbcEncryptedAuditOutboxPayload( + ciphertext = rs.getBytes("encrypted_payload"), + keyId = rs.getString("encryption_key_id"), + algorithm = rs.getString("encryption_algorithm"), + nonce = rs.getBytes("encryption_nonce"), + payloadDigest = rs.getString("payload_digest"), + ) + decodeOutboxRecord(testCodec.decode(envelope)) + } + } + } + + private fun migrate() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + for ( + migration in + listOf( + "V1__sovereign_persistence.sql", + "V4__audit_outbox_hardening.sql", + ) + ) { + val sql = + this::class.java.classLoader + .getResource("tramai/persistence/jdbc/postgres/$migration") + ?.readText() + ?: error("Migration not found: $migration") + stmt.execute(sql) + } + } + } + } + + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256").digest(value.toByteArray()).joinToString("") { "%02x".format(it) } + + private fun identity( + workloadId: String = "wl-1", + runId: String = "run-1", + ): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("cfg-1"), + version = ConfigurationVersion("v1"), + ), + environmentId = EnvironmentId("env-1"), + deploymentId = DeploymentId("dep-1"), + ), + runId = RunId(runId), + ) + + private fun record( + identity: GovernedRunIdentity, + outboxId: String = "outbox-1", + ): SovereignOpsAuditOutboxRecord = + SovereignOpsAuditOutboxRecord( + outboxId = outboxId, + eventKey = "event-$outboxId", + aggregateIdDigest = sha256("aggregate"), + actor = "tester", + workflowRunId = identity.runId.value, + correlationId = null, + approvalStatus = "DENIED", + approvalVersion = 1L, + reasonDigest = sha256("reason"), + reasonLength = 6, + createdAt = T0, + ) + + private fun governedJson(): String = String(encodeOutboxRecord(record(identity()), identity())) + + private companion object { + private val T0: Instant = Instant.parse("2026-09-14T10:00:00Z") + } +} + +// Local alias for the governed wrapper: the class name plus a signature would otherwise push the +// one-line helper definitions past the analyzer's line limit. +private typealias GovernedOutboxStore = GovernedJdbcSovereignOpsAuditOutboxStore diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStoreTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStoreTest.kt index cbf93a7ab..54b2f9b1b 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStoreTest.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestMutationStoreTest.kt @@ -1,5 +1,6 @@ package dev.tramai.spring.sovereign.persistence.jdbc +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule @@ -11,23 +12,37 @@ import dev.tramai.core.approval.ApprovalRequest import dev.tramai.core.approval.ApprovalStatus import dev.tramai.core.approval.SensitiveToolArguments import dev.tramai.core.approval.Sha256Digest -import dev.tramai.core.approval.gateway.ResumeToken -import dev.tramai.core.approval.gateway.ApproverRole import dev.tramai.core.approval.gateway.ApprovalId import dev.tramai.core.approval.gateway.ApprovalResumeCredentialRecord +import dev.tramai.core.approval.gateway.ApproverRole +import dev.tramai.core.approval.gateway.ResumeToken import dev.tramai.core.approval.gateway.SealedResumeToken import dev.tramai.core.approval.gateway.WorkflowRunId +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId import dev.tramai.core.model.Message import dev.tramai.core.model.MessageRole import dev.tramai.core.model.ToolCall import dev.tramai.engine.EngineExecutionIdentity import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocation import dev.tramai.engine.ReplayEnvelopeDigestHelper import dev.tramai.engine.ResumeOperationReference import dev.tramai.engine.ResumeToolReference import dev.tramai.engine.SensitiveReplayEnvelope import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.approval.ApprovalAttributionCorruptionException +import dev.tramai.engine.approval.ApprovalAttributionKeys import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.persistence.jdbc.GovernedJdbcSuspendedInvocationStore import dev.tramai.persistence.jdbc.JdbcApprovalContinuationStore import dev.tramai.persistence.jdbc.JdbcApprovalStore import dev.tramai.persistence.jdbc.JdbcContinuationArgumentsCodec @@ -40,6 +55,9 @@ import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutatio import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -50,12 +68,19 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.postgresql.ds.PGSimpleDataSource import org.testcontainers.containers.PostgreSQLContainer +import java.lang.reflect.Method +import java.lang.reflect.Proxy import java.security.MessageDigest import java.security.SecureRandom +import java.sql.Connection +import java.sql.PreparedStatement +import java.sql.SQLException import java.time.Clock import java.time.Instant import java.time.ZoneOffset import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec @@ -64,29 +89,31 @@ import javax.sql.DataSource @TestInstance(TestInstance.Lifecycle.PER_CLASS) class JdbcSovereignOpsApprovalRequestMutationStoreTest { - companion object { private const val POSTGRES_IMAGE = "postgres:17-alpine" - private val postgres = PostgreSQLContainer(POSTGRES_IMAGE) - .withDatabaseName("sovereign_ops_request_mutation_test") - .withUsername("test") - .withPassword("test") - - private fun createDataSource(): DataSource = PGSimpleDataSource().apply { - setUrl(postgres.jdbcUrl) - user = postgres.username - password = postgres.password - } + private val postgres = + PostgreSQLContainer(POSTGRES_IMAGE) + .withDatabaseName("sovereign_ops_request_mutation_test") + .withUsername("test") + .withPassword("test") + + private fun createDataSource(): DataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } private val BASE_NOW: Instant = Instant.parse("2026-01-01T00:00:00Z") } private val testAesKey = ByteArray(32).also { SecureRandom().nextBytes(it) } private val testSecretKey: SecretKey = SecretKeySpec(testAesKey, "AES") - private val mapper: ObjectMapper = ObjectMapper() - .registerKotlinModule() - .registerModule(JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + private val mapper: ObjectMapper = + ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) private lateinit var dataSource: DataSource private lateinit var replayCodec: JdbcReplayEnvelopeCodec @@ -115,416 +142,234 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { replayCodec = testReplayCodec() continuationCodec = testContinuationCodec() outboxCodec = testOutboxCodec() - mutationStore = JdbcSovereignOpsApprovalRequestMutationStore( - dataSource = dataSource, - replayEnvelopeCodec = replayCodec, - continuationArgumentsCodec = continuationCodec, - outboxPayloadCodec = outboxCodec, - encryptionKey = testSecretKey, - encryptionKeyId = "test-key-1", - clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), - ) - approvalStore = JdbcApprovalStore( - dataSource = dataSource, - clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), - ) - suspendedInvocationStore = JdbcSuspendedInvocationStore( - dataSource = dataSource, - replayEnvelopeCodec = replayCodec, - clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), - ) - continuationStore = JdbcApprovalContinuationStore( - dataSource = dataSource, - argumentsCodec = continuationCodec, - clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), - ) + mutationStore = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + approvalStore = + JdbcApprovalStore( + dataSource = dataSource, + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + suspendedInvocationStore = + JdbcSuspendedInvocationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + continuationStore = + JdbcApprovalContinuationStore( + dataSource = dataSource, + argumentsCodec = continuationCodec, + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) } @Test - fun `create approval request persists approval suspended invocation and continuation atomically`() { runBlocking { - val request = request("approval-a") - - val result = mutationStore.createApprovalRequest(request) - - assertThat(result).isEqualTo( - SovereignOpsApprovalRequestMutationResult.Created( - approvalId = "approval-a", - correlationId = "corr-approval-a", - resumeToken = ResumeToken("resume-approval-a"), - ), - ) - - val approval = approvalStore.get("approval-a") - assertThat(approval).isNotNull - assertThat(approval!!.status).isEqualTo(ApprovalStatus.PENDING) + fun `create approval request persists approval suspended invocation and continuation atomically`() { + runBlocking { + val request = request("approval-a") - val suspended = suspendedInvocationStore.get("approval-a") - assertThat(suspended).isNotNull - assertThat(suspended!!.correlationId).isEqualTo("corr-approval-a") + val result = mutationStore.createApprovalRequest(request) - val replayEnvelope = suspendedInvocationStore.revealReplayEnvelope("approval-a") - assertThat(replayEnvelope).isNotNull - assertThat(replayEnvelope!!.revealForResume().messages) - .extracting { it.content } - .containsExactly("request-approval-a", "") + assertThat(result).isEqualTo( + SovereignOpsApprovalRequestMutationResult.Created( + approvalId = "approval-a", + correlationId = "corr-approval-a", + resumeToken = ResumeToken("resume-approval-a"), + ), + ) - val continuation = continuationStore.get("approval-a") - assertThat(continuation).isNotNull - assertThat(continuation!!.status).isEqualTo(ApprovalContinuationStatus.PENDING) - assertThat(selectCount("SELECT count(*) FROM audit_outbox")).isZero() - } - } + val approval = approvalStore.get("approval-a") + assertThat(approval).isNotNull + assertThat(approval!!.status).isEqualTo(ApprovalStatus.PENDING) - @Test - fun `create approval request with audit intent also creates pending outbox record`() { runBlocking { - val request = request("approval-b") - val auditIntent = auditIntent("approval-b", "event-key-b") + val suspended = suspendedInvocationStore.get("approval-a") + assertThat(suspended).isNotNull + assertThat(suspended!!.correlationId).isEqualTo("corr-approval-a") - mutationStore.createApprovalRequest(request, auditIntent) + val replayEnvelope = suspendedInvocationStore.revealReplayEnvelope("approval-a") + assertThat(replayEnvelope).isNotNull + assertThat(replayEnvelope!!.revealForResume().messages) + .extracting { it.content } + .containsExactly("request-approval-a", "") - assertThat(selectValue("SELECT status FROM audit_outbox WHERE outbox_id = ?", auditIntent.outboxId)) - .isEqualTo("PENDING") - assertThat(selectCount("SELECT count(*) FROM audit_outbox WHERE event_key = 'event-key-b'")).isEqualTo(1) - } + val continuation = continuationStore.get("approval-a") + assertThat(continuation).isNotNull + assertThat(continuation!!.status).isEqualTo(ApprovalContinuationStatus.PENDING) + assertThat(selectCount("SELECT count(*) FROM audit_outbox")).isZero() + } } @Test - fun `duplicate approval request returns existing`() { runBlocking { - val request = request("approval-c") + fun `create approval request with audit intent also creates pending outbox record`() { + runBlocking { + val request = request("approval-b") + val auditIntent = auditIntent("approval-b", "event-key-b") - val created = mutationStore.createApprovalRequest(request) - val duplicate = mutationStore.createApprovalRequest(request) + mutationStore.createApprovalRequest(request, auditIntent) - assertThat(created).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Created::class.java) - assertThat(duplicate).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Existing::class.java) - val existing = duplicate as SovereignOpsApprovalRequestMutationResult.Existing - assertThat(existing.approval.approvalId).isEqualTo("approval-c") - assertThat(selectCount("SELECT count(*) FROM approvals WHERE approval_id = 'approval-c'")).isEqualTo(1) - } + assertThat(selectValue("SELECT status FROM audit_outbox WHERE outbox_id = ?", auditIntent.outboxId)) + .isEqualTo("PENDING") + assertThat(selectCount("SELECT count(*) FROM audit_outbox WHERE event_key = 'event-key-b'")).isEqualTo(1) + } } @Test - fun `constraint failure rolls back partial approval request creation`() { runBlocking { - val request = request("approval-d") - suspendedInvocationStore.create( - metadata = request("existing-conflict").suspendedInvocationMetadata.copy( - toolCallId = request.suspendedInvocationMetadata.toolCallId, - toolName = request.suspendedInvocationMetadata.toolName, - replayEnvelopeDigest = request.suspendedInvocationMetadata.replayEnvelopeDigest, - ), - replayEnvelope = request.replayEnvelope, - ) + fun `duplicate approval request returns existing`() { + runBlocking { + val request = request("approval-c") - assertThatThrownBy { - runBlocking { - mutationStore.createApprovalRequest(request) - } - }.isInstanceOf(IllegalStateException::class.java) - .hasMessageContaining("tramai-sovereign-ops-approval-request-mutation-database-failure") + val created = mutationStore.createApprovalRequest(request) + val duplicate = mutationStore.createApprovalRequest(request) - assertThat(approvalStore.get("approval-d")).isNull() - assertThat(suspendedInvocationStore.get("approval-d")).isNull() - assertThat(continuationStore.get("approval-d")).isNull() - assertThat(selectCount("SELECT count(*) FROM audit_outbox")).isZero() - } + assertThat(created).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Created::class.java) + assertThat(duplicate).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Existing::class.java) + val existing = duplicate as SovereignOpsApprovalRequestMutationResult.Existing + assertThat(existing.approval.approvalId).isEqualTo("approval-c") + assertThat(selectCount("SELECT count(*) FROM approvals WHERE approval_id = 'approval-c'")).isEqualTo(1) + } } @Test - fun `cancellation exception is rethrown and transaction rolls back`() { runBlocking { - val request = request("approval-e") - val cancellingStore = JdbcSovereignOpsApprovalRequestMutationStore( - dataSource = dataSource, - replayEnvelopeCodec = object : JdbcReplayEnvelopeCodec { - override fun encode(plaintext: ByteArray): JdbcEncryptedReplayEnvelope { - throw CancellationException("cancelled") + fun `audit outbox failure rolls back approval request creation`() { + runBlocking { + val request = request("approval-j") + val auditIntent = auditIntent("approval-j", "outbox-failure-test") + val failingCodec = + object : JdbcOpsAuditOutboxPayloadCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload = + throw IllegalStateException("simulated-outbox-codec-failure") + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray = unsupported() } + val storeWithFailingOutbox = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = failingCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) - override fun decode(envelope: JdbcEncryptedReplayEnvelope): ByteArray = envelope.ciphertext - }, - continuationArgumentsCodec = continuationCodec, - outboxPayloadCodec = outboxCodec, - encryptionKey = testSecretKey, - encryptionKeyId = "test-key-1", - clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), - ) - - assertThatThrownBy { - runBlocking { - cancellingStore.createApprovalRequest(request) - } - }.isInstanceOf(CancellationException::class.java) - - assertThat(approvalStore.get("approval-e")).isNull() - assertThat(suspendedInvocationStore.get("approval-e")).isNull() - assertThat(continuationStore.get("approval-e")).isNull() - } - } - - @Test - fun `rejects replay envelope digest mismatch and rolls back all records`() { runBlocking { - val request = request("approval-f").copy( - suspendedInvocationMetadata = request("approval-f").suspendedInvocationMetadata.copy( - replayEnvelopeDigest = Sha256Digest.of("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), - ), - ) - - assertThatThrownBy { - runBlocking { - mutationStore.createApprovalRequest(request) - } - }.isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("replay-envelope-digest-mismatch") - - assertThat(approvalStore.get("approval-f")).isNull() - assertThat(suspendedInvocationStore.get("approval-f")).isNull() - assertThat(continuationStore.get("approval-f")).isNull() - assertThat(selectCount("SELECT count(*) FROM audit_outbox")).isZero() - } - } - - @Test - fun `rejects already expired approval request and rolls back all records`() { runBlocking { - val now = BASE_NOW.plusSeconds(30) - val clockAtNow = Clock.fixed(now, ZoneOffset.UTC) - val storeWithExpiryCheck = JdbcSovereignOpsApprovalRequestMutationStore( - dataSource = dataSource, - replayEnvelopeCodec = replayCodec, - continuationArgumentsCodec = continuationCodec, - outboxPayloadCodec = outboxCodec, - encryptionKey = testSecretKey, - encryptionKeyId = "test-key-1", - clock = clockAtNow, - ) - val request = request("approval-g").copy( - approvalRequest = request("approval-g").approvalRequest.copy( - expiresAt = now.minusSeconds(1), - ), - ) - - assertThatThrownBy { - runBlocking { - storeWithExpiryCheck.createApprovalRequest(request) - } - }.isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("approval-request-expired-at-creation") - - assertThat(approvalStore.get("approval-g")).isNull() - assertThat(suspendedInvocationStore.get("approval-g")).isNull() - assertThat(continuationStore.get("approval-g")).isNull() - } - } - - @Test - fun `rejects invalid continuation metadata and rolls back all records`() { runBlocking { - val request = request("approval-h").copy( - continuation = request("approval-h").continuation.copy( - workflowRunId = " ", - ), - ) - - assertThatThrownBy { - runBlocking { - mutationStore.createApprovalRequest(request) - } - }.isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("continuation.workflowRunId") - - assertThat(approvalStore.get("approval-h")).isNull() - assertThat(suspendedInvocationStore.get("approval-h")).isNull() - assertThat(continuationStore.get("approval-h")).isNull() - } - } - - @Test - fun `rejects future continuation createdAt and rolls back all records`() { runBlocking { - val now = BASE_NOW.plusSeconds(30) - val clockAtNow = Clock.fixed(now, ZoneOffset.UTC) - val storeWithTimeCheck = JdbcSovereignOpsApprovalRequestMutationStore( - dataSource = dataSource, - replayEnvelopeCodec = replayCodec, - continuationArgumentsCodec = continuationCodec, - outboxPayloadCodec = outboxCodec, - encryptionKey = testSecretKey, - encryptionKeyId = "test-key-1", - clock = clockAtNow, - ) - val request = request("approval-i").copy( - continuation = request("approval-i").continuation.copy( - createdAt = now.plusSeconds(60), - ), - ) - - assertThatThrownBy { - runBlocking { - storeWithTimeCheck.createApprovalRequest(request) - } - }.isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("continuation-created-at-in-future") - - assertThat(approvalStore.get("approval-i")).isNull() - assertThat(suspendedInvocationStore.get("approval-i")).isNull() - assertThat(continuationStore.get("approval-i")).isNull() - } - } - - @Test - fun `audit outbox failure rolls back approval request creation`() { runBlocking { - val request = request("approval-j") - val auditIntent = auditIntent("approval-j", "outbox-failure-test") - val failingCodec = object : JdbcOpsAuditOutboxPayloadCodec { - override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload = - throw IllegalStateException("simulated-outbox-codec-failure") - override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray = - throw UnsupportedOperationException() + val thrown = + assertThatSuspendCallThrows { + storeWithFailingOutbox.createApprovalRequest(request, auditIntent) + } + thrown + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("simulated-outbox-codec-failure") + + assertThat(approvalStore.get("approval-j")).isNull() + assertThat(suspendedInvocationStore.get("approval-j")).isNull() + assertThat(continuationStore.get("approval-j")).isNull() + val outboxCount = + selectCount("SELECT count(*) FROM audit_outbox WHERE event_key = 'outbox-failure-test'") + assertThat(outboxCount).isZero() } - val storeWithFailingOutbox = JdbcSovereignOpsApprovalRequestMutationStore( - dataSource = dataSource, - replayEnvelopeCodec = replayCodec, - continuationArgumentsCodec = continuationCodec, - outboxPayloadCodec = failingCodec, - encryptionKey = testSecretKey, - encryptionKeyId = "test-key-1", - clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), - ) - - assertThatThrownBy { - runBlocking { - storeWithFailingOutbox.createApprovalRequest(request, auditIntent) - } - }.isInstanceOf(IllegalStateException::class.java) - .hasMessageContaining("simulated-outbox-codec-failure") - - assertThat(approvalStore.get("approval-j")).isNull() - assertThat(suspendedInvocationStore.get("approval-j")).isNull() - assertThat(continuationStore.get("approval-j")).isNull() - assertThat(selectCount("SELECT count(*) FROM audit_outbox WHERE event_key = 'outbox-failure-test'")).isZero() - } } - // ── Inbox metadata tests ────────────────────────────────────────── + // ── Inbox metadata tests ── @Test - fun `approval request creation persists inbox metadata atomically`() { runBlocking { - val request = request("approval-inbox-1") - val metadata = ApprovalInboxMetadata( - requiredRole = ApproverRole("medical-reviewer"), - riskLevel = "HIGH", - subjectType = "claim", - subjectId = "claim-123", - recommendationType = "claim-payout", - ) - - val result = mutationStore.createApprovalRequest(request, inboxMetadata = metadata) - - assertThat(result).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Created::class.java) - - // Read sanitized_metadata JSON from DB and verify inbox fields via parsed tree - val rawJson = selectValue( - "SELECT sanitized_metadata::text FROM approvals WHERE approval_id = ?", - "approval-inbox-1", - )!! - val root = mapper.readTree(rawJson) - val inbox = root["inbox"] - assertThat(inbox).isNotNull - assertThat(inbox["requiredRole"].asText()).isEqualTo("medical-reviewer") - assertThat(inbox["riskLevel"].asText()).isEqualTo("HIGH") - assertThat(inbox["subjectType"].asText()).isEqualTo("claim") - assertThat(inbox["subjectId"].asText()).isEqualTo("claim-123") - assertThat(inbox["recommendationType"].asText()).isEqualTo("claim-payout") - - // Verify inbox does not contain sensitive binding fields (they live under binding) - assertThat(inbox.has("argumentsDigest")).isFalse() - assertThat(inbox.has("approvalTokenDigest")).isFalse() - } - } - + fun `approval request creation persists inbox metadata atomically`() { + runBlocking { + val request = request("approval-inbox-1") + val metadata = + ApprovalInboxMetadata( + requiredRole = ApproverRole("medical-reviewer"), + riskLevel = "HIGH", + subjectType = "claim", + subjectId = "claim-123", + recommendationType = "claim-payout", + ) - @Test - fun `rollback removes inbox metadata when continuation insert fails`() { runBlocking { - val request = request("approval-inbox-2") - val metadata = ApprovalInboxMetadata( - requiredRole = ApproverRole("medical-reviewer"), - riskLevel = "HIGH", - subjectType = "claim", - subjectId = "claim-456", - recommendationType = "claim-payout", - ) - // Use a failing continuation arguments codec to trigger rollback - val failingCodec = object : JdbcContinuationArgumentsCodec { - override fun encode(plaintext: ByteArray): JdbcEncryptedContinuationArguments = - throw RuntimeException("simulated-codec-failure") - override fun decode(envelope: JdbcEncryptedContinuationArguments): ByteArray = - throw RuntimeException("simulated-codec-failure") + val result = mutationStore.createApprovalRequest(request, inboxMetadata = metadata) + + assertThat(result).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Created::class.java) + + // Read sanitized_metadata JSON from DB and verify inbox fields via parsed tree + val rawJson = + selectValue( + "SELECT sanitized_metadata::text FROM approvals WHERE approval_id = ?", + "approval-inbox-1", + )!! + val root = mapper.readTree(rawJson) + val inbox = root["inbox"] + assertThat(inbox).isNotNull + assertThat(inbox["requiredRole"].asText()).isEqualTo("medical-reviewer") + assertThat(inbox["riskLevel"].asText()).isEqualTo("HIGH") + assertThat(inbox["subjectType"].asText()).isEqualTo("claim") + assertThat(inbox["subjectId"].asText()).isEqualTo("claim-123") + assertThat(inbox["recommendationType"].asText()).isEqualTo("claim-payout") + + // Verify inbox does not contain sensitive binding fields (they live under binding) + assertThat(inbox.has("argumentsDigest")).isFalse() + assertThat(inbox.has("approvalTokenDigest")).isFalse() } - val storeWithFailingCodec = JdbcSovereignOpsApprovalRequestMutationStore( - dataSource = dataSource, - replayEnvelopeCodec = replayCodec, - continuationArgumentsCodec = failingCodec, - outboxPayloadCodec = outboxCodec, - encryptionKey = testSecretKey, - encryptionKeyId = "test-key-1", - clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), - ) - - assertThatThrownBy { - runBlocking { - storeWithFailingCodec.createApprovalRequest(request, inboxMetadata = metadata) - } - }.isInstanceOf(RuntimeException::class.java) - - // Verify approval was rolled back (no row) - assertThat(approvalStore.get("approval-inbox-2")).isNull() - } } @Test - fun `existing approval replay returns existing metadata safely without inbox changes`() { runBlocking { - val request = request("approval-inbox-3") - val metadata = ApprovalInboxMetadata( - requiredRole = ApproverRole("medical-reviewer"), - riskLevel = "HIGH", - subjectType = "claim", - subjectId = "claim-789", - recommendationType = "claim-payout", - ) + fun `existing approval replay returns existing metadata safely without inbox changes`() { + runBlocking { + val request = request("approval-inbox-3") + val metadata = + ApprovalInboxMetadata( + requiredRole = ApproverRole("medical-reviewer"), + riskLevel = "HIGH", + subjectType = "claim", + subjectId = "claim-789", + recommendationType = "claim-payout", + ) - // First create with metadata - mutationStore.createApprovalRequest(request, inboxMetadata = metadata) + // First create with metadata + mutationStore.createApprovalRequest(request, inboxMetadata = metadata) - // Second call — should return Existing, not Created - val result = mutationStore.createApprovalRequest(request, inboxMetadata = metadata) + // Second call — should return Existing, not Created + val result = mutationStore.createApprovalRequest(request, inboxMetadata = metadata) - assertThat(result).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Existing::class.java) - val existing = result as SovereignOpsApprovalRequestMutationResult.Existing - assertThat(existing.approval.approvalId).isEqualTo("approval-inbox-3") + assertThat(result).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Existing::class.java) + val existing = result as SovereignOpsApprovalRequestMutationResult.Existing + assertThat(existing.approval.approvalId).isEqualTo("approval-inbox-3") - // Only one row exists - val count = selectCount("SELECT count(*) FROM approvals WHERE approval_id = 'approval-inbox-3'") - assertThat(count).isEqualTo(1) - } + // Only one row exists + val count = selectCount("SELECT count(*) FROM approvals WHERE approval_id = 'approval-inbox-3'") + assertThat(count).isEqualTo(1) + } } @Test fun `transactional resume credential uses configured encryption key id`() { runBlocking { val request = request("cred-key-id-test") - val metadata = ApprovalInboxMetadata( - requiredRole = ApproverRole("medical-reviewer"), - riskLevel = "HIGH", - subjectType = "claim", - subjectId = "claim-key-id", - recommendationType = "claim-payout", - ) - val resumeCredential = ApprovalResumeCredentialRecord( - approvalId = ApprovalId("cred-key-id-test"), - workflowRunId = WorkflowRunId("wf-cred-key-id-test"), - resumeToken = SealedResumeToken.seal( - ResumeToken("test-resume-token"), - ), - createdAt = BASE_NOW, - expiresAt = BASE_NOW.plusSeconds(600), - version = 1L, - ) + val metadata = + ApprovalInboxMetadata( + requiredRole = ApproverRole("medical-reviewer"), + riskLevel = "HIGH", + subjectType = "claim", + subjectId = "claim-key-id", + recommendationType = "claim-payout", + ) + val resumeCredential = + ApprovalResumeCredentialRecord( + approvalId = ApprovalId("cred-key-id-test"), + workflowRunId = WorkflowRunId("wf-cred-key-id-test"), + resumeToken = + SealedResumeToken.seal( + ResumeToken("test-resume-token"), + ), + createdAt = BASE_NOW, + expiresAt = BASE_NOW.plusSeconds(600), + version = 1L, + ) mutationStore.createApprovalRequest( request = request, @@ -533,10 +378,11 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { ) // Read encryption_key_id from the resume credentials table - val keyId = selectValue( - "SELECT encryption_key_id FROM tramai_approval_resume_credentials WHERE approval_id = ?", - "cred-key-id-test", - ) + val keyId = + selectValue( + "SELECT encryption_key_id FROM tramai_approval_resume_credentials WHERE approval_id = ?", + "cred-key-id-test", + ) assertThat(keyId).isEqualTo("test-key-1") assertThat(keyId).isNotEqualTo("default") @@ -549,87 +395,97 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { val workflowDigest = digest('b') val argumentsDigest = digest('a') val approvalTokenDigest = digest('c') - val messages = listOf( - Message(role = MessageRole.USER, content = "request-$approvalId"), - Message( - role = MessageRole.ASSISTANT, - content = "", - toolCalls = listOf( - ToolCall( - id = "tool-call-$approvalId", - name = "tool-$approvalId", - argumentsJson = "__redacted_approval_continuation_args__", - ), + val messages = + listOf( + Message(role = MessageRole.USER, content = "request-$approvalId"), + Message( + role = MessageRole.ASSISTANT, + content = "", + toolCalls = + listOf( + ToolCall( + id = "tool-call-$approvalId", + name = "tool-$approvalId", + argumentsJson = "__redacted_approval_continuation_args__", + ), + ), ), - ), - ) + ) val operationReference = ResumeOperationReference("t.Service", "approve", "(Ljava/lang/String;)V", digest('d')) val replayDigest = ReplayEnvelopeDigestHelper.compute(operationReference, messages) return ApprovalGatewayPersistenceRequest( - approvalRequest = ApprovalRequest( - approvalId = approvalId, - binding = ApprovalBinding( + approvalRequest = + ApprovalRequest( + approvalId = approvalId, + binding = + ApprovalBinding( + workflowRunId = "wf-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = argumentsDigest, + policyVersion = "policy-v1", + workflowDigest = workflowDigest, + approvalTokenDigest = approvalTokenDigest, + ), + status = ApprovalStatus.PENDING, + requestedBy = "requestor-$approvalId", + requestedAt = requestedAt, + expiresAt = expiresAt, + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ), + continuation = + ApprovalContinuation( + approvalId = approvalId, workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + toolCallId = "tool-call-$approvalId", toolName = "tool-$approvalId", argumentsDigest = argumentsDigest, policyVersion = "policy-v1", workflowDigest = workflowDigest, - approvalTokenDigest = approvalTokenDigest, + status = ApprovalContinuationStatus.PENDING, + createdAt = requestedAt, + approvalExpiresAt = expiresAt, + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, ), - status = ApprovalStatus.PENDING, - requestedBy = "requestor-$approvalId", - requestedAt = requestedAt, - expiresAt = expiresAt, - decidedBy = null, - decidedAt = null, - decisionComment = null, - consumedBy = null, - consumedAt = null, - version = 0L, - ), - continuation = ApprovalContinuation( - approvalId = approvalId, - workflowRunId = "wf-$approvalId", - correlationId = "corr-$approvalId", - toolCallId = "tool-call-$approvalId", - toolName = "tool-$approvalId", - argumentsDigest = argumentsDigest, - policyVersion = "policy-v1", - workflowDigest = workflowDigest, - status = ApprovalContinuationStatus.PENDING, - createdAt = requestedAt, - approvalExpiresAt = expiresAt, - claimedBy = null, - claimedAt = null, - completedAt = null, - version = 0L, - ), sensitiveArguments = SensitiveToolArguments.of("""{"claimId":"$approvalId"}"""), - suspendedInvocationMetadata = SuspendedInvocationMetadata( - approvalId = approvalId, - toolCallId = "tool-call-$approvalId", - toolName = "tool-$approvalId", - toolCallIndex = 0, - correlationId = "corr-$approvalId", - identity = EngineExecutionIdentity( - workflowRunId = "wf-$approvalId", + suspendedInvocationMetadata = + SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + toolCallIndex = 0, correlationId = "corr-$approvalId", - workflowDigest = workflowDigest, - policyVersion = "policy-v1", - actorId = "requestor-$approvalId", + identity = + EngineExecutionIdentity( + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + workflowDigest = workflowDigest, + policyVersion = "policy-v1", + actorId = "requestor-$approvalId", + ), + securityContext = ExecutionSecurityContext(), + operationReference = operationReference, + replayEnvelopeDigest = replayDigest, + toolReference = ResumeToolReference("tool-$approvalId", digest('e')), ), - securityContext = ExecutionSecurityContext(), - operationReference = operationReference, - replayEnvelopeDigest = replayDigest, - toolReference = ResumeToolReference("tool-$approvalId", digest('e')), - ), replayEnvelope = SensitiveReplayEnvelope.of(messages), resumeToken = ResumeToken("resume-$approvalId"), ) } - private fun auditIntent(approvalId: String, eventKey: String): SovereignOpsAuditOutboxRecord = + private fun auditIntent( + approvalId: String, + eventKey: String, + ): SovereignOpsAuditOutboxRecord = SovereignOpsAuditOutboxRecord( outboxId = UUID.randomUUID().toString(), aggregateIdDigest = sha256Hex(approvalId), @@ -709,9 +565,11 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { val spec = GCMParameterSpec(128, nonce) cipher.init(Cipher.ENCRYPT_MODE, keySpec, spec) val ciphertext = cipher.doFinal(plaintext) - val digest = MessageDigest.getInstance("SHA-256") - .digest(plaintext) - .joinToString("") { "%02x".format(it) } + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(plaintext) + .joinToString("") { "%02x".format(it) } return JdbcEncryptedAuditOutboxPayload( ciphertext = ciphertext, keyId = "test-key-1", @@ -721,7 +579,11 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { ) } - private fun decrypt(ciphertext: ByteArray, nonce: ByteArray, algorithm: String): ByteArray { + private fun decrypt( + ciphertext: ByteArray, + nonce: ByteArray, + algorithm: String, + ): ByteArray { val cipher = Cipher.getInstance(algorithm) val keySpec = SecretKeySpec(testAesKey, "AES") val spec = GCMParameterSpec(128, nonce) @@ -732,7 +594,9 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { private fun truncateTables() { dataSource.connection.use { conn -> conn.createStatement().use { stmt -> - stmt.execute("TRUNCATE TABLE approval_continuations, suspended_invocations, audit_outbox, approvals CASCADE") + val truncateAll = + "TRUNCATE TABLE approval_continuations, suspended_invocations, audit_outbox, approvals CASCADE" + stmt.execute(truncateAll) } } } @@ -746,17 +610,215 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { "tramai/persistence/jdbc/postgres/V4__audit_outbox_hardening.sql", "tramai/persistence/jdbc/postgres/V6__approval_resume_credential_custody.sql", ).forEach { resource -> - val sql = javaClass.classLoader - .getResourceAsStream(resource) - ?.bufferedReader() - ?.readText() - ?: error("Migration not found: $resource") + val sql = + javaClass.classLoader + .getResourceAsStream(resource) + ?.bufferedReader() + ?.readText() + ?: error("Migration not found: $resource") stmt.execute(sql) } } } } + /** + * The failure the caller observes must be the failure the operation actually suffered. Each test + * below pins one of those ownership decisions: a cleanup failure is attached to the primary, and + * a recovered primary-key race has no primary left to attach anything to. + */ + @Test + fun `cancellation stays primary when rollback fails`() { + val cancellation = CancellationException("test-cancellation") + val rollbackFailure = SQLException("test-rollback-failure") + val wrapper = FaultInjectingDataSource(dataSource, failRollbackWith = rollbackFailure) + val store = storeWith(wrapper, replayCodec = replayCodecThrowing(cancellation)) + + val thrown = runCatchingSuspend { store.createApprovalRequest(request("cancel-rollback"), null) } + + assertThat(thrown).isSameAs(cancellation) + assertThat(thrown!!.suppressed.toList()).contains(rollbackFailure) + assertThat(wrapper.rollbackCalls).isGreaterThan(0) + } + + @Test + fun `cancellation stays primary when restoring autoCommit fails`() { + val cancellation = CancellationException("test-cancellation") + val restoreFailure = SQLException("test-restore-failure") + val wrapper = FaultInjectingDataSource(dataSource, failRestoreWith = restoreFailure) + val store = storeWith(wrapper, replayCodec = replayCodecThrowing(cancellation)) + + val thrown = runCatchingSuspend { store.createApprovalRequest(request("cancel-restore"), null) } + + assertThat(thrown).isSameAs(cancellation) + assertThat(thrown!!.suppressed.toList()).contains(restoreFailure) + assertThat(wrapper.rollbackCalls).isGreaterThan(0) + } + + @Test + fun `recovered primary key race leaves no primary for a restore failure`() { + val duplicateKey = + SQLException( + "duplicate key value violates unique constraint \"approvals_pkey\"", + "23505", + ) + val restoreFailure = SQLException("test-restore-after-recovery") + // The loser connection is the one that receives the simulated duplicate key and the failing + // restore; the winner is committed through the raw data source, so recovery re-reads a real row. + val wrapper = + FaultInjectingDataSource( + delegate = dataSource, + failRestoreWith = restoreFailure, + duplicateKeyOnApprovalInsert = duplicateKey, + winnerStore = { runBlocking { mutationStore.createApprovalRequest(request("pk-race-restore"), null) } }, + ) + val store = storeWith(wrapper, replayCodec = replayCodec) + + val thrown = + runCatchingSuspend { + store.createApprovalRequest(request("pk-race-restore"), null) + } + + assertThat(wrapper.insertInterceptions).isEqualTo(1) + assertThat(thrown).isSameAs(restoreFailure) + assertThat(thrown).isNotInstanceOf(IllegalStateException::class.java) + assertThat(thrown!!.message).doesNotContain("tramai-sovereign-ops-approval-request-mutation") + } + + /** Builds a store over [source] with the given codecs, mirroring the fixture's construction. */ + private fun storeWith( + source: DataSource, + replayCodec: JdbcReplayEnvelopeCodec, + ): JdbcSovereignOpsApprovalRequestMutationStore = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = source, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + + /** The real codec, except that encoding — which runs after the approval insert — throws [failure]. */ + private fun replayCodecThrowing(failure: Throwable): JdbcReplayEnvelopeCodec = + object : JdbcReplayEnvelopeCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedReplayEnvelope = throw failure + + override fun decode(envelope: JdbcEncryptedReplayEnvelope): ByteArray = testReplayCodec().decode(envelope) + } + + /** Returns the exception a suspending call threw, or null when it returned normally. */ + private fun runCatchingSuspend(block: suspend () -> Unit): Throwable? = + try { + runBlocking { block() } + null + } catch (e: Throwable) { + e + } + + /** + * Delegates to the real PostgreSQL data source and injects exactly one fault on the first + * connection it hands out: a failing rollback, a failing autoCommit restore, or a duplicate-key + * failure on the approval insert. Later acquisitions (recovery's own re-read, and the committed + * winner) stay raw, so production's catch and recovery paths run for real. + */ + private class FaultInjectingDataSource( + private val delegate: DataSource, + private val failRollbackWith: SQLException? = null, + private val failRestoreWith: SQLException? = null, + private val duplicateKeyOnApprovalInsert: SQLException? = null, + private val winnerStore: (() -> Unit)? = null, + ) : DataSource { + private val acquisitions = AtomicInteger() + private var autoCommitWrites = 0 + + var rollbackCalls = 0 + private set + + var insertInterceptions = 0 + private set + + override fun getConnection(): Connection { + val raw = delegate.connection + if (acquisitions.incrementAndGet() > 1) return raw + return Proxy.newProxyInstance( + Connection::class.java.classLoader, + arrayOf(Connection::class.java), + ) { _, method, args -> + val callArgs = args ?: emptyArray() + when (method.name) { + "rollback" -> { + rollbackCalls++ + failRollbackWith?.let { throw it } + method.invoke(raw, *callArgs) + } + + "setAutoCommit" -> { + autoCommitWrites++ + // The first write is transaction setup; anything later is the restore. + if (autoCommitWrites > 1 && failRestoreWith != null) throw failRestoreWith + method.invoke(raw, *callArgs) + } + + "prepareStatement" -> { + proxyStatement(raw, method, callArgs) + } + + else -> { + method.invoke(raw, *callArgs) + } + } + } as Connection + } + + private fun proxyStatement( + raw: Connection, + method: java.lang.reflect.Method, + args: Array, + ): Any { + val statement = method.invoke(raw, *args) + if (duplicateKeyOnApprovalInsert == null) return statement + return Proxy.newProxyInstance( + java.sql.PreparedStatement::class.java.classLoader, + arrayOf(java.sql.PreparedStatement::class.java), + ) { _, m, statementArgs -> + if (m.name == "executeUpdate" && (args.firstOrNull() as? String)?.contains("approvals") == true) { + insertInterceptions++ + winnerStore?.invoke() + throw duplicateKeyOnApprovalInsert + } + m.invoke(statement, *(statementArgs ?: emptyArray())) + } + } + + override fun getConnection( + username: String?, + password: String?, + ): Connection = getConnection() + + override fun getLogWriter(): java.io.PrintWriter? = delegate.logWriter + + override fun setLogWriter(out: java.io.PrintWriter?) = delegate.setLogWriter(out) + + override fun getLoginTimeout(): Int = delegate.loginTimeout + + override fun setLoginTimeout(seconds: Int) = delegate.setLoginTimeout(seconds) + + override fun getParentLogger(): java.util.logging.Logger = delegate.parentLogger + + override fun unwrap(iface: Class?): T = unsupported() + + override fun isWrapperFor(iface: Class<*>?): Boolean = false + } + + private fun assertThatSuspendCallThrows(block: suspend () -> Unit) = + assertThatThrownBy { + runBlocking { + block() + } + } + private fun selectCount(sql: String): Int = dataSource.connection.use { conn -> conn.createStatement().use { stmt -> @@ -767,7 +829,12 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { } } - private fun selectValue(sql: String, value: String): String? = + // --------------------------------------------------------------------------------------------- + + private fun selectValue( + sql: String, + value: String, + ): String? = dataSource.connection.use { conn -> conn.prepareStatement(sql).use { stmt -> stmt.setString(1, value) @@ -784,3 +851,5 @@ class JdbcSovereignOpsApprovalRequestMutationStoreTest { return "sha256:${hash.joinToString("") { "%02x".format(it) }}" } } + +private fun unsupported(): Nothing = throw UnsupportedOperationException() diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestRejectionTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestRejectionTest.kt new file mode 100644 index 000000000..eba22ef40 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsApprovalRequestRejectionTest.kt @@ -0,0 +1,629 @@ +package dev.tramai.spring.sovereign.persistence.jdbc + +import dev.tramai.core.approval.ApprovalBinding +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.gateway.ApproverRole +import dev.tramai.core.approval.gateway.ResumeToken +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.model.Message +import dev.tramai.core.model.MessageRole +import dev.tramai.core.model.ToolCall +import dev.tramai.engine.EngineExecutionIdentity +import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.ReplayEnvelopeDigestHelper +import dev.tramai.engine.ResumeOperationReference +import dev.tramai.engine.ResumeToolReference +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.persistence.jdbc.JdbcApprovalContinuationStore +import dev.tramai.persistence.jdbc.JdbcApprovalStore +import dev.tramai.persistence.jdbc.JdbcContinuationArgumentsCodec +import dev.tramai.persistence.jdbc.JdbcEncryptedContinuationArguments +import dev.tramai.persistence.jdbc.JdbcEncryptedReplayEnvelope +import dev.tramai.persistence.jdbc.JdbcReplayEnvelopeCodec +import dev.tramai.persistence.jdbc.JdbcSuspendedInvocationStore +import dev.tramai.spring.sovereign.ops.inbox.ApprovalInboxMetadata +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName +import java.security.MessageDigest +import java.security.SecureRandom +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JdbcSovereignOpsApprovalRequestRejectionTest { + companion object { + private const val POSTGRES_IMAGE = "postgres:17-alpine" + private val postgres = + PostgreSQLContainer(DockerImageName.parse(POSTGRES_IMAGE)) + .withDatabaseName("sovereign_ops_request_mutation_test") + .withUsername("test") + .withPassword("test") + + private fun createDataSource(): DataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + + private val BASE_NOW: Instant = Instant.parse("2026-01-01T00:00:00Z") + } + + private val testAesKey = ByteArray(32).also { SecureRandom().nextBytes(it) } + + private val testSecretKey: SecretKey = SecretKeySpec(testAesKey, "AES") + + private lateinit var dataSource: DataSource + + private lateinit var replayCodec: JdbcReplayEnvelopeCodec + + private lateinit var continuationCodec: JdbcContinuationArgumentsCodec + + private lateinit var outboxCodec: JdbcOpsAuditOutboxPayloadCodec + + private lateinit var mutationStore: JdbcSovereignOpsApprovalRequestMutationStore + + private lateinit var approvalStore: JdbcApprovalStore + + private lateinit var suspendedInvocationStore: JdbcSuspendedInvocationStore + + private lateinit var continuationStore: JdbcApprovalContinuationStore + + @BeforeAll + fun startPostgres() { + postgres.start() + dataSource = createDataSource() + runMigrations() + } + + @AfterAll + fun stopPostgres() { + postgres.stop() + } + + @BeforeEach + fun setUp() { + truncateTables() + replayCodec = testReplayCodec() + continuationCodec = testContinuationCodec() + outboxCodec = testOutboxCodec() + mutationStore = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + approvalStore = + JdbcApprovalStore( + dataSource = dataSource, + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + suspendedInvocationStore = + JdbcSuspendedInvocationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + continuationStore = + JdbcApprovalContinuationStore( + dataSource = dataSource, + argumentsCodec = continuationCodec, + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + } + + @Test + fun `constraint failure rolls back partial approval request creation`() { + runBlocking { + val request = request("approval-d") + suspendedInvocationStore.create( + metadata = + request("existing-conflict").suspendedInvocationMetadata.copy( + toolCallId = request.suspendedInvocationMetadata.toolCallId, + toolName = request.suspendedInvocationMetadata.toolName, + replayEnvelopeDigest = request.suspendedInvocationMetadata.replayEnvelopeDigest, + ), + replayEnvelope = request.replayEnvelope, + ) + + val thrown = + assertThatSuspendCallThrows { + mutationStore.createApprovalRequest(request) + } + thrown + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("tramai-sovereign-ops-approval-request-mutation-database-failure") + + assertThat(approvalStore.get("approval-d")).isNull() + assertThat(suspendedInvocationStore.get("approval-d")).isNull() + assertThat(continuationStore.get("approval-d")).isNull() + assertThat(selectCount("SELECT count(*) FROM audit_outbox")).isZero() + } + } + + @Test + fun `cancellation exception is rethrown and transaction rolls back`() { + runBlocking { + val request = request("approval-e") + val cancellingCodec = + object : JdbcReplayEnvelopeCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedReplayEnvelope = cancelled() + + override fun decode(envelope: JdbcEncryptedReplayEnvelope): ByteArray = envelope.ciphertext + } + val cancellingStore = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = cancellingCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + + val thrown = assertThatSuspendCallThrows { cancellingStore.createApprovalRequest(request) } + thrown.isInstanceOf(CancellationException::class.java) + + assertThat(approvalStore.get("approval-e")).isNull() + assertThat(suspendedInvocationStore.get("approval-e")).isNull() + assertThat(continuationStore.get("approval-e")).isNull() + } + } + + @Test + fun `rejects replay envelope digest mismatch and rolls back all records`() { + runBlocking { + val mismatchedDigest = + Sha256Digest.of( + "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ) + val base = request("approval-f") + val request = + base.copy( + suspendedInvocationMetadata = + base.suspendedInvocationMetadata.copy(replayEnvelopeDigest = mismatchedDigest), + ) + + val thrown = + assertThatSuspendCallThrows { + mutationStore.createApprovalRequest(request) + } + thrown.isInstanceOf(IllegalArgumentException::class.java) + thrown.hasMessageContaining("replay-envelope-digest-mismatch") + + assertThat(approvalStore.get("approval-f")).isNull() + assertThat(suspendedInvocationStore.get("approval-f")).isNull() + assertThat(continuationStore.get("approval-f")).isNull() + assertThat(selectCount("SELECT count(*) FROM audit_outbox")).isZero() + } + } + + @Test + fun `rejects already expired approval request and rolls back all records`() { + runBlocking { + val now = BASE_NOW.plusSeconds(30) + val clockAtNow = Clock.fixed(now, ZoneOffset.UTC) + val storeWithExpiryCheck = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = clockAtNow, + ) + val request = + request("approval-g").copy( + approvalRequest = + request("approval-g").approvalRequest.copy( + expiresAt = now.minusSeconds(1), + ), + ) + + val thrown = + assertThatSuspendCallThrows { + storeWithExpiryCheck.createApprovalRequest(request) + } + thrown + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("approval-request-expired-at-creation") + + assertThat(approvalStore.get("approval-g")).isNull() + assertThat(suspendedInvocationStore.get("approval-g")).isNull() + assertThat(continuationStore.get("approval-g")).isNull() + } + } + + @Test + fun `rejects invalid continuation metadata and rolls back all records`() { + runBlocking { + val request = + request("approval-h").copy( + continuation = + request("approval-h").continuation.copy( + workflowRunId = " ", + ), + ) + + val thrown = + assertThatSuspendCallThrows { + mutationStore.createApprovalRequest(request) + } + thrown + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("continuation.workflowRunId") + + assertThat(approvalStore.get("approval-h")).isNull() + assertThat(suspendedInvocationStore.get("approval-h")).isNull() + assertThat(continuationStore.get("approval-h")).isNull() + } + } + + @Test + fun `rejects future continuation createdAt and rolls back all records`() { + runBlocking { + val now = BASE_NOW.plusSeconds(30) + val clockAtNow = Clock.fixed(now, ZoneOffset.UTC) + val storeWithTimeCheck = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = clockAtNow, + ) + val request = + request("approval-i").copy( + continuation = + request("approval-i").continuation.copy( + createdAt = now.plusSeconds(60), + ), + ) + + val thrown = + assertThatSuspendCallThrows { + storeWithTimeCheck.createApprovalRequest(request) + } + thrown + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("continuation-created-at-in-future") + + assertThat(approvalStore.get("approval-i")).isNull() + assertThat(suspendedInvocationStore.get("approval-i")).isNull() + assertThat(continuationStore.get("approval-i")).isNull() + } + } + + @Test + fun `rollback removes inbox metadata when continuation insert fails`() { + runBlocking { + val request = request("approval-inbox-2") + val metadata = + ApprovalInboxMetadata( + requiredRole = ApproverRole("medical-reviewer"), + riskLevel = "HIGH", + subjectType = "claim", + subjectId = "claim-456", + recommendationType = "claim-payout", + ) + // Use a failing continuation arguments codec to trigger rollback + val failureMessage = "simulated-codec-failure" + val failingCodec = + object : JdbcContinuationArgumentsCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedContinuationArguments = + throw SimulatedCodecFailure(failureMessage) + + override fun decode(envelope: JdbcEncryptedContinuationArguments): ByteArray = + throw SimulatedCodecFailure(failureMessage) + } + val storeWithFailingCodec = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = failingCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + + val thrown = + assertThatSuspendCallThrows { + storeWithFailingCodec.createApprovalRequest(request, inboxMetadata = metadata) + } + thrown.isInstanceOf(RuntimeException::class.java) + + // Verify approval was rolled back (no row) + assertThat(approvalStore.get("approval-inbox-2")).isNull() + } + } + + private fun assertThatSuspendCallThrows(block: suspend () -> Unit) = + assertThatThrownBy { + runBlocking { + block() + } + } + + private fun decrypt( + ciphertext: ByteArray, + nonce: ByteArray, + algorithm: String, + ): ByteArray { + val cipher = Cipher.getInstance(algorithm) + val keySpec = SecretKeySpec(testAesKey, "AES") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.DECRYPT_MODE, keySpec, spec) + return cipher.doFinal(ciphertext) + } + + private fun digest(ch: Char): Sha256Digest = Sha256Digest.of("sha256:${ch.toString().repeat(64)}") + + private fun encrypt(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload { + val algorithm = "AES/GCM/NoPadding" + val cipher = Cipher.getInstance(algorithm) + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + val keySpec = SecretKeySpec(testAesKey, "AES") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, spec) + val ciphertext = cipher.doFinal(plaintext) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(plaintext) + .joinToString("") { "%02x".format(it) } + return JdbcEncryptedAuditOutboxPayload( + ciphertext = ciphertext, + keyId = "test-key-1", + algorithm = algorithm, + nonce = nonce, + payloadDigest = "sha256:$digest", + ) + } + + private fun governedApprovalRequest(approvalId: String): ApprovalRequest = + ApprovalRequest( + approvalId = approvalId, + binding = + ApprovalBinding( + workflowRunId = "wf-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + approvalTokenDigest = digest('c'), + ), + status = ApprovalStatus.PENDING, + requestedBy = "requestor-$approvalId", + requestedAt = BASE_NOW, + expiresAt = BASE_NOW.plusSeconds(600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) + + private fun governedContinuation(approvalId: String): ApprovalContinuation = + ApprovalContinuation( + approvalId = approvalId, + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + status = ApprovalContinuationStatus.PENDING, + createdAt = BASE_NOW, + approvalExpiresAt = BASE_NOW.plusSeconds(600), + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, + ) + + private fun governedMessages(approvalId: String): List = + listOf( + Message(role = MessageRole.USER, content = "request-$approvalId"), + Message( + role = MessageRole.ASSISTANT, + content = "", + toolCalls = + listOf( + ToolCall( + id = "tool-call-$approvalId", + name = "tool-$approvalId", + argumentsJson = "__redacted_approval_continuation_args__", + ), + ), + ), + ) + + private fun governedSuspendedMetadata( + approvalId: String, + messages: List, + ): SuspendedInvocationMetadata { + val operationReference = + ResumeOperationReference("t.Service", "approve", "(Ljava/lang/String;)V", digest('d')) + return SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + toolCallIndex = 0, + correlationId = "corr-$approvalId", + identity = + EngineExecutionIdentity( + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + workflowDigest = digest('b'), + policyVersion = "policy-v1", + actorId = "requestor-$approvalId", + ), + securityContext = ExecutionSecurityContext(), + operationReference = operationReference, + replayEnvelopeDigest = ReplayEnvelopeDigestHelper.compute(operationReference, messages), + toolReference = ResumeToolReference("tool-$approvalId", digest('e')), + ) + } + + // 0.7.1d1 P3: governed transactional creation. The approval-row attribution and the suspension + // identity must come from ONE canonical identity inside the SAME transaction, and an existing row + // may only ever be adopted by the run it is durably attributed to. + // --------------------------------------------------------------------------------------------- + + private fun request(approvalId: String): ApprovalGatewayPersistenceRequest { + val messages = governedMessages(approvalId) + return ApprovalGatewayPersistenceRequest( + approvalRequest = governedApprovalRequest(approvalId), + continuation = governedContinuation(approvalId), + sensitiveArguments = SensitiveToolArguments.of("""{"claimId":"$approvalId"}"""), + suspendedInvocationMetadata = governedSuspendedMetadata(approvalId, messages), + replayEnvelope = SensitiveReplayEnvelope.of(messages), + resumeToken = ResumeToken("resume-$approvalId"), + ) + } + + private fun runMigrations() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + listOf( + "tramai/persistence/jdbc/postgres/V1__sovereign_persistence.sql", + "tramai/persistence/jdbc/postgres/V2__approval_continuations.sql", + "tramai/persistence/jdbc/postgres/V4__audit_outbox_hardening.sql", + "tramai/persistence/jdbc/postgres/V6__approval_resume_credential_custody.sql", + ).forEach { resource -> + val sql = + javaClass.classLoader + .getResourceAsStream(resource) + ?.bufferedReader() + ?.readText() + ?: error("Migration not found: $resource") + stmt.execute(sql) + } + } + } + } + + private fun selectCount(sql: String): Int = + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + check(rs.next()) + rs.getInt(1) + } + } + } + + // --------------------------------------------------------------------------------------------- + + private fun testContinuationCodec(): JdbcContinuationArgumentsCodec = + object : JdbcContinuationArgumentsCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedContinuationArguments = + encrypt(plaintext).let { + JdbcEncryptedContinuationArguments( + ciphertext = it.ciphertext, + keyId = it.keyId, + algorithm = it.algorithm, + nonce = it.nonce, + payloadDigest = it.payloadDigest, + ) + } + + override fun decode(envelope: JdbcEncryptedContinuationArguments): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun testOutboxCodec(): JdbcOpsAuditOutboxPayloadCodec = + object : JdbcOpsAuditOutboxPayloadCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload = encrypt(plaintext) + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun testReplayCodec(): JdbcReplayEnvelopeCodec = + object : JdbcReplayEnvelopeCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedReplayEnvelope = + encrypt(plaintext).let { + JdbcEncryptedReplayEnvelope( + ciphertext = it.ciphertext, + keyId = it.keyId, + algorithm = it.algorithm, + nonce = it.nonce, + payloadDigest = it.payloadDigest, + ) + } + + override fun decode(envelope: JdbcEncryptedReplayEnvelope): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun truncateTables() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + val truncateAll = + "TRUNCATE TABLE approval_continuations, suspended_invocations, audit_outbox, approvals CASCADE" + stmt.execute(truncateAll) + } + } + } + + private class SimulatedCodecFailure( + message: String, + ) : RuntimeException(message) +} + +private fun cancelled(): Nothing = throw CancellationException("cancelled") diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsGovernedApprovalRequestContinuityTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsGovernedApprovalRequestContinuityTest.kt new file mode 100644 index 000000000..202fb4d20 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsGovernedApprovalRequestContinuityTest.kt @@ -0,0 +1,751 @@ +package dev.tramai.spring.sovereign.persistence.jdbc + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import dev.tramai.core.approval.ApprovalBinding +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.gateway.ResumeToken +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.model.Message +import dev.tramai.core.model.MessageRole +import dev.tramai.core.model.ToolCall +import dev.tramai.engine.EngineExecutionIdentity +import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.ReplayEnvelopeDigestHelper +import dev.tramai.engine.ResumeOperationReference +import dev.tramai.engine.ResumeToolReference +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.approval.ApprovalAttributionCorruptionException +import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.persistence.jdbc.JdbcApprovalContinuationStore +import dev.tramai.persistence.jdbc.JdbcApprovalStore +import dev.tramai.persistence.jdbc.JdbcContinuationArgumentsCodec +import dev.tramai.persistence.jdbc.JdbcEncryptedContinuationArguments +import dev.tramai.persistence.jdbc.JdbcEncryptedReplayEnvelope +import dev.tramai.persistence.jdbc.JdbcReplayEnvelopeCodec +import dev.tramai.persistence.jdbc.JdbcSuspendedInvocationStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationResult +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.security.MessageDigest +import java.security.SecureRandom +import java.sql.Connection +import java.sql.PreparedStatement +import java.sql.SQLException +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource + +@org.junit.jupiter.api.TestInstance(org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS) +class JdbcSovereignOpsGovernedApprovalRequestContinuityTest { + companion object { + private const val POSTGRES_IMAGE = "postgres:17-alpine" + private val postgres = + PostgreSQLContainer(DockerImageName.parse(POSTGRES_IMAGE)) + .withDatabaseName("sovereign_ops_governed_mutation_test") + .withUsername("test") + .withPassword("test") + + private fun createDataSource(): DataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + + private val BASE_NOW: Instant = Instant.parse("2026-01-01T00:00:00Z") + } + + private val testAesKey = ByteArray(32).also { SecureRandom().nextBytes(it) } + private val testSecretKey: SecretKey = SecretKeySpec(testAesKey, "AES") + private val mapper: ObjectMapper = + ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + private val clockAt: Clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC) + + private lateinit var dataSource: DataSource + private lateinit var replayCodec: JdbcReplayEnvelopeCodec + private lateinit var continuationCodec: JdbcContinuationArgumentsCodec + private lateinit var outboxCodec: JdbcOpsAuditOutboxPayloadCodec + private lateinit var mutationStore: JdbcSovereignOpsApprovalRequestMutationStore + private lateinit var approvalStore: JdbcApprovalStore + private lateinit var suspendedInvocationStore: JdbcSuspendedInvocationStore + private lateinit var continuationStore: JdbcApprovalContinuationStore + + @BeforeAll + fun startPostgres() { + postgres.start() + dataSource = createDataSource() + runMigrations() + } + + @AfterAll + fun stopPostgres() { + postgres.stop() + } + + @BeforeEach + fun setUp() { + truncateTables() + replayCodec = testReplayCodec() + continuationCodec = testContinuationCodec() + outboxCodec = testOutboxCodec() + mutationStore = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = clockAt, + ) + approvalStore = JdbcApprovalStore(dataSource = dataSource, clock = clockAt) + suspendedInvocationStore = + JdbcSuspendedInvocationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + clock = clockAt, + ) + continuationStore = + JdbcApprovalContinuationStore( + dataSource = dataSource, + argumentsCodec = continuationCodec, + clock = clockAt, + ) + } + + // ----------------------------------------------------------------------------------------- + // 0.7.1d1 P3 fixtures. Built in small parts on purpose: the legacy suite's single request() + // builder is baselined under its own declaration identity, so this suite constructs its own + // equivalent request instead of moving that function. + // ----------------------------------------------------------------------------------------- + + private fun request(approvalId: String): ApprovalGatewayPersistenceRequest { + val messages = governedMessages(approvalId) + return ApprovalGatewayPersistenceRequest( + approvalRequest = governedApprovalRequest(approvalId), + continuation = governedContinuation(approvalId), + sensitiveArguments = SensitiveToolArguments.of("""{"claimId":"$approvalId"}"""), + suspendedInvocationMetadata = governedSuspendedMetadata(approvalId, messages), + replayEnvelope = SensitiveReplayEnvelope.of(messages), + resumeToken = ResumeToken("resume-$approvalId"), + ) + } + + private fun governedMessages(approvalId: String): List = + listOf( + Message(role = MessageRole.USER, content = "request-$approvalId"), + Message( + role = MessageRole.ASSISTANT, + content = "", + toolCalls = + listOf( + ToolCall( + id = "tool-call-$approvalId", + name = "tool-$approvalId", + argumentsJson = "__redacted_approval_continuation_args__", + ), + ), + ), + ) + + private fun governedApprovalRequest(approvalId: String): ApprovalRequest = + ApprovalRequest( + approvalId = approvalId, + binding = + ApprovalBinding( + workflowRunId = "wf-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + approvalTokenDigest = digest('c'), + ), + status = ApprovalStatus.PENDING, + requestedBy = "requestor-$approvalId", + requestedAt = BASE_NOW, + expiresAt = BASE_NOW.plusSeconds(600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) + + private fun governedContinuation(approvalId: String): ApprovalContinuation = + ApprovalContinuation( + approvalId = approvalId, + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + status = ApprovalContinuationStatus.PENDING, + createdAt = BASE_NOW, + approvalExpiresAt = BASE_NOW.plusSeconds(600), + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, + ) + + private fun governedSuspendedMetadata( + approvalId: String, + messages: List, + ): SuspendedInvocationMetadata { + val operationReference = + ResumeOperationReference("t.Service", "approve", "(Ljava/lang/String;)V", digest('d')) + return SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + toolCallIndex = 0, + correlationId = "corr-$approvalId", + identity = + EngineExecutionIdentity( + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + workflowDigest = digest('b'), + policyVersion = "policy-v1", + actorId = "requestor-$approvalId", + ), + securityContext = ExecutionSecurityContext(), + operationReference = operationReference, + replayEnvelopeDigest = ReplayEnvelopeDigestHelper.compute(operationReference, messages), + toolReference = ResumeToolReference("tool-$approvalId", digest('e')), + ) + } + + // 0.7.1d1 P3: governed transactional creation. The approval-row attribution and the suspension + // identity must come from ONE canonical identity inside the SAME transaction, and an existing row + // may only ever be adopted by the run it is durably attributed to. + // --------------------------------------------------------------------------------------------- + + @Test + fun `governed creation of an exactly attributed existing approval is idempotent`() { + runBlocking { + val approvalId = "approval-governed-idempotent" + val identity = identity("wf-$approvalId") + mutationStore.createGovernedApprovalRequest(request(approvalId), identity) + + val second = mutationStore.createGovernedApprovalRequest(request(approvalId), identity) + + assertThat(second).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Existing::class.java) + assertThat((second as SovereignOpsApprovalRequestMutationResult.Existing).approval.approvalId) + .isEqualTo(approvalId) + assertThat(selectCount("SELECT count(*) FROM approvals")).isEqualTo(1) + assertThat(decryptedSuspension(approvalId)["payloadVersion"].asInt()).isEqualTo(2) + } + } + + @Test + fun `governed creation refuses an un-attributed existing approval`() { + runBlocking { + val approvalId = "approval-legacy-existing" + mutationStore.createApprovalRequest(request(approvalId)) + + val identity = identity("wf-$approvalId") + val store = mutationStore + val thrown = + assertThatSuspendCallThrows { + store.createGovernedApprovalRequest(request(approvalId), identity) + } + thrown.isInstanceOf(GovernedRunContinuityException::class.java) + + // The legacy row is untouched and nothing new was written. + assertThat(selectCount("SELECT count(*) FROM approvals")).isEqualTo(1) + assertThat(selectCount("SELECT count(*) FROM suspended_invocations")).isEqualTo(1) + assertThat(metadataNode(approvalId).has("attribution")).isFalse + } + } + + @Test + fun `governed creation refuses an existing approval attributed to another identity`() { + runBlocking { + val approvalId = "approval-foreign-identity" + val identity = identity("wf-$approvalId") + mutationStore.createGovernedApprovalRequest(request(approvalId), identity) + // Simulate a row durably attributed elsewhere, leaving the run id and every other + // component intact so the row is foreign by identity only. + updateMetadata( + approvalId, + """jsonb_set(sanitized_metadata, '{attribution,"approval.identity.workload"}', '"other-workload"')""", + ) + + assertThatThrownBy { + runBlocking { mutationStore.createGovernedApprovalRequest(request(approvalId), identity) } + }.isInstanceOf(GovernedRunContinuityException::class.java) + + assertThat(selectCount("SELECT count(*) FROM approvals")).isEqualTo(1) + } + } + + @Test + fun `partial persisted attribution is corruption, never a continuity failure`() { + runBlocking { + val approvalId = "approval-partial-attribution" + val identity = identity("wf-$approvalId") + mutationStore.createGovernedApprovalRequest(request(approvalId), identity) + updateMetadata( + approvalId, + """sanitized_metadata #- '{attribution,"approval.identity.deployment"}'""", + ) + + val store = mutationStore + val thrown = + assertThatSuspendCallThrows { + store.createGovernedApprovalRequest(request(approvalId), identity) + } + thrown.isInstanceOf(ApprovalAttributionCorruptionException::class.java) + } + } + + @Test + fun `governed creation failure after the approval insert rolls everything back`() { + runBlocking { + val approvalId = "approval-governed-rollback" + val failingCodec = + object : JdbcOpsAuditOutboxPayloadCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload { + error("simulated-outbox-codec-failure") + } + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray = unsupported() + } + val store = mutationStoreWith(outboxPayloadCodec = failingCodec) + val intent = auditIntent(approvalId, "governed-rollback") + val approvalRequest = request(approvalId) + val governedIdentity = identity("wf-$approvalId") + + assertThatSuspendCallThrows { + store.createGovernedApprovalRequest(approvalRequest, governedIdentity, intent) + }.isInstanceOf(IllegalStateException::class.java) + + // The approval row is inserted before the outbox write, so its absence proves the + // governed path shares the single rollback boundary. + assertThat(selectCount("SELECT count(*) FROM approvals")).isZero() + assertThat(selectCount("SELECT count(*) FROM suspended_invocations")).isZero() + assertThat(selectCount("SELECT count(*) FROM approval_continuations")).isZero() + } + } + + @Test + fun `a forced primary-key race with the exact identity adopts the existing row`() { + runBlocking { + val approvalId = "approval-race-exact" + val identity = identity("wf-$approvalId") + val racing = + dataSourceRacingOnApprovalInsert { + // Independent connection, own transaction, already committed when the INSERT fails. + mutationStore.createGovernedApprovalRequest(request(approvalId), identity) + } + + val store = mutationStoreWith(dataSource = racing) + val result = store.createGovernedApprovalRequest(request(approvalId), identity) + + assertThat(result).isInstanceOf(SovereignOpsApprovalRequestMutationResult.Existing::class.java) + assertThat(selectCount("SELECT count(*) FROM approvals")).isEqualTo(1) + assertThat(decryptedSuspension(approvalId)["payloadVersion"].asInt()).isEqualTo(2) + } + } + + @Test + fun `a forced primary-key race with a foreign identity fails closed`() { + runBlocking { + val approvalId = "approval-race-foreign" + // Same run id, different deployment attribution: the loser must not adopt it. + val identity = identity("wf-$approvalId") + val foreign = identity("wf-$approvalId", workloadId = "other-workload") + val racing = + dataSourceRacingOnApprovalInsert { + mutationStore.createGovernedApprovalRequest(request(approvalId), foreign) + } + + val store = mutationStoreWith(dataSource = racing) + val thrown = + assertThatSuspendCallThrows { + store.createGovernedApprovalRequest(request(approvalId), identity) + } + thrown.isInstanceOf(GovernedRunContinuityException::class.java) + + assertThat(selectCount("SELECT count(*) FROM approvals")).isEqualTo(1) + } + } + + @Test + fun `a forced primary-key race with an un-attributed row fails closed`() { + runBlocking { + val approvalId = "approval-race-legacy" + val racing = + dataSourceRacingOnApprovalInsert { + mutationStore.createApprovalRequest(request(approvalId)) + } + + assertThatSuspendCallThrows { + mutationStoreWith(dataSource = racing) + .createGovernedApprovalRequest(request(approvalId), identity("wf-$approvalId")) + }.isInstanceOf(GovernedRunContinuityException::class.java) + + assertThat(metadataNode(approvalId).has("attribution")).isFalse + } + } + + /** + * Forces the primary-key race deterministically: when the approval INSERT runs, an independent + * connection commits the competing row first, then the statement itself fails with a + * duplicate-key SQLException. No thread timing is involved, so the rollback-and-re-read path in + * production is exercised exactly as written. + */ + + private fun dataSourceRacingOnApprovalInsert(competingWrite: suspend () -> Unit): DataSource { + val fired = AtomicBoolean(false) + val real = dataSource + return proxyOf(DataSource::class.java) { method, args -> + val connection = method.invoke(real, *(args ?: emptyArray())) as Connection + if (method.name == "getConnection") racingConnection(connection, fired, competingWrite) else connection + } + } + + private fun racingConnection( + connection: Connection, + fired: AtomicBoolean, + competingWrite: suspend () -> Unit, + ): Connection = + proxyOf(Connection::class.java) { method, args -> + val result = method.invoke(connection, *(args ?: emptyArray())) + val sql = args?.firstOrNull() as? String + if (method.name == "prepareStatement" && sql?.contains("INSERT INTO approvals") == true) { + racingStatement(result, fired, competingWrite) + } else { + result + } + } + + private fun racingStatement( + statement: Any, + fired: AtomicBoolean, + competingWrite: suspend () -> Unit, + ): PreparedStatement = + proxyOf(PreparedStatement::class.java) { method, args -> + if (method.name == "executeUpdate" && fired.compareAndSet(false, true)) { + runBlocking { competingWrite() } + throw SQLException("duplicate key value violates unique constraint \"approvals_pkey\"", "23505") + } + method.invoke(statement, *(args ?: emptyArray())) + } + + /** Single-method JDK proxy so the racing DataSource stays readable. */ + @Suppress("UNCHECKED_CAST") + private fun identity( + runId: String, + workloadId: String = "claims", + ): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId(runId), + ) + + private fun mutationStoreWith( + dataSource: DataSource = this.dataSource, + outboxPayloadCodec: JdbcOpsAuditOutboxPayloadCodec = outboxCodec, + ) = JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxPayloadCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC), + ) + + /** Structural view of the approval metadata, so jsonb reordering cannot affect the assertion. */ + + private fun metadataNode(approvalId: String): JsonNode = + mapper.readTree( + checkNotNull(selectValue("SELECT sanitized_metadata FROM approvals WHERE approval_id = ?", approvalId)), + ) + + /** Decrypted suspension payload as a JSON tree: the only way to see V1/V2 and the identity. */ + + private fun decryptedSuspension(approvalId: String): JsonNode { + val columns = + dataSource.connection.use { conn -> + conn + .prepareStatement( + "SELECT encrypted_replay_envelope, encryption_nonce, encryption_algorithm " + + "FROM suspended_invocations WHERE invocation_id = ?", + ).use { stmt -> + stmt.setString(1, approvalId) + stmt.executeQuery().use { rs -> + check(rs.next()) + Triple(rs.getBytes(1), rs.getBytes(2), rs.getString(3)) + } + } + } + return mapper.readTree(decrypt(columns.first, columns.second, columns.third)) + } + + private fun updateMetadata( + approvalId: String, + expression: String, + ) { + dataSource.connection.use { conn -> + conn + .prepareStatement( + "UPDATE approvals SET sanitized_metadata = $expression WHERE approval_id = ?", + ).use { stmt -> + stmt.setString(1, approvalId) + check(stmt.executeUpdate() == 1) + } + } + } + + private fun auditIntent( + approvalId: String, + eventKey: String, + ): SovereignOpsAuditOutboxRecord = + SovereignOpsAuditOutboxRecord( + outboxId = UUID.randomUUID().toString(), + aggregateIdDigest = sha256Hex(approvalId), + eventKey = eventKey, + actor = "system", + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + approvalStatus = "PENDING", + approvalVersion = 0L, + reasonDigest = sha256Hex("approval-requested"), + reasonLength = "approval-requested".length, + createdAt = BASE_NOW, + status = SovereignOpsAuditOutboxStatus.PREPARED, + ) + + private fun digest(ch: Char): Sha256Digest = Sha256Digest.of("sha256:${ch.toString().repeat(64)}") + + private fun testReplayCodec(): JdbcReplayEnvelopeCodec = + object : JdbcReplayEnvelopeCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedReplayEnvelope = + encrypt(plaintext).let { + JdbcEncryptedReplayEnvelope( + ciphertext = it.ciphertext, + keyId = it.keyId, + algorithm = it.algorithm, + nonce = it.nonce, + payloadDigest = it.payloadDigest, + ) + } + + override fun decode(envelope: JdbcEncryptedReplayEnvelope): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun testContinuationCodec(): JdbcContinuationArgumentsCodec = + object : JdbcContinuationArgumentsCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedContinuationArguments = + encrypt(plaintext).let { + JdbcEncryptedContinuationArguments( + ciphertext = it.ciphertext, + keyId = it.keyId, + algorithm = it.algorithm, + nonce = it.nonce, + payloadDigest = it.payloadDigest, + ) + } + + override fun decode(envelope: JdbcEncryptedContinuationArguments): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun testOutboxCodec(): JdbcOpsAuditOutboxPayloadCodec = + object : JdbcOpsAuditOutboxPayloadCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload = encrypt(plaintext) + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun encrypt(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload { + val algorithm = "AES/GCM/NoPadding" + val cipher = Cipher.getInstance(algorithm) + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + val keySpec = SecretKeySpec(testAesKey, "AES") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, spec) + val ciphertext = cipher.doFinal(plaintext) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(plaintext) + .joinToString("") { "%02x".format(it) } + return JdbcEncryptedAuditOutboxPayload( + ciphertext = ciphertext, + keyId = "test-key-1", + algorithm = algorithm, + nonce = nonce, + payloadDigest = "sha256:$digest", + ) + } + + private fun decrypt( + ciphertext: ByteArray, + nonce: ByteArray, + algorithm: String, + ): ByteArray { + val cipher = Cipher.getInstance(algorithm) + val keySpec = SecretKeySpec(testAesKey, "AES") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.DECRYPT_MODE, keySpec, spec) + return cipher.doFinal(ciphertext) + } + + private fun truncateTables() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.execute( + "TRUNCATE TABLE approval_continuations, suspended_invocations, audit_outbox, approvals CASCADE", + ) + } + } + } + + private fun runMigrations() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + listOf( + "tramai/persistence/jdbc/postgres/V1__sovereign_persistence.sql", + "tramai/persistence/jdbc/postgres/V2__approval_continuations.sql", + "tramai/persistence/jdbc/postgres/V4__audit_outbox_hardening.sql", + "tramai/persistence/jdbc/postgres/V6__approval_resume_credential_custody.sql", + ).forEach { resource -> + val sql = + javaClass.classLoader + .getResourceAsStream(resource) + ?.bufferedReader() + ?.readText() + ?: error("Migration not found: $resource") + stmt.execute(sql) + } + } + } + } + + private fun assertThatSuspendCallThrows(block: suspend () -> Unit) = + assertThatThrownBy { + runBlocking { + block() + } + } + + private fun selectCount(sql: String): Int = + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + check(rs.next()) + rs.getInt(1) + } + } + } + + // --------------------------------------------------------------------------------------------- + // 0.7.1d1 P3: governed transactional creation. The approval-row attribution and the suspension + // identity must come from ONE canonical identity inside the SAME transaction, and an existing row + // may only ever be adopted by the run it is durably attributed to. + // --------------------------------------------------------------------------------------------- + + private fun sha256Hex(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(input.toByteArray(Charsets.UTF_8)) + return "sha256:${hash.joinToString("") { "%02x".format(it) }}" + } + + private fun proxyOf( + type: Class, + handler: (Method, Array?) -> Any?, + ): T = + type.cast( + Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, args -> + handler(method, args) + }, + ) + + private fun selectValue( + sql: String, + value: String, + ): String? = + dataSource.connection.use { conn -> + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, value) + stmt.executeQuery().use { rs -> + check(rs.next()) + rs.getString(1) + } + } + } +} + +private fun unsupported(): Nothing = throw UnsupportedOperationException() diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsGovernedApprovalRequestMutationStoreTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsGovernedApprovalRequestMutationStoreTest.kt new file mode 100644 index 000000000..704b00060 --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/JdbcSovereignOpsGovernedApprovalRequestMutationStoreTest.kt @@ -0,0 +1,596 @@ +package dev.tramai.spring.sovereign.persistence.jdbc + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import dev.tramai.core.approval.ApprovalBinding +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.gateway.ResumeToken +import dev.tramai.core.exception.GovernedRunContinuityException +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.model.Message +import dev.tramai.core.model.MessageRole +import dev.tramai.core.model.ToolCall +import dev.tramai.engine.EngineExecutionIdentity +import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.ReplayEnvelopeDigestHelper +import dev.tramai.engine.ResumeOperationReference +import dev.tramai.engine.ResumeToolReference +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.approval.ApprovalAttributionKeys +import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.persistence.jdbc.GovernedJdbcSuspendedInvocationStore +import dev.tramai.persistence.jdbc.JdbcApprovalContinuationStore +import dev.tramai.persistence.jdbc.JdbcApprovalStore +import dev.tramai.persistence.jdbc.JdbcContinuationArgumentsCodec +import dev.tramai.persistence.jdbc.JdbcEncryptedContinuationArguments +import dev.tramai.persistence.jdbc.JdbcEncryptedReplayEnvelope +import dev.tramai.persistence.jdbc.JdbcReplayEnvelopeCodec +import dev.tramai.persistence.jdbc.JdbcSuspendedInvocationStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationResult +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.postgresql.PostgreSQLContainer +import org.testcontainers.utility.DockerImageName +import java.security.MessageDigest +import java.security.SecureRandom +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.sql.DataSource + +@org.junit.jupiter.api.TestInstance(org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS) +class JdbcSovereignOpsGovernedApprovalRequestMutationStoreTest { + companion object { + private const val POSTGRES_IMAGE = "postgres:17-alpine" + private val postgres = + PostgreSQLContainer(DockerImageName.parse(POSTGRES_IMAGE)) + .withDatabaseName("sovereign_ops_governed_mutation_test") + .withUsername("test") + .withPassword("test") + + private fun createDataSource(): DataSource = + PGSimpleDataSource().apply { + setUrl(postgres.jdbcUrl) + user = postgres.username + password = postgres.password + } + + private val BASE_NOW: Instant = Instant.parse("2026-01-01T00:00:00Z") + } + + private val testAesKey = ByteArray(32).also { SecureRandom().nextBytes(it) } + private val testSecretKey: SecretKey = SecretKeySpec(testAesKey, "AES") + private val mapper: ObjectMapper = + ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + private val clockAt: Clock = Clock.fixed(BASE_NOW.plusSeconds(30), ZoneOffset.UTC) + + private lateinit var dataSource: DataSource + private lateinit var replayCodec: JdbcReplayEnvelopeCodec + private lateinit var continuationCodec: JdbcContinuationArgumentsCodec + private lateinit var outboxCodec: JdbcOpsAuditOutboxPayloadCodec + private lateinit var mutationStore: JdbcSovereignOpsApprovalRequestMutationStore + private lateinit var approvalStore: JdbcApprovalStore + private lateinit var suspendedInvocationStore: JdbcSuspendedInvocationStore + private lateinit var continuationStore: JdbcApprovalContinuationStore + + @BeforeAll + fun startPostgres() { + postgres.start() + dataSource = createDataSource() + runMigrations() + } + + @AfterAll + fun stopPostgres() { + postgres.stop() + } + + @BeforeEach + fun setUp() { + truncateTables() + replayCodec = testReplayCodec() + continuationCodec = testContinuationCodec() + outboxCodec = testOutboxCodec() + mutationStore = + JdbcSovereignOpsApprovalRequestMutationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + continuationArgumentsCodec = continuationCodec, + outboxPayloadCodec = outboxCodec, + encryptionKey = testSecretKey, + encryptionKeyId = "test-key-1", + clock = clockAt, + ) + approvalStore = JdbcApprovalStore(dataSource = dataSource, clock = clockAt) + suspendedInvocationStore = + JdbcSuspendedInvocationStore( + dataSource = dataSource, + replayEnvelopeCodec = replayCodec, + clock = clockAt, + ) + continuationStore = + JdbcApprovalContinuationStore( + dataSource = dataSource, + argumentsCodec = continuationCodec, + clock = clockAt, + ) + } + + // ----------------------------------------------------------------------------------------- + // 0.7.1d1 P3 fixtures. Built in small parts on purpose: the legacy suite's single request() + // builder is baselined under its own declaration identity, so this suite constructs its own + // equivalent request instead of moving that function. + // ----------------------------------------------------------------------------------------- + + private fun request(approvalId: String): ApprovalGatewayPersistenceRequest { + val messages = governedMessages(approvalId) + return ApprovalGatewayPersistenceRequest( + approvalRequest = governedApprovalRequest(approvalId), + continuation = governedContinuation(approvalId), + sensitiveArguments = SensitiveToolArguments.of("""{"claimId":"$approvalId"}"""), + suspendedInvocationMetadata = governedSuspendedMetadata(approvalId, messages), + replayEnvelope = SensitiveReplayEnvelope.of(messages), + resumeToken = ResumeToken("resume-$approvalId"), + ) + } + + private fun governedMessages(approvalId: String): List = + listOf( + Message(role = MessageRole.USER, content = "request-$approvalId"), + Message( + role = MessageRole.ASSISTANT, + content = "", + toolCalls = + listOf( + ToolCall( + id = "tool-call-$approvalId", + name = "tool-$approvalId", + argumentsJson = "__redacted_approval_continuation_args__", + ), + ), + ), + ) + + private fun governedApprovalRequest(approvalId: String): ApprovalRequest = + ApprovalRequest( + approvalId = approvalId, + binding = + ApprovalBinding( + workflowRunId = "wf-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + approvalTokenDigest = digest('c'), + ), + status = ApprovalStatus.PENDING, + requestedBy = "requestor-$approvalId", + requestedAt = BASE_NOW, + expiresAt = BASE_NOW.plusSeconds(600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) + + private fun governedContinuation(approvalId: String): ApprovalContinuation = + ApprovalContinuation( + approvalId = approvalId, + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + status = ApprovalContinuationStatus.PENDING, + createdAt = BASE_NOW, + approvalExpiresAt = BASE_NOW.plusSeconds(600), + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, + ) + + private fun governedSuspendedMetadata( + approvalId: String, + messages: List, + ): SuspendedInvocationMetadata { + val operationReference = + ResumeOperationReference("t.Service", "approve", "(Ljava/lang/String;)V", digest('d')) + return SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + toolCallIndex = 0, + correlationId = "corr-$approvalId", + identity = + EngineExecutionIdentity( + workflowRunId = "wf-$approvalId", + correlationId = "corr-$approvalId", + workflowDigest = digest('b'), + policyVersion = "policy-v1", + actorId = "requestor-$approvalId", + ), + securityContext = ExecutionSecurityContext(), + operationReference = operationReference, + replayEnvelopeDigest = ReplayEnvelopeDigestHelper.compute(operationReference, messages), + toolReference = ResumeToolReference("tool-$approvalId", digest('e')), + ) + } + + // 0.7.1d1 P3: governed transactional creation. The approval-row attribution and the suspension + // identity must come from ONE canonical identity inside the SAME transaction, and an existing row + // may only ever be adopted by the run it is durably attributed to. + // --------------------------------------------------------------------------------------------- + + @Test + fun `governed creation writes the reserved attribution and the canonical identity in one transaction`() { + runBlocking { + val approvalId = "approval-governed-a" + val identity = identity("wf-$approvalId") + + val result = mutationStore.createGovernedApprovalRequest(request(approvalId), identity) + + assertThat(result).isEqualTo( + SovereignOpsApprovalRequestMutationResult.Created( + approvalId = approvalId, + correlationId = "corr-$approvalId", + resumeToken = ResumeToken("resume-$approvalId"), + ), + ) + + // Approval row: the five reserved components, and no run id of its own. + val attribution = metadataNode(approvalId)["attribution"] + assertThat(attribution).isNotNull + ApprovalAttributionKeys.ALL.forEach { key -> + assertThat(attribution!![key]?.asText()).isNotBlank + } + + // Suspension row: the SAME canonical identity, proven by reading it back through the + // canonical governed reader (exact equality of all six components, not just the run id). + val canonicalReader = GovernedJdbcSuspendedInvocationStore(delegate = suspendedInvocationStore) + assertThat(canonicalReader.governedRunIdentity(approvalId)).isEqualTo(identity) + assertThat(attribution!!.size()).isEqualTo(ApprovalAttributionKeys.ALL.size) + assertThat(attribution.has(identity.runId.value)).isFalse + + // Suspension: decrypted V2 payload carrying the full canonical identity. + val payload = decryptedSuspension(approvalId) + assertThat(payload["payloadVersion"].asInt()).isEqualTo(2) + val persisted = payload["governedRunIdentity"] + assertThat(persisted["runId"].asText()).isEqualTo(identity.runId.value) + assertThat(persisted["workloadId"].asText()).isEqualTo(identity.deployment.workloadId.value) + assertThat(persisted["deploymentId"].asText()).isEqualTo(identity.deployment.deploymentId.value) + assertThat(suspendedInvocationStore.get(approvalId)).isNotNull + } + } + + @Test + fun `ungoverned creation metadata keeps the released shape`() { + runBlocking { + val approvalId = "approval-legacy-shape" + + mutationStore.createApprovalRequest(request(approvalId)) + + // No attribution block at all: not an empty object, not null. + val metadata = metadataNode(approvalId) + assertThat(metadata.has("attribution")).isFalse + ApprovalAttributionKeys.ALL.forEach { key -> + assertThat(metadata.has(key)).isFalse + } + // And the suspension is a V1 payload: neither version nor identity is present. + val payload = decryptedSuspension(approvalId) + assertThat(payload.has("payloadVersion")).isFalse + assertThat(payload.has("governedRunIdentity")).isFalse + } + } + + @Test + fun `governed creation rejects a request bound to a different run with zero writes`() { + runBlocking { + val approvalId = "approval-governed-mismatch" + + val store = mutationStore + val approvalRequest = request(approvalId) + val mismatchedIdentity = identity("elsewhere") + val thrown = + assertThatSuspendCallThrows { + store.createGovernedApprovalRequest(approvalRequest, mismatchedIdentity) + } + thrown.isInstanceOf(GovernedRunContinuityException::class.java) + + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(suspendedInvocationStore.get(approvalId)).isNull() + assertThat(selectCount("SELECT count(*) FROM approvals")).isZero() + } + } + + @Test + fun `governed creation rejects suspension metadata naming another run with zero writes`() { + runBlocking { + val approvalId = "approval-carrier-suspension" + val identity = identity("wf-$approvalId") + val base = request(approvalId) + val request = + base.copy( + suspendedInvocationMetadata = + base.suspendedInvocationMetadata.copy( + identity = + base.suspendedInvocationMetadata.identity.copy( + workflowRunId = "wf-other", + ), + ), + ) + + val thrown = + assertThatSuspendCallThrows { + mutationStore.createGovernedApprovalRequest(request, identity) + } + thrown.isInstanceOf(GovernedRunContinuityException::class.java) + thrown.hasMessageContaining("suspended invocation metadata") + + assertThat(approvalStore.get(approvalId)).isNull() + assertThat(suspendedInvocationStore.get(approvalId)).isNull() + assertThat(continuationStore.get(approvalId)).isNull() + assertThat(selectCount("SELECT count(*) FROM approvals")).isZero() + assertThat(selectCount("SELECT count(*) FROM suspended_invocations")).isZero() + assertThat(selectCount("SELECT count(*) FROM approval_continuations")).isZero() + } + } + + @Test + fun `governed creation rejects a continuation naming another run with zero writes`() { + runBlocking { + val approvalId = "approval-carrier-continuation" + val identity = identity("wf-$approvalId") + val base = request(approvalId) + val request = base.copy(continuation = base.continuation.copy(workflowRunId = "wf-other")) + + val thrown = + assertThatSuspendCallThrows { + mutationStore.createGovernedApprovalRequest(request, identity) + } + thrown.isInstanceOf(GovernedRunContinuityException::class.java) + thrown.hasMessageContaining("continuation") + + assertThat(selectCount("SELECT count(*) FROM approvals")).isZero() + assertThat(selectCount("SELECT count(*) FROM suspended_invocations")).isZero() + assertThat(selectCount("SELECT count(*) FROM approval_continuations")).isZero() + } + } + + private fun identity( + runId: String, + workloadId: String = "claims", + ): GovernedRunIdentity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId(workloadId), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claims-prod"), + version = ConfigurationVersion("17"), + ), + environmentId = EnvironmentId("production"), + deploymentId = DeploymentId("eu-west-amsterdam-01"), + ), + runId = RunId(runId), + ) + + private fun metadataNode(approvalId: String): JsonNode = + mapper.readTree( + checkNotNull(selectValue("SELECT sanitized_metadata FROM approvals WHERE approval_id = ?", approvalId)), + ) + + /** Decrypted suspension payload as a JSON tree: the only way to see V1/V2 and the identity. */ + + private fun decryptedSuspension(approvalId: String): JsonNode { + val columns = + dataSource.connection.use { conn -> + conn + .prepareStatement( + "SELECT encrypted_replay_envelope, encryption_nonce, encryption_algorithm " + + "FROM suspended_invocations WHERE invocation_id = ?", + ).use { stmt -> + stmt.setString(1, approvalId) + stmt.executeQuery().use { rs -> + check(rs.next()) + Triple(rs.getBytes(1), rs.getBytes(2), rs.getString(3)) + } + } + } + return mapper.readTree(decrypt(columns.first, columns.second, columns.third)) + } + + private fun digest(ch: Char): Sha256Digest = Sha256Digest.of("sha256:${ch.toString().repeat(64)}") + + private fun testReplayCodec(): JdbcReplayEnvelopeCodec = + object : JdbcReplayEnvelopeCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedReplayEnvelope = + encrypt(plaintext).let { + JdbcEncryptedReplayEnvelope( + ciphertext = it.ciphertext, + keyId = it.keyId, + algorithm = it.algorithm, + nonce = it.nonce, + payloadDigest = it.payloadDigest, + ) + } + + override fun decode(envelope: JdbcEncryptedReplayEnvelope): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun testContinuationCodec(): JdbcContinuationArgumentsCodec = + object : JdbcContinuationArgumentsCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedContinuationArguments = + encrypt(plaintext).let { + JdbcEncryptedContinuationArguments( + ciphertext = it.ciphertext, + keyId = it.keyId, + algorithm = it.algorithm, + nonce = it.nonce, + payloadDigest = it.payloadDigest, + ) + } + + override fun decode(envelope: JdbcEncryptedContinuationArguments): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun testOutboxCodec(): JdbcOpsAuditOutboxPayloadCodec = + object : JdbcOpsAuditOutboxPayloadCodec { + override fun encode(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload = encrypt(plaintext) + + override fun decode(envelope: JdbcEncryptedAuditOutboxPayload): ByteArray = + decrypt( + ciphertext = envelope.ciphertext, + nonce = envelope.nonce, + algorithm = envelope.algorithm, + ) + } + + private fun encrypt(plaintext: ByteArray): JdbcEncryptedAuditOutboxPayload { + val algorithm = "AES/GCM/NoPadding" + val cipher = Cipher.getInstance(algorithm) + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + val keySpec = SecretKeySpec(testAesKey, "AES") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, spec) + val ciphertext = cipher.doFinal(plaintext) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(plaintext) + .joinToString("") { "%02x".format(it) } + return JdbcEncryptedAuditOutboxPayload( + ciphertext = ciphertext, + keyId = "test-key-1", + algorithm = algorithm, + nonce = nonce, + payloadDigest = "sha256:$digest", + ) + } + + private fun decrypt( + ciphertext: ByteArray, + nonce: ByteArray, + algorithm: String, + ): ByteArray { + val cipher = Cipher.getInstance(algorithm) + val keySpec = SecretKeySpec(testAesKey, "AES") + val spec = GCMParameterSpec(128, nonce) + cipher.init(Cipher.DECRYPT_MODE, keySpec, spec) + return cipher.doFinal(ciphertext) + } + + private fun truncateTables() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.execute( + "TRUNCATE TABLE approval_continuations, suspended_invocations, audit_outbox, approvals CASCADE", + ) + } + } + } + + private fun runMigrations() { + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + listOf( + "tramai/persistence/jdbc/postgres/V1__sovereign_persistence.sql", + "tramai/persistence/jdbc/postgres/V2__approval_continuations.sql", + "tramai/persistence/jdbc/postgres/V4__audit_outbox_hardening.sql", + "tramai/persistence/jdbc/postgres/V6__approval_resume_credential_custody.sql", + ).forEach { resource -> + val sql = + javaClass.classLoader + .getResourceAsStream(resource) + ?.bufferedReader() + ?.readText() + ?: error("Migration not found: $resource") + stmt.execute(sql) + } + } + } + } + + private fun assertThatSuspendCallThrows(block: suspend () -> Unit) = + assertThatThrownBy { + runBlocking { + block() + } + } + + private fun selectCount(sql: String): Int = + dataSource.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + check(rs.next()) + rs.getInt(1) + } + } + } + + // --------------------------------------------------------------------------------------------- + // 0.7.1d1 P3: governed transactional creation. The approval-row attribution and the suspension + // identity must come from ONE canonical identity inside the SAME transaction, and an existing row + // may only ever be adopted by the run it is durably attributed to. + // --------------------------------------------------------------------------------------------- + + private fun selectValue( + sql: String, + value: String, + ): String? = + dataSource.connection.use { conn -> + conn.prepareStatement(sql).use { stmt -> + stmt.setString(1, value) + stmt.executeQuery().use { rs -> + check(rs.next()) + rs.getString(1) + } + } + } +} diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfigurationTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfigurationTest.kt index 0f282d2e6..8645e6237 100644 --- a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfigurationTest.kt +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignJdbcPersistenceAutoConfigurationTest.kt @@ -1,26 +1,37 @@ package dev.tramai.spring.sovereign.persistence.jdbc import dev.tramai.core.approval.ApprovalContinuationStore +import dev.tramai.core.approval.ApprovalRequest import dev.tramai.core.approval.ApprovalStore import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.persistence.jdbc.GovernedJdbcSuspendedInvocationStore import dev.tramai.persistence.jdbc.JdbcApprovalContinuationStore import dev.tramai.persistence.jdbc.JdbcApprovalStore import dev.tramai.persistence.jdbc.JdbcAuditPayloadCodec import dev.tramai.persistence.jdbc.JdbcAuditStore import dev.tramai.persistence.jdbc.JdbcContinuationArgumentsCodec +import dev.tramai.persistence.jdbc.JdbcEncryptedAuditPayload import dev.tramai.persistence.jdbc.JdbcReplayEnvelopeCodec import dev.tramai.persistence.jdbc.JdbcSuspendedInvocationStore -import dev.tramai.spring.sovereign.ops.lease.SovereignOpsWorkerLeaseStore import dev.tramai.security.approval.InMemoryApprovalContinuationStore import dev.tramai.security.approval.InMemoryApprovalStore import dev.tramai.security.audit.AuditEvent import dev.tramai.security.audit.AuditStore import dev.tramai.security.audit.InMemoryAuditStore +import dev.tramai.spring.sovereign.ops.lease.SovereignOpsWorkerLeaseStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationStore import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxRecord import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStatus import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsAuditOutboxStore +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary import java.io.PrintWriter import java.nio.file.Path import java.sql.Connection @@ -31,17 +42,8 @@ import java.util.Base64 import java.util.logging.Logger import javax.crypto.SecretKey import javax.sql.DataSource -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.autoconfigure.AutoConfigurations -import org.springframework.boot.test.context.runner.ApplicationContextRunner -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Primary class SovereignJdbcPersistenceAutoConfigurationTest { - @TempDir lateinit var tempDir: Path @@ -50,24 +52,26 @@ class SovereignJdbcPersistenceAutoConfigurationTest { Base64.getEncoder().encodeToString(ByteArray(32) { it.toByte() }) /** Properties for a valid JDBC config using key-file. */ - private fun validJdbcProps(keyFile: Path): Map = mapOf( - "tramai.sovereign.persistence.type" to "jdbc", - "tramai.sovereign.persistence.encryption.key-file" to keyFile.toAbsolutePath().toString(), - ) + private fun validJdbcProps(keyFile: Path): Map = + mapOf( + "tramai.sovereign.persistence.type" to "jdbc", + "tramai.sovereign.persistence.encryption.key-file" to keyFile.toAbsolutePath().toString(), + ) /** Properties with custom outbox config. */ - private fun jdbcPropsWithOutboxConfig(keyFile: Path): Map = mapOf( - "tramai.sovereign.persistence.type" to "jdbc", - "tramai.sovereign.persistence.encryption.key-file" to keyFile.toAbsolutePath().toString(), - "tramai.sovereign.persistence.jdbc.claim-lease-duration" to "10m", - "tramai.sovereign.persistence.jdbc.max-claim-limit" to "1000", - ) - - private val contextRunner = ApplicationContextRunner() - .withConfiguration( - AutoConfigurations.of(SovereignJdbcPersistenceAutoConfiguration::class.java), + private fun jdbcPropsWithOutboxConfig(keyFile: Path): Map = + mapOf( + "tramai.sovereign.persistence.type" to "jdbc", + "tramai.sovereign.persistence.encryption.key-file" to keyFile.toAbsolutePath().toString(), + "tramai.sovereign.persistence.jdbc.claim-lease-duration" to "10m", + "tramai.sovereign.persistence.jdbc.max-claim-limit" to "1000", ) - .withUserConfiguration(TestDataSourceConfig::class.java) + + private val contextRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(SovereignJdbcPersistenceAutoConfiguration::class.java), + ).withUserConfiguration(TestDataSourceConfig::class.java) // ── type=memory does not create JDBC stores ────────────────────── @@ -108,12 +112,10 @@ class SovereignJdbcPersistenceAutoConfigurationTest { ApplicationContextRunner() .withConfiguration( AutoConfigurations.of(SovereignJdbcPersistenceAutoConfiguration::class.java), - ) - .withPropertyValues( + ).withPropertyValues( "tramai.sovereign.persistence.type=jdbc", "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -146,8 +148,7 @@ class SovereignJdbcPersistenceAutoConfigurationTest { "tramai.sovereign.persistence.type=jdbc", "tramai.sovereign.persistence.encryption.key-env=MY_KEY", "tramai.sovereign.persistence.encryption.key-file=/tmp/some-key-file", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -163,8 +164,7 @@ class SovereignJdbcPersistenceAutoConfigurationTest { .withPropertyValues( "tramai.sovereign.persistence.type=jdbc", "tramai.sovereign.persistence.encryption.key-env=TRAMAI_TEST_NONEXISTENT_KEY_98765", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -183,8 +183,7 @@ class SovereignJdbcPersistenceAutoConfigurationTest { .withPropertyValues( "tramai.sovereign.persistence.type=jdbc", "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -205,8 +204,7 @@ class SovereignJdbcPersistenceAutoConfigurationTest { .withPropertyValues( "tramai.sovereign.persistence.type=jdbc", "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasFailed() val failure = requireNotNull(ctx.startupFailure) assertThat(failure) @@ -222,11 +220,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(ApprovalStore::class.java) val store = ctx.getBean(ApprovalStore::class.java) assertThat(store).isExactlyInstanceOf(JdbcApprovalStore::class.java) @@ -239,33 +237,35 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(SovereignOpsApprovalRequestMutationStore::class.java) val store = ctx.getBean(SovereignOpsApprovalRequestMutationStore::class.java) - assertThat(store).isExactlyInstanceOf(JdbcSovereignOpsApprovalRequestMutationStore::class.java) + // Exposed through the governed wrapper so the default wiring satisfies the governed + // capability the transactional gateway requires; the delegate is the JDBC store. + assertThat(store).isInstanceOf(GovernedJdbcSovereignOpsApprovalRequestMutationStore::class.java) } } // ── valid config creates SuspendedInvocationStore ───────────────── @Test - fun `valid config creates SuspendedInvocationStore instance of JdbcSuspendedInvocationStore`() { + fun `valid config creates a suspended invocation store with the governed capability`() { val keyFile = prepareKeyFile() contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(SuspendedInvocationStore::class.java) val store = ctx.getBean(SuspendedInvocationStore::class.java) - assertThat(store).isExactlyInstanceOf(JdbcSuspendedInvocationStore::class.java) + assertThat(store).isInstanceOf(GovernedJdbcSuspendedInvocationStore::class.java) } } @@ -277,11 +277,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(ApprovalContinuationStore::class.java) val store = ctx.getBean(ApprovalContinuationStore::class.java) assertThat(store).isExactlyInstanceOf(JdbcApprovalContinuationStore::class.java) @@ -296,11 +296,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(AuditStore::class.java) val store = ctx.getBean(AuditStore::class.java) assertThat(store).isExactlyInstanceOf(JdbcAuditStore::class.java) @@ -315,11 +315,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(SovereignOpsAuditOutboxStore::class.java) val store = ctx.getBean(SovereignOpsAuditOutboxStore::class.java) assertThat(store).isExactlyInstanceOf( @@ -336,11 +336,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> val store = ctx.getBean(SovereignOpsAuditOutboxStore::class.java) assertThat(store.isDurable()).isTrue } @@ -354,11 +354,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(ApprovalStore::class.java) assertThat(ctx).hasSingleBean(SuspendedInvocationStore::class.java) assertThat(ctx).hasSingleBean(ApprovalContinuationStore::class.java) @@ -376,11 +376,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(JdbcAuditPayloadCodec::class.java) assertThat(ctx).hasSingleBean(JdbcReplayEnvelopeCodec::class.java) assertThat(ctx).hasSingleBean(JdbcContinuationArgumentsCodec::class.java) @@ -394,26 +394,25 @@ class SovereignJdbcPersistenceAutoConfigurationTest { fun `base starter uses JDBC stores instead of in memory`() { val keyFile = prepareKeyFile() - val combinedRunner = ApplicationContextRunner() - .withConfiguration( - AutoConfigurations.of( - SovereignJdbcPersistenceAutoConfiguration::class.java, - dev.tramai.spring.sovereign.SovereignTramaiAutoConfiguration::class.java, - ), - ) - .withUserConfiguration( - TestDataSourceConfig::class.java, - MinimalProviderConfig::class.java, - ) - .withPropertyValues( - "tramai.sovereign.enabled=true", - "tramai.sovereign.allowed-models[0]=local-model", - "tramai.sovereign.allowed-providers[0]=local-provider", - "tramai.sovereign.provider-zones.local-provider=LOCAL", - "tramai.sovereign.models.local-model=local-provider", - "tramai.sovereign.persistence.type=jdbc", - "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", - ) + val combinedRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SovereignJdbcPersistenceAutoConfiguration::class.java, + dev.tramai.spring.sovereign.SovereignTramaiAutoConfiguration::class.java, + ), + ).withUserConfiguration( + TestDataSourceConfig::class.java, + MinimalProviderConfig::class.java, + ).withPropertyValues( + "tramai.sovereign.enabled=true", + "tramai.sovereign.allowed-models[0]=local-model", + "tramai.sovereign.allowed-providers[0]=local-provider", + "tramai.sovereign.provider-zones.local-provider=LOCAL", + "tramai.sovereign.models.local-model=local-provider", + "tramai.sovereign.persistence.type=jdbc", + "tramai.sovereign.persistence.encryption.key-file=${keyFile.toAbsolutePath()}", + ) combinedRunner.run { ctx -> // AuditStore should be JDBC-backed, NOT in-memory @@ -445,11 +444,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomAuditStoreConfig::class.java) .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(AuditStore::class.java)).hasSize(1) val store = ctx.getBean(AuditStore::class.java) assertThat(store).isInstanceOf(CustomAuditStore::class.java) @@ -463,11 +462,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomApprovalStoreConfig::class.java) .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(ApprovalStore::class.java)).hasSize(1) val store = ctx.getBean(ApprovalStore::class.java) assertThat(store).isInstanceOf(CustomApprovalStore::class.java) @@ -481,11 +480,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomOutboxStoreConfig::class.java) .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(SovereignOpsAuditOutboxStore::class.java)).hasSize(1) val store = ctx.getBean(SovereignOpsAuditOutboxStore::class.java) assertThat(store).isInstanceOf(CustomOutboxStore::class.java) @@ -499,11 +498,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomAuditPayloadCodecConfig::class.java) .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(JdbcAuditPayloadCodec::class.java)).hasSize(1) val codec = ctx.getBean(JdbcAuditPayloadCodec::class.java) assertThat(codec).isInstanceOf(CustomAuditPayloadCodec::class.java) @@ -518,11 +517,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *jdbcPropsWithOutboxConfig(keyFile).entries + *jdbcPropsWithOutboxConfig(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> val props = ctx.getBean(SovereignJdbcPersistenceProperties::class.java) assertThat(props.jdbc.claimLeaseDuration).isEqualTo(Duration.ofMinutes(10)) } @@ -534,11 +533,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *jdbcPropsWithOutboxConfig(keyFile).entries + *jdbcPropsWithOutboxConfig(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> val props = ctx.getBean(SovereignJdbcPersistenceProperties::class.java) assertThat(props.jdbc.maxClaimLimit).isEqualTo(1000) } @@ -553,14 +552,13 @@ class SovereignJdbcPersistenceAutoConfigurationTest { ApplicationContextRunner() .withConfiguration( AutoConfigurations.of(SovereignJdbcPersistenceAutoConfiguration::class.java), - ) - .withUserConfiguration(TestDataSourceConfig::class.java, UnrelatedSecretKeyConfig::class.java) + ).withUserConfiguration(TestDataSourceConfig::class.java, UnrelatedSecretKeyConfig::class.java) .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> // Context starts successfully despite an unrelated SecretKey bean assertThat(ctx).hasSingleBean(JdbcAuditPayloadCodec::class.java) assertThat(ctx).hasSingleBean(JdbcReplayEnvelopeCodec::class.java) @@ -578,11 +576,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(SovereignOpsWorkerLeaseStore::class.java) val store = ctx.getBean(SovereignOpsWorkerLeaseStore::class.java) assertThat(store).isExactlyInstanceOf(JdbcSovereignOpsWorkerLeaseStore::class.java) @@ -595,11 +593,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx).hasSingleBean(SovereignOpsApprovalMutationStore::class.java) val store = ctx.getBean(SovereignOpsApprovalMutationStore::class.java) assertThat(store).isExactlyInstanceOf(JdbcSovereignOpsApprovalMutationStore::class.java) @@ -613,11 +611,11 @@ class SovereignJdbcPersistenceAutoConfigurationTest { contextRunner .withUserConfiguration(CustomLeaseStoreConfig::class.java) .withPropertyValues( - *validJdbcProps(keyFile).entries + *validJdbcProps(keyFile) + .entries .map { "${it.key}=${it.value}" } .toTypedArray(), - ) - .run { ctx -> + ).run { ctx -> assertThat(ctx.getBeansOfType(SovereignOpsWorkerLeaseStore::class.java)).hasSize(1) val store = ctx.getBean(SovereignOpsWorkerLeaseStore::class.java) assertThat(store).isInstanceOf(CustomLeaseStore::class.java) @@ -693,14 +691,24 @@ open class UnrelatedSecretKeyConfig { */ class NoOpDataSource : DataSource { override fun getConnection(): Connection = throw UnsupportedOperationException("NoOpDataSource") - override fun getConnection(username: String, password: String): Connection = - throw UnsupportedOperationException("NoOpDataSource") + + override fun getConnection( + username: String, + password: String, + ): Connection = throw UnsupportedOperationException("NoOpDataSource") + override fun getLogWriter(): PrintWriter? = null + override fun setLogWriter(out: PrintWriter?) = Unit + override fun setLoginTimeout(seconds: Int) = Unit + override fun getLoginTimeout(): Int = 0 + override fun getParentLogger(): Logger = throw UnsupportedOperationException("NoOpDataSource") + override fun unwrap(iface: Class): T = throw UnsupportedOperationException("NoOpDataSource") + override fun isWrapperFor(iface: Class<*>): Boolean = false } @@ -710,69 +718,67 @@ class CustomAuditStore : AuditStore { override suspend fun appendNext( auditStreamId: String, eventFactory: (AuditEvent?) -> AuditEvent, - ): AuditEvent = eventFactory( - AuditEvent( - schemaVersion = 1, - hashAlgorithm = dev.tramai.security.audit.AuditHashAlgorithm.SHA_256, - auditStreamId = auditStreamId, - eventId = "custom-test-event", - sequenceNumber = 1, - workflowRunId = null, - correlationId = null, - actor = "test", - enforcementPoint = "test", - decision = "permit", - policyVersion = null, - workflowDigest = null, - previousEventHash = null, - eventHash = "a".repeat(64), - timestamp = Instant.now(), - reasonCode = null, - ), - ) + ): AuditEvent = + eventFactory( + AuditEvent( + schemaVersion = 1, + hashAlgorithm = dev.tramai.security.audit.AuditHashAlgorithm.SHA_256, + auditStreamId = auditStreamId, + eventId = "custom-test-event", + sequenceNumber = 1, + workflowRunId = null, + correlationId = null, + actor = "test", + enforcementPoint = "test", + decision = "permit", + policyVersion = null, + workflowDigest = null, + previousEventHash = null, + eventHash = "a".repeat(64), + timestamp = Instant.now(), + reasonCode = null, + ), + ) + override suspend fun readStream(auditStreamId: String): List = emptyList() + override suspend fun latestEvent(auditStreamId: String): AuditEvent? = null } class CustomApprovalStore : ApprovalStore { private var created = false - override suspend fun create( - request: dev.tramai.core.approval.ApprovalRequest, - ): dev.tramai.core.approval.ApprovalRequest { - created = true - return request - } + + override suspend fun create(request: ApprovalRequest): ApprovalRequest = + run { + created = true + request + } + override suspend fun get(approvalId: String): dev.tramai.core.approval.ApprovalRequest? = null + override suspend fun transition( approvalId: String, expectedVersion: Long, transition: dev.tramai.core.approval.ApprovalTransition, - ): dev.tramai.core.approval.ApprovalRequest { - throw UnsupportedOperationException("custom stub") - } + ): dev.tramai.core.approval.ApprovalRequest = throw UnsupportedOperationException("custom stub") + override suspend fun consumeApprovedOrReplay( approvalId: String, expectedVersion: Long, presentedTokenDigest: dev.tramai.core.approval.Sha256Digest, consumedBy: String, - ): dev.tramai.core.approval.ApprovalConsumptionReceipt { - throw UnsupportedOperationException("custom stub") - } + ): dev.tramai.core.approval.ApprovalConsumptionReceipt = throw UnsupportedOperationException("custom stub") } class CustomOutboxStore : SovereignOpsAuditOutboxStore { override fun isDurable(): Boolean = true - override suspend fun append( - record: SovereignOpsAuditOutboxRecord, - ): SovereignOpsAuditOutboxRecord = record + override suspend fun append(record: SovereignOpsAuditOutboxRecord): SovereignOpsAuditOutboxRecord = record override suspend fun markReadyForDispatch( outboxId: String, expectedStatus: SovereignOpsAuditOutboxStatus, - ): SovereignOpsAuditOutboxRecord { - throw UnsupportedOperationException("custom stub") - } + ): SovereignOpsAuditOutboxRecord = throw UnsupportedOperationException("custom stub") override suspend fun claimPending( claimedBy: String, @@ -785,9 +791,7 @@ class CustomOutboxStore : SovereignOpsAuditOutboxStore { expectedStatus: SovereignOpsAuditOutboxStatus, expectedAttemptCount: Int, emittedAt: Instant, - ): SovereignOpsAuditOutboxRecord { - throw UnsupportedOperationException("custom stub") - } + ): SovereignOpsAuditOutboxRecord = throw UnsupportedOperationException("custom stub") override suspend fun markFailed( outboxId: String, @@ -795,17 +799,19 @@ class CustomOutboxStore : SovereignOpsAuditOutboxStore { expectedAttemptCount: Int, errorCode: String, retryable: Boolean, - ): SovereignOpsAuditOutboxRecord { - throw UnsupportedOperationException("custom stub") - } + ): SovereignOpsAuditOutboxRecord = throw UnsupportedOperationException("custom stub") override suspend fun get(outboxId: String): SovereignOpsAuditOutboxRecord? = null + override suspend fun findByEventKey(eventKey: String): SovereignOpsAuditOutboxRecord? = null + override suspend fun listPending(limit: Int): List = emptyList() + override suspend fun listByStatus( status: SovereignOpsAuditOutboxStatus, limit: Int, ): List = emptyList() + override suspend fun listExpiredEmitting( now: Instant, limit: Int, @@ -821,8 +827,8 @@ class CustomAuditPayloadCodec : JdbcAuditPayloadCodec { nonce = ByteArray(12), payloadDigest = "custom", ) - override fun decode(envelope: dev.tramai.persistence.jdbc.JdbcEncryptedAuditPayload): ByteArray = - envelope.ciphertext + + override fun decode(envelope: JdbcEncryptedAuditPayload): ByteArray = envelope.ciphertext } // ── Worker lease store stubs ─────────────────────────────────────── @@ -854,15 +860,13 @@ class CustomLeaseStore : SovereignOpsWorkerLeaseStore { now: Instant, ) = throw UnsupportedOperationException("custom stub") - override suspend fun get(leaseName: String) = - throw UnsupportedOperationException("custom stub") + override suspend fun get(leaseName: String) = throw UnsupportedOperationException("custom stub") } class StubModelProvider : dev.tramai.core.provider.ModelProvider { override fun providerId(): String = "local-provider" - override suspend fun complete( - request: dev.tramai.core.model.ModelRequest, - ): dev.tramai.core.model.ModelResponse = - dev.tramai.core.model.ModelResponse(content = "stub response") + override suspend fun complete(request: dev.tramai.core.model.ModelRequest): dev.tramai.core.model.ModelResponse = + dev.tramai.core.model + .ModelResponse(content = "stub response") } diff --git a/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignOpsTransactionalGatewayGovernedWiringTest.kt b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignOpsTransactionalGatewayGovernedWiringTest.kt new file mode 100644 index 000000000..d5d87e46e --- /dev/null +++ b/tramai-spring-boot-starter-sovereign-persistence-jdbc/src/test/kotlin/dev/tramai/spring/sovereign/persistence/jdbc/SovereignOpsTransactionalGatewayGovernedWiringTest.kt @@ -0,0 +1,400 @@ +@file:OptIn(ExperimentalTramaiInternalApi::class) + +package dev.tramai.spring.sovereign.persistence.jdbc + +import dev.tramai.core.approval.ApprovalBinding +import dev.tramai.core.approval.ApprovalContinuation +import dev.tramai.core.approval.ApprovalContinuationStatus +import dev.tramai.core.approval.ApprovalRequest +import dev.tramai.core.approval.ApprovalStatus +import dev.tramai.core.approval.SensitiveToolArguments +import dev.tramai.core.approval.Sha256Digest +import dev.tramai.core.approval.gateway.ApprovalGateway +import dev.tramai.core.approval.gateway.ApprovalRecommendation +import dev.tramai.core.approval.gateway.ApprovalRequestResult +import dev.tramai.core.approval.gateway.ApprovalSubject +import dev.tramai.core.approval.gateway.ApproverRole +import dev.tramai.core.approval.gateway.ResumeToken +import dev.tramai.core.approval.gateway.WorkflowRunId +import dev.tramai.core.identity.ConfigurationId +import dev.tramai.core.identity.ConfigurationVersion +import dev.tramai.core.identity.DeploymentId +import dev.tramai.core.identity.EnvironmentId +import dev.tramai.core.identity.GovernedRunIdentity +import dev.tramai.core.identity.GovernedRunScope +import dev.tramai.core.identity.RunId +import dev.tramai.core.identity.WorkloadConfigurationIdentity +import dev.tramai.core.identity.WorkloadDeploymentIdentity +import dev.tramai.core.identity.WorkloadId +import dev.tramai.core.model.Message +import dev.tramai.core.model.MessageRole +import dev.tramai.core.model.ToolCall +import dev.tramai.core.observation.secondary.ExperimentalTramaiInternalApi +import dev.tramai.engine.EngineExecutionIdentity +import dev.tramai.engine.ExecutionSecurityContext +import dev.tramai.engine.GovernedSuspendedInvocationStore +import dev.tramai.engine.ReplayEnvelopeDigestHelper +import dev.tramai.engine.ResumeOperationReference +import dev.tramai.engine.ResumeToolReference +import dev.tramai.engine.SensitiveReplayEnvelope +import dev.tramai.engine.SuspendedInvocationMetadata +import dev.tramai.engine.SuspendedInvocationStore +import dev.tramai.engine.approval.ApprovalGatewayPersistenceRequest +import dev.tramai.engine.approval.ApprovalGatewayRequestFactory +import dev.tramai.engine.approval.encodeApprovalAttribution +import dev.tramai.spring.sovereign.ops.ApprovalGatewayAutoConfiguration +import dev.tramai.spring.sovereign.ops.outbox.GovernedSovereignOpsApprovalRequestMutationStore +import dev.tramai.spring.sovereign.ops.outbox.SovereignOpsApprovalRequestMutationStore +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.postgresql.ds.PGSimpleDataSource +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.SpringBootConfiguration +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.postgresql.PostgreSQLContainer +import java.time.Instant +import javax.sql.DataSource + +/** + * Proves the governed capability is reachable through the DEFAULT production entry point. + * + * The lower-level suites prove the governed mutation machinery works when it is constructed and + * handed to the gateway directly. That is not the same claim: a perfectly correct governed store + * that auto-configuration never exposes, and a gateway that rejects governed runs before consulting + * it, both leave production without the carriage 0.7.1d1 requires. This test therefore starts at the + * auto-configured gateway, inside a real [GovernedRunScope], against the store the auto-configuration + * itself produces, and asserts the identity that actually reached durable state. + */ +@SpringBootTest(classes = [GovernedGatewayWiringTestConfig::class]) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SovereignOpsTransactionalGatewayGovernedWiringTest { + @Autowired + private lateinit var gatewayProvider: ObjectProvider + + @Autowired + private lateinit var mutationStoreProvider: ObjectProvider + + @Autowired + private lateinit var suspensionStore: SuspendedInvocationStore + + private val gateway: ApprovalGateway get() = requireNotNull(gatewayProvider.ifAvailable) + private val mutationStore: SovereignOpsApprovalRequestMutationStore + get() = requireNotNull(mutationStoreProvider.ifAvailable) + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + private val identity = + GovernedRunIdentity( + deployment = + WorkloadDeploymentIdentity( + workloadId = WorkloadId("claims-intake"), + configuration = + WorkloadConfigurationIdentity( + id = ConfigurationId("claim-triage"), + version = ConfigurationVersion("7"), + ), + environmentId = EnvironmentId("prod-eu"), + deploymentId = DeploymentId("deploy-42"), + ), + runId = RunId("governed-run-1"), + ) + + @BeforeAll + fun runMigrations() { + listOf( + "V1__sovereign_persistence.sql", + "V2__approval_continuations.sql", + "V4__audit_outbox_hardening.sql", + "V6__approval_resume_credential_custody.sql", + "V7__approval_continuations_resume_retry.sql", + ).forEach { file -> + val sql = + javaClass.classLoader + .getResourceAsStream("tramai/persistence/jdbc/postgres/$file") + ?.bufferedReader() + ?.readText() + ?: error("Migration not found: $file") + runCatching { jdbcTemplate.execute(sql) } + } + } + + @AfterAll + fun stopPostgres() { + postgres.stop() + } + + @BeforeEach + fun cleanUp() { + jdbcTemplate.execute( + "TRUNCATE TABLE approval_continuations, suspended_invocations, audit_outbox, approvals CASCADE", + ) + } + + @Test + fun `auto-configuration exposes the governed mutation capability`() { + assertThat(mutationStore).isInstanceOf(GovernedSovereignOpsApprovalRequestMutationStore::class.java) + } + + @Test + fun `governed run suspends through the auto-configured gateway and persists canonical identity`() { + val approvalId = "governed-wiring-1" + + val result = + runBlocking { + withContext(GovernedRunScope(identity)) { + gateway.requestApproval( + subject = ApprovalSubject(approvalId), + recommendation = + ApprovalRecommendation( + type = "claim-review", + summary = "governed wiring", + payload = emptyMap(), + ), + requiredRole = ApproverRole("medical-reviewer"), + workflowRunId = WorkflowRunId(identity.runId.value), + ) + } + } + + assertThat(result).isInstanceOf(ApprovalRequestResult.Suspended::class.java) + + // (1) The approval-row attribution snapshot decoded from durable state is the canonical + // identity, five components and no run-id copy. + val metadata = + com.fasterxml.jackson.databind + .ObjectMapper() + .readTree( + jdbcTemplate.queryForObject( + "SELECT sanitized_metadata FROM approvals WHERE approval_id = ?", + String::class.java, + approvalId, + ), + ) + val attribution = metadata["attribution"] + assertThat(attribution).isNotNull + encodeApprovalAttribution(identity).forEach { (key, value) -> + assertThat(attribution!![key]?.asText()) + .describedAs("attribution component %s", key) + .isEqualTo(value) + } + assertThat(attribution!!.has(identity.runId.value)).isFalse + + // (2) The suspension is the canonical full identity record for the same run. + val suspensions = suspensionStore + assertThat(suspensions).isInstanceOf(GovernedSuspendedInvocationStore::class.java) + val persistedIdentity = + runBlocking { (suspensions as GovernedSuspendedInvocationStore).governedRunIdentity(approvalId) } + assertThat(persistedIdentity).isEqualTo(identity) + } + + companion object { + private val postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("sovereign_ops_gateway_wiring_test") + + /** A real 256-bit AES key on disk: the production key-file source, not a test-only shortcut. */ + private val keyFile: java.nio.file.Path = + java.nio.file.Files + .createTempFile("tramai-wiring-key", ".b64") + .also { + java.nio.file.Files.write( + it, + java.util.Base64 + .getEncoder() + .encodeToString(ByteArray(32) { 7 }) + .toByteArray(), + ) + } + + @JvmStatic + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + if (!postgres.isRunning) postgres.start() + registry.add("spring.datasource.url") { postgres.jdbcUrl } + registry.add("spring.datasource.username") { postgres.username } + registry.add("spring.datasource.password") { postgres.password } + // The JDBC persistence auto-configuration is gated on this; without it the whole + // governed store wiring is skipped and the test would prove nothing. + registry.add("tramai.sovereign.persistence.type") { "jdbc" } + registry.add("tramai.sovereign.persistence.encryption.key-file") { keyFile.toString() } + } + } +} + +/** + * Imported first so its DataSource bean definition exists before the auto-configurations' condition + * evaluation — the missing-datasource guard is `@ConditionalOnMissingBean` and would otherwise match + * at parse time and fail the context. + */ +@SpringBootConfiguration +private open class GovernedGatewayDataSourceConfig { + @Bean + open fun dataSource( + @Value("\${spring.datasource.url}") url: String, + @Value("\${spring.datasource.username}") username: String, + @Value("\${spring.datasource.password}") password: String, + ): DataSource = + PGSimpleDataSource().apply { + setUrl(url) + setUser(username) + setPassword(password) + } + + @Bean + open fun approvalGatewayRequestFactory(): ApprovalGatewayRequestFactory = GovernedWiringRequestFactory() + + @Bean + open fun jdbcTemplate(dataSource: DataSource): JdbcTemplate = JdbcTemplate(dataSource) +} + +@SpringBootConfiguration +@Import( + GovernedGatewayDataSourceConfig::class, + SovereignJdbcPersistenceAutoConfiguration::class, + ApprovalGatewayAutoConfiguration::class, +) +private open class GovernedGatewayWiringTestConfig + +/** + * Identity-blind, like every production factory: it echoes the run id it is handed and never + * nominates attribution. A governed run id therefore has exactly one source — the gateway. + */ +private class GovernedWiringRequestFactory : ApprovalGatewayRequestFactory { + private fun digest(ch: Char): Sha256Digest = Sha256Digest.of("sha256:${ch.toString().repeat(64)}") + + private fun messages(approvalId: String): List = + listOf( + Message(role = MessageRole.USER, content = "request-$approvalId"), + Message( + role = MessageRole.ASSISTANT, + content = "", + toolCalls = + listOf( + ToolCall( + id = "tool-call-$approvalId", + name = "tool-$approvalId", + argumentsJson = "__redacted_approval_continuation_args__", + ), + ), + ), + ) + + override suspend fun createRequest( + subject: ApprovalSubject, + recommendation: ApprovalRecommendation, + requiredRole: ApproverRole, + workflowRunId: WorkflowRunId?, + ): ApprovalGatewayPersistenceRequest { + val now: Instant = Instant.now() + val approvalId = subject.value + // The factory echoes the run id it is handed and nominates nothing: a governed run id has + // exactly one source, the gateway's resolved canonical identity. + val runId = workflowRunId?.value ?: "wf-$approvalId" + val correlationId = "corr-$approvalId" + val messages = messages(approvalId) + val operationReference = + ResumeOperationReference("t.Service", "approve", "(Ljava/lang/String;)V", digest('d')) + + return ApprovalGatewayPersistenceRequest( + approvalRequest = approvalRequest(approvalId, runId, now), + continuation = continuation(approvalId, runId, now), + sensitiveArguments = SensitiveToolArguments.of("""{"claimId":"$approvalId"}"""), + suspendedInvocationMetadata = + suspendedMetadata(approvalId, runId, correlationId, messages, operationReference), + replayEnvelope = SensitiveReplayEnvelope.of(messages), + resumeToken = ResumeToken("resume-$approvalId"), + ) + } + + private fun approvalRequest( + approvalId: String, + runId: String, + now: Instant, + ): ApprovalRequest = + ApprovalRequest( + approvalId = approvalId, + binding = + ApprovalBinding( + workflowRunId = runId, + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + approvalTokenDigest = digest('c'), + ), + status = ApprovalStatus.PENDING, + requestedBy = "requestor-$approvalId", + requestedAt = now, + expiresAt = now.plusSeconds(600), + decidedBy = null, + decidedAt = null, + decisionComment = null, + consumedBy = null, + consumedAt = null, + version = 0L, + ) + + private fun continuation( + approvalId: String, + runId: String, + now: Instant, + ): ApprovalContinuation = + ApprovalContinuation( + approvalId = approvalId, + workflowRunId = runId, + correlationId = "corr-$approvalId", + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + argumentsDigest = digest('a'), + policyVersion = "policy-v1", + workflowDigest = digest('b'), + status = ApprovalContinuationStatus.PENDING, + createdAt = now, + approvalExpiresAt = now.plusSeconds(600), + claimedBy = null, + claimedAt = null, + completedAt = null, + version = 0L, + ) + + private fun suspendedMetadata( + approvalId: String, + runId: String, + correlationId: String, + messages: List, + operationReference: ResumeOperationReference, + ): SuspendedInvocationMetadata = + SuspendedInvocationMetadata( + approvalId = approvalId, + toolCallId = "tool-call-$approvalId", + toolName = "tool-$approvalId", + toolCallIndex = 0, + correlationId = correlationId, + identity = + EngineExecutionIdentity( + workflowRunId = runId, + correlationId = correlationId, + workflowDigest = digest('b'), + policyVersion = "policy-v1", + actorId = "requestor-$approvalId", + ), + securityContext = ExecutionSecurityContext(), + operationReference = operationReference, + replayEnvelopeDigest = ReplayEnvelopeDigestHelper.compute(operationReference, messages), + toolReference = ResumeToolReference("tool-$approvalId", digest('e')), + ) +}