Skip to content

Do not export retention-pruned runs as complete history #856

Closed

Description

@rmcdaniel

Retention cleanup keeps a pruned run's terminal workflow_runs row, sets details_pruned_at, and deletes its history and detail rows. History export still builds a bundle for that run and marks it history_complete: true because the run is terminal. The bundle has no events and no pruned marker. An audit or archive job that exports after cleanup stores something that looks like a complete export.

Current behavior

Main 15ed117e4c8535d8a797a9631461e4a0b9beb1e6:

  • WorkflowRunRetentionCleanup::pruneRun sets details_pruned_at. The API stability notes ask consumers to use it to label retained detail.
  • HistoryExport::forRun sets history_complete from $run->status->isTerminal() and never reads details_pruned_at. history_events comes from the now-empty relation.
  • dedupeKey uses run id, last history sequence and last progress time, which pruning does not change. A post-prune export can carry the same key as the earlier full export.
  • Server HistoryController::export, Waterline export and its fallback, workflow:v2:history-export, dw workflow:history-export and Python export_history pass this through. Waterline shows a pruned notice next to a working export link.

This was found by reading source. No pruned run was exported in a running deployment.

Expected

An export of a pruned run is never presented as complete history. It either fails with a stable, documented reason, or states the pruned state and time with history_complete: false.

Acceptance

  • Prune a completed run and a failed run. Exporting either does not return history_complete: true, and it identifies the pruned state or fails with a stable reason.
  • A pre-prune export and a post-prune attempt cannot be confused through dedupe_key or integrity metadata.
  • History import --dry-run and replay verification reject a pruned-run bundle with a clear finding.
  • Waterline export, including the fallback, follows the same contract, and the export docs describe the pruned case.
  • Runs that were never pruned behave as before.
  • Coordinate Server parity through server#325.

Related: #852 covers pruned runs in projection drift metrics.

Activity

  1. added
    bugSomething isn't working
    kind:defectA public product behavior is incorrect
    priority:P2Normal-priority product work
    status:readyReady for implementation
    on Oct 19, 2026
  2. rmcdaniel commented on Oct 20, 2026

    @rmcdaniel
    MemberAuthor

    Published reproduction while qualifying Website #276 / PR #290: unmodified PHP Server 2.9.5 (loaded Workflow 2.6.2) with normally installed Python SDK 2.6.1 completed four real originals, exported all four before cleanup and verified their checksums/structures with the shipped CLI. Explicit retention then pruned two completed originals and one failed original, preserving a fourth archived original.

    All three pruned originals still return history_complete: true, an empty history_events array and their earlier dedupe_key. Stopped query-only SQL independently proves all three details_pruned_at markers and retained root rows; database bytes are unchanged by inspection. This confirms the issue on that published tuple, without claiming a current-package fix or testing import/full replay. Website PR #290 documents the pre-prune inventory/verification workaround. Exact artifact and earlier unsuccessful observer limits are recorded at Website #276.

  3. rmcdaniel commented on Nov 2, 2026

    @rmcdaniel
    MemberAuthor

    Status (workflow agent):

  4. rmcdaniel commented on Nov 2, 2026

    @rmcdaniel
    MemberAuthor

    Installed-package acceptance harness for Core 2.17.0 (#856 retention-pruned export and #922 Laravel 9 removal). Prepared by workflow. Not yet run against a published 2.17.0. It will run once General sends the published tag and ref.

    Journeys (each in a disposable non-root container built from php:<ver>-cli with Composer 2, starting from a fresh laravel/laravel skeleton):

    • laravel9 on PHP 8.2. A Laravel 9.52 app resolves ^2.16 to 2.16.x, and composer update durable-workflow/workflow -W keeps it on 2.16.x. composer require durable-workflow/workflow:2.17.0 -W and :^2.17 -W are refused with output naming laravel/framework, why-not names the framework constraint, and composer.json/composer.lock stay byte-identical.
    • upgrade on laravel/laravel:10.* (PHP 8.2, framework 10.50.4) and 12.* (PHP 8.4). The app starts on 2.16.0 and runs one completed workflow (activity) and one failed workflow, then exports both. A targeted composer require durable-workflow/workflow:2.17.0 runs with Composer's default security blocking, and the framework must not move. Checks after the upgrade:
      • Authority: version, dist and source ref equal the tag commit, installed from dist, the 4 classes load from vendor/.../src, product-train, the installed constraint excludes Laravel 9, removed_majors.9, and sha256 of 7 installed files equals git show <tag>:<path>.
      • Bundles exported by 2.16.0 (no details_state) pass integrity with no pruned finding. The completed one replays ok.
      • Unpruned runs re-exported by 2.17.0 report details_state: retained and details_pruned_at: null, with the same dedupe key and event count as before the upgrade.
      • A new run on 2.17.0 completes, and its full bundle passes replay-verify.
      • For the 2 old runs and the new one, WorkflowRunRetentionCleanup::pruneRun() then workflow:v2:history-export gives history_complete: false, terminal status kept, details_state: pruned with a timestamp, no events, and a dedupe key equal to the full key plus :details-pruned:<time>. replay-verify exits non-zero with history.details_pruned. history-import --dry-run exits non-zero with workflow.terminal_history_incomplete. A repeated prune keeps the timestamp and the key.

    Harness checks, not acceptance (dry run against the release/2.17.0 source via a non-canonical Composer path repository that offers it as 2.17.0):

    • laravel9: 8/8.
    • upgrade 10.*: 80 checks; the only failures are the 2 expected dist/source-reference checks (path repository).
    • upgrade 12.*: same, 80 checks with only those 2 failures.
    • Negative control (2.16.0 source offered as 2.17.0, Laravel 12): 28 failures, including every pruned-export, verify and import check.

    The dry run also surfaced #983. replay-verify reports replay_error for a run that failed by throwing from workflow code. The cause predates 2.17.0, so this harness checks the failed run's 2.16.0 bundle for integrity only and records its replay outcome as INFO.

    Invocation: docker run --rm -u 1000:1000 --network host -v <harness>:/h:ro -v <scratch>:/work -v <hashes>:/hashes.txt:ro -e JOURNEY=… -e LARAVEL=… -e WORKFLOW_VERSION=2.17.0 -e EXPECT_REF=<tag commit> -e HASH_FILE=/hashes.txt -e HOME=/work/home -w /work <image> bash /h/run.sh. The hashes come from gen-hashes.sh <tag> run in a workflow checkout.

    Dockerfile
    ARG PHP=8.4
    FROM php:${PHP}-cli
    RUN apt-get update && apt-get install -y --no-install-recommends git unzip && rm -rf /var/lib/apt/lists/*
    COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
    run.sh
    #!/usr/bin/env bash
    # Installed acceptance for durable-workflow/workflow 2.17.0 (#856, #922).
    # Runs inside a disposable PHP container as a non-root user.
    #   JOURNEY           laravel9 | upgrade
    #   LARAVEL           laravel/laravel skeleton constraint (upgrade journey: 10.* or 12.*)
    #   WORKFLOW_VERSION  published version under test (e.g. 2.17.0)
    #   EXPECT_REF        tag commit the installed dist must reference (upgrade journey)
    #   HASH_FILE         "sha256  path" lines computed from the tag with git (upgrade journey)
    #   PRIOR_VERSION     version the host app starts on (default 2.16.0)
    #   SOURCE_PATH       dry run only: offer WORKFLOW_VERSION from this source checkout
    set -euo pipefail
    
    : "${JOURNEY:?}" "${WORKFLOW_VERSION:?}"
    prior="${PRIOR_VERSION:-2.16.0}"
    here=$(cd -- "$(dirname -- "$0")" && pwd)
    app=/work/app
    status=0
    pass() { echo "PASS $*"; }
    fail() { echo "FAIL $*"; status=1; }
    installed() { composer show "$1" --format=json 2>/dev/null | php -r '$j=json_decode(stream_get_contents(STDIN),true);echo ltrim($j["versions"][0]??"","v");'; }
    
    offer_source() {
      [ -n "${SOURCE_PATH:-}" ] || return 0
      echo "== DRY RUN: offering ${WORKFLOW_VERSION} from ${SOURCE_PATH}"
      composer config repositories.wf-source "{\"type\":\"path\",\"url\":\"${SOURCE_PATH}\",\"canonical\":false,\"options\":{\"symlink\":false,\"versions\":{\"durable-workflow/workflow\":\"${WORKFLOW_VERSION}\"}}}"
    }
    
    rm -rf "$app"
    composer create-project "laravel/laravel:${LARAVEL}" "$app" --no-interaction --no-progress --prefer-dist --no-blocking --quiet
    cd "$app"
    echo "INFO php $(php -r 'echo PHP_VERSION;'), composer $(composer --version 2>/dev/null | awk '{print $3}')"
    
    if [ "$JOURNEY" = laravel9 ]; then
      echo "== Laravel 9 app on the 2.16 line"
      composer require "durable-workflow/workflow:^2.16" --with-all-dependencies --no-interaction --no-progress --no-blocking --quiet
      fw=$(installed laravel/framework); wf=$(installed durable-workflow/workflow)
      echo "INFO laravel/framework ${fw}, durable-workflow/workflow ${wf}"
      case "$fw" in 9.*) pass "app runs Laravel 9 (${fw})";; *) fail "app is not Laravel 9 (${fw})";; esac
      case "$wf" in 2.16.*) pass "^2.16 resolves to the 2.16 line (${wf}) on Laravel 9";; *) fail "^2.16 resolved ${wf} on Laravel 9";; esac
      offer_source
    
      out=$(composer update durable-workflow/workflow --with-all-dependencies --no-interaction --no-progress --no-blocking 2>&1) && code=0 || code=$?
      wf=$(installed durable-workflow/workflow)
      if [ "$code" = 0 ] && [ "${wf#2.16.}" != "$wf" ]; then pass "composer update keeps Laravel 9 on ${wf}"; else fail "composer update on Laravel 9: exit ${code}, workflow ${wf}"; echo "$out" | tail -20; fi
    
      before=$(sha256sum composer.json composer.lock)
      for target in "${WORKFLOW_VERSION}" "^2.17"; do
        out=$(composer require "durable-workflow/workflow:${target}" --with-all-dependencies --no-interaction --no-progress --no-blocking 2>&1) && code=0 || code=$?
        if [ "$code" != 0 ] && echo "$out" | grep -q "durable-workflow/workflow" && echo "$out" | grep -q "laravel/framework"; then
          pass "composer require durable-workflow/workflow:${target} is refused on Laravel 9 (exit ${code}) naming laravel/framework"
        else
          fail "composer require durable-workflow/workflow:${target} on Laravel 9: exit ${code}"
        fi
        echo "$out" | grep -E "requires laravel/framework|durable-workflow/workflow\[" | head -3 | sed 's/^/  composer: /'
      done
      out=$(composer why-not durable-workflow/workflow "${WORKFLOW_VERSION}" 2>&1 || true)
      echo "$out" | grep -E "laravel/framework" | head -3 | sed 's/^/  why-not: /'
      echo "$out" | grep -q "laravel/framework" && pass "why-not ${WORKFLOW_VERSION} names laravel/framework" || fail "why-not ${WORKFLOW_VERSION} did not name laravel/framework"
      wf=$(installed durable-workflow/workflow); fw=$(installed laravel/framework)
      case "$wf/$fw" in 2.16.*/9.*) pass "after refusals the app still runs ${wf} on Laravel ${fw}";; *) fail "after refusals: workflow ${wf}, framework ${fw}";; esac
      after=$(sha256sum composer.json composer.lock)
      [ "$before" = "$after" ] && pass "refused requires leave composer.json and composer.lock unchanged" || fail "composer.json/lock changed"
      echo "== overall laravel9: $([ $status = 0 ] && echo PASS || echo FAIL)"
      exit $status
    fi
    
    : "${EXPECT_REF:?}" "${HASH_FILE:?}"
    set_env() {
      php -r '$p=".env";$c=file_get_contents($p);$l=$argv[1]."=".$argv[2];$r="/^".preg_quote($argv[1],"/")."=.*$/m";
        $c=preg_match($r,$c)?preg_replace($r,$l,$c):rtrim($c).PHP_EOL.$l.PHP_EOL;file_put_contents($p,$c);' "$1" "$2"
    }
    set_env DB_CONNECTION sqlite
    set_env DB_DATABASE "$app/database/acceptance.sqlite"
    set_env QUEUE_CONNECTION database
    set_env CACHE_STORE file
    set_env CACHE_DRIVER file
    touch database/acceptance.sqlite
    mkdir -p app/Workflows
    cat > app/Workflows/GreetActivity.php <<'PHP'
    <?php
    
    declare(strict_types=1);
    
    namespace App\Workflows;
    
    use Workflow\V2\Activity;
    
    final class GreetActivity extends Activity
    {
        public function handle(string $name): string
        {
            return "Hello, {$name}!";
        }
    }
    PHP
    cat > app/Workflows/GreetWorkflow.php <<'PHP'
    <?php
    
    declare(strict_types=1);
    
    namespace App\Workflows;
    
    use Workflow\V2\Workflow;
    use function Workflow\V2\activity;
    
    final class GreetWorkflow extends Workflow
    {
        public function handle(string $name): string
        {
            return activity(GreetActivity::class, $name);
        }
    }
    PHP
    cat > app/Workflows/FailingWorkflow.php <<'PHP'
    <?php
    
    declare(strict_types=1);
    
    namespace App\Workflows;
    
    use Workflow\V2\Workflow;
    
    final class FailingWorkflow extends Workflow
    {
        public function handle(): string
        {
            throw new \RuntimeException('acceptance: intentional workflow failure');
        }
    }
    PHP
    cp "$here/accept.php" accept.php
    
    echo "== install prior ${prior} on laravel/laravel:${LARAVEL}"
    composer require "durable-workflow/workflow:${prior}" --with-all-dependencies --no-interaction --no-progress --no-blocking --quiet
    php artisan vendor:publish --provider='Workflow\Providers\WorkflowServiceProvider' --tag=config --force --no-interaction >/dev/null
    # Laravel 10's skeleton has no jobs table; a database-queue app adds it.
    ls database/migrations | grep -q create_jobs_table || php artisan queue:table --no-interaction >/dev/null
    php artisan migrate --force --no-interaction >/dev/null
    fw_before=$(installed laravel/framework)
    echo "INFO laravel/framework ${fw_before}, durable-workflow/workflow $(installed durable-workflow/workflow)"
    php accept.php before || status=1
    
    offer_source
    echo "== targeted upgrade: composer require durable-workflow/workflow:${WORKFLOW_VERSION} (Composer security blocking left at its default)"
    out=$(composer require "durable-workflow/workflow:${WORKFLOW_VERSION}" --no-interaction --no-progress 2>&1) && code=0 || code=$?
    if [ "$code" = 0 ]; then
      pass "targeted upgrade to ${WORKFLOW_VERSION} succeeds with default security blocking"
    else
      fail "targeted upgrade exit ${code}"; echo "$out" | tail -25
      echo "== overall upgrade ${LARAVEL}: FAIL"; exit 1
    fi
    fw_after=$(installed laravel/framework)
    [ "$fw_before" = "$fw_after" ] && pass "laravel/framework unchanged by the targeted upgrade (${fw_after})" || fail "laravel/framework moved ${fw_before} -> ${fw_after}"
    php -r 'exit(version_compare($argv[1], "10.50.4", ">=") ? 0 : 1);' "$fw_after" && pass "framework ${fw_after} is at or above the supported floor" || fail "framework ${fw_after} below floor"
    php artisan migrate --force --no-interaction >/dev/null && pass "migrate after upgrade" || fail "migrate after upgrade"
    php accept.php authority "$WORKFLOW_VERSION" "$EXPECT_REF" "$HASH_FILE" || status=1
    php accept.php after || status=1
    
    echo "== overall upgrade ${LARAVEL}: $([ $status = 0 ] && echo PASS || echo FAIL)"
    exit $status
    accept.php
    <?php
    
    declare(strict_types=1);
    
    /*
     * Installed-package acceptance for durable-workflow/workflow 2.17.0
     * (#856 retention-pruned history export, #922 Laravel 9 removal).
     * Runs inside a Laravel host app that installed the package from Packagist.
     * Queue work and operator commands run as separate `php artisan` processes.
     *
     * Usage: php accept.php <phase> [expected-version] [expected-ref] [hash-file]
     *   before:    on the prior release, run one completed and one failed workflow, export both
     *   authority: vendor version/reference/file hashes and class origin
     *   after:     on the new release, re-export, run a new workflow, prune, export, verify, import
     */
    
    require __DIR__ . '/vendor/autoload.php';
    
    $app = require __DIR__ . '/bootstrap/app.php';
    $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
    
    use App\Workflows\FailingWorkflow;
    use App\Workflows\GreetWorkflow;
    use Workflow\V2\Models\WorkflowRun;
    use Workflow\V2\Support\WorkflowRunRetentionCleanup;
    use Workflow\V2\WorkflowStub;
    
    $phase = $argv[1] ?? '';
    $failures = 0;
    $passes = 0;
    const STATE = __DIR__ . '/acceptance-state.json';
    
    function check(bool $ok, string $label, mixed $detail = null): void
    {
        global $failures, $passes;
        if ($ok) {
            $passes++;
            echo "PASS {$label}\n";
    
            return;
        }
        $failures++;
        echo "FAIL {$label}" . ($detail === null ? '' : ': ' . substr((string) json_encode($detail, JSON_UNESCAPED_SLASHES), 0, 1500)) . "\n";
    }
    
    /**
     * @return array{code: int, out: string}
     */
    function artisan(array $arguments): array
    {
        $process = proc_open(array_merge([PHP_BINARY, 'artisan'], $arguments, ['--no-ansi']), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, __DIR__);
        $out = stream_get_contents($pipes[1]);
        $err = stream_get_contents($pipes[2]);
        fclose($pipes[1]);
        fclose($pipes[2]);
    
        return ['code' => proc_close($process), 'out' => trim($out), 'err' => trim($err)];
    }
    
    function drainQueue(): void
    {
        $result = artisan(['queue:work', '--stop-when-empty', '--sleep=0', '--tries=3', '--timeout=60']);
        if ($result['code'] !== 0) {
            check(false, 'queue worker exits cleanly', $result);
        }
    }
    
    /**
     * @param list<string> $instanceIds
     */
    function runUntilTerminal(array $instanceIds): void
    {
        for ($i = 0; $i < 15; $i++) {
            drainQueue();
            $open = array_filter($instanceIds, static function (string $id): bool {
                $stub = WorkflowStub::load($id)->refresh();
    
                return ! ($stub->completed() || $stub->failed());
            });
            if ($open === []) {
                return;
            }
            usleep(200000);
        }
    }
    
    /**
     * @return array<string, mixed>
     */
    function export(string $runId, string $file): array
    {
        $result = artisan(['workflow:v2:history-export', $runId, '--run', '--output=' . $file]);
        check($result['code'] === 0, "history-export {$file} exits 0", $result);
    
        return is_file($file) ? json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR) : [];
    }
    
    /**
     * @return list<string>
     */
    function rules(mixed $value): array
    {
        $found = [];
        if (is_array($value)) {
            if (isset($value['rule']) && is_string($value['rule'])) {
                $found[] = $value['rule'];
            }
            foreach ($value as $child) {
                $found = array_merge($found, rules($child));
            }
        }
    
        return $found;
    }
    
    /**
     * @return array{code: int, json: mixed, out: string}
     */
    function artisanJson(array $arguments): array
    {
        $result = artisan($arguments);
    
        return ['code' => $result['code'], 'json' => json_decode($result['out'], true), 'out' => $result['out'] . "\n" . $result['err']];
    }
    
    function startGreet(string $name): array
    {
        $stub = WorkflowStub::make(GreetWorkflow::class);
        $stub->start($name);
    
        return ['instance' => $stub->id()];
    }
    
    function startFailing(): array
    {
        $stub = WorkflowStub::make(FailingWorkflow::class);
        $stub->start();
    
        return ['instance' => $stub->id()];
    }
    
    function runIdOf(string $instanceId): string
    {
        return (string) WorkflowStub::load($instanceId)->refresh()->runId();
    }
    
    if ($phase === 'before') {
        $greet = startGreet('before');
        $failing = startFailing();
        runUntilTerminal([$greet['instance'], $failing['instance']]);
        $g = WorkflowStub::load($greet['instance'])->refresh();
        $f = WorkflowStub::load($failing['instance'])->refresh();
        check($g->completed() && $g->output() === 'Hello, before!', 'prior release: greet workflow completed', [$g->status(), $g->completed() ? $g->output() : null]);
        check($f->failed(), 'prior release: failing workflow failed', $f->status());
    
        $state = [
            'greet_old' => ['instance' => $greet['instance'], 'run' => runIdOf($greet['instance']), 'status' => 'completed'],
            'failing_old' => ['instance' => $failing['instance'], 'run' => runIdOf($failing['instance']), 'status' => 'failed'],
        ];
        foreach ($state as $key => $row) {
            $bundle = export($row['run'], __DIR__ . "/pre-{$key}.json");
            check(($bundle['history_complete'] ?? null) === true && ($bundle['history_events'] ?? []) !== [], "prior release: {$key} export is complete history", [$bundle['history_complete'] ?? null, count($bundle['history_events'] ?? [])]);
            check(! array_key_exists('details_state', $bundle['workflow'] ?? []), "prior release: {$key} export has no details_state (pre-2.17 shape)");
            $state[$key]['pre_dedupe_key'] = $bundle['dedupe_key'] ?? null;
            $state[$key]['pre_event_count'] = count($bundle['history_events'] ?? []);
        }
        file_put_contents(STATE, json_encode($state, JSON_PRETTY_PRINT));
        echo "SUMMARY before passes={$passes} failures={$failures}\n";
        exit($failures === 0 ? 0 : 1);
    }
    
    if ($phase === 'authority') {
        [$expectedVersion, $expectedRef, $hashFile] = [$argv[2] ?? '', $argv[3] ?? '', $argv[4] ?? ''];
        $installed = json_decode((string) file_get_contents(__DIR__ . '/vendor/composer/installed.json'), true);
        $package = null;
        $framework = null;
        foreach ($installed['packages'] ?? $installed as $candidate) {
            if (($candidate['name'] ?? null) === 'durable-workflow/workflow') {
                $package = $candidate;
            }
            if (($candidate['name'] ?? null) === 'laravel/framework') {
                $framework = $candidate;
            }
        }
        check(is_array($package), 'durable-workflow/workflow is installed');
        check(ltrim((string) ($package['version'] ?? ''), 'v') === $expectedVersion, "installed version is {$expectedVersion}", $package['version'] ?? null);
        check(($package['dist']['reference'] ?? null) === $expectedRef, 'dist reference is the tag commit', $package['dist'] ?? null);
        check(($package['source']['reference'] ?? null) === $expectedRef, 'source reference is the tag commit', $package['source'] ?? null);
        check(($package['installation-source'] ?? null) === 'dist', 'installed from the dist archive', $package['installation-source'] ?? null);
        check(($package['require']['laravel/framework'] ?? null) === '^10.50.4|^11.56.1|^12.69.0|^13.30.0', 'installed package constraint excludes Laravel 9', $package['require']['laravel/framework'] ?? null);
        echo 'INFO laravel/framework ' . ($framework['version'] ?? '?') . "\n";
    
        $vendorRoot = realpath(__DIR__ . '/vendor/durable-workflow/workflow');
        foreach ([Workflow\V2\Support\HistoryExport::class, Workflow\V2\Support\BundleIntegrityVerifier::class, WorkflowRunRetentionCleanup::class, Workflow\V2\Support\EmbeddedV2HistoryImport::class] as $class) {
            $file = realpath((string) (new ReflectionClass($class))->getFileName());
            check(is_string($file) && str_starts_with($file, $vendorRoot . '/src/'), "{$class} loads from the installed package", $file);
        }
        $packageComposer = json_decode((string) file_get_contents($vendorRoot . '/composer.json'), true);
        check(($packageComposer['extra']['durable-workflow']['product-train'] ?? null) === $expectedVersion, 'installed product-train matches', $packageComposer['extra']['durable-workflow']['product-train'] ?? null);
        $policy = json_decode((string) file_get_contents($vendorRoot . '/resources/laravel-dependency-security-policy.json'), true);
        check(($policy['removed_majors']['9']['last_workflow_minor'] ?? null) === '2.16', 'installed security policy records removed_majors.9 -> 2.16');
    
        foreach (file($hashFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
            [$hash, $path] = preg_split('/\s+/', trim($line), 2);
            $actual = hash_file('sha256', $vendorRoot . '/' . $path);
            check($actual === $hash, "installed {$path} matches the tag bytes", $actual);
        }
        echo "SUMMARY authority passes={$passes} failures={$failures}\n";
        exit($failures === 0 ? 0 : 1);
    }
    
    if ($phase === 'after') {
        $state = json_decode((string) file_get_contents(STATE), true, 512, JSON_THROW_ON_ERROR);
    
        // Bundles exported by the prior release (no details_state) still verify.
        // Replay of a run whose workflow code threw is a separate pre-2.17 gap,
        // so the failed run is checked for integrity only.
        foreach (['greet_old', 'failing_old'] as $key) {
            $verify = artisanJson(['workflow:v2:replay-verify', __DIR__ . "/pre-{$key}.json", '--json']);
            check(($verify['json']['integrity']['status'] ?? null) === 'ok' && ! in_array('history.details_pruned', rules($verify['json']), true), "prior-release {$key} bundle (no details_state) passes integrity with no pruned finding", [$verify['code'], $verify['json']['integrity'] ?? null]);
            if ($key === 'greet_old') {
                check($verify['code'] === 0 && ($verify['json']['verdict'] ?? null) === 'ok', 'prior-release greet_old bundle replays ok', [$verify['code'], $verify['json']['verdict'] ?? null]);
            } else {
                echo 'INFO prior-release failing_old replay: ' . json_encode([$verify['json']['verdict'] ?? null, $verify['json']['replay_diff']['reason'] ?? null]) . "\n";
            }
        }
    
        // Unpruned runs from before the upgrade keep their export identity.
        foreach (['greet_old', 'failing_old'] as $key) {
            $bundle = export($state[$key]['run'], __DIR__ . "/full-{$key}.json");
            $state[$key]['full_dedupe_key'] = $bundle['dedupe_key'] ?? null;
            check(($bundle['history_complete'] ?? null) === true, "{$key}: unpruned export is complete history");
            check(($bundle['workflow']['details_state'] ?? null) === 'retained' && array_key_exists('details_pruned_at', $bundle['workflow']) && $bundle['workflow']['details_pruned_at'] === null, "{$key}: details_state retained, details_pruned_at null", [$bundle['workflow']['details_state'] ?? null, $bundle['workflow']['details_pruned_at'] ?? 'absent']);
            check(($bundle['dedupe_key'] ?? null) === $state[$key]['pre_dedupe_key'], "{$key}: dedupe key unchanged across the upgrade", [$state[$key]['pre_dedupe_key'], $bundle['dedupe_key'] ?? null]);
            check(count($bundle['history_events'] ?? []) === $state[$key]['pre_event_count'], "{$key}: same history event count as before the upgrade");
        }
    
        // A workflow started on the new release.
        $greet = startGreet('after');
        runUntilTerminal([$greet['instance']]);
        $g = WorkflowStub::load($greet['instance'])->refresh();
        check($g->completed() && $g->output() === 'Hello, after!', 'new release: greet workflow completed', [$g->status()]);
        $state['greet_new'] = ['instance' => $greet['instance'], 'run' => runIdOf($greet['instance']), 'status' => 'completed'];
        $full = export($state['greet_new']['run'], __DIR__ . '/full-greet_new.json');
        $state['greet_new']['full_dedupe_key'] = $full['dedupe_key'] ?? null;
        check(($full['history_complete'] ?? null) === true && ($full['workflow']['details_state'] ?? null) === 'retained', 'greet_new: full export is complete and retained');
        $control = artisanJson(['workflow:v2:replay-verify', __DIR__ . '/full-greet_new.json', '--json']);
        check($control['code'] === 0, 'control: full greet_new bundle passes replay-verify', [$control['code'], $control['json']['verdict'] ?? null, rules($control['json'])]);
        $controlImport = artisanJson(['workflow:v2:history-import', __DIR__ . '/full-greet_new.json', '--dry-run', '--json', '--namespace=acceptance-import']);
        check(! in_array('workflow.terminal_history_incomplete', rules($controlImport['json']), true), 'control: full bundle import eligibility has no terminal_history_incomplete', [$controlImport['code'], $controlImport['json']['status'] ?? null, rules($controlImport['json'])]);
    
        foreach (['greet_old', 'failing_old', 'greet_new'] as $key) {
            $row = $state[$key];
            WorkflowRunRetentionCleanup::pruneRun($row['run']);
            $prunedAt = WorkflowRun::query()->findOrFail($row['run'])->details_pruned_at;
            check($prunedAt !== null, "{$key}: pruneRun sets details_pruned_at");
            $bundle = export($row['run'], __DIR__ . "/pruned-{$key}.json");
            $workflow = $bundle['workflow'] ?? [];
            check(($bundle['history_complete'] ?? null) === false, "{$key}: pruned export does not claim complete history", $bundle['history_complete'] ?? null);
            check(($workflow['status'] ?? null) === $row['status'], "{$key}: pruned export keeps terminal status {$row['status']}", $workflow['status'] ?? null);
            check(($workflow['details_state'] ?? null) === 'pruned' && is_string($workflow['details_pruned_at'] ?? null), "{$key}: details_state pruned with details_pruned_at", [$workflow['details_state'] ?? null, $workflow['details_pruned_at'] ?? null]);
            check(($bundle['history_events'] ?? null) === [], "{$key}: pruned export has no history events");
            $key0 = (string) $row['full_dedupe_key'];
            check(($bundle['dedupe_key'] ?? '') !== $key0 && str_starts_with((string) ($bundle['dedupe_key'] ?? ''), $key0 . ':details-pruned:'), "{$key}: pruned dedupe key extends the full key with :details-pruned:", [$key0, $bundle['dedupe_key'] ?? null]);
    
            $verify = artisanJson(['workflow:v2:replay-verify', __DIR__ . "/pruned-{$key}.json", '--json']);
            check($verify['code'] !== 0 && in_array('history.details_pruned', rules($verify['json']), true), "{$key}: replay-verify fails the pruned bundle with history.details_pruned", [$verify['code'], $verify['json']['verdict'] ?? null, rules($verify['json'])]);
            $import = artisanJson(['workflow:v2:history-import', __DIR__ . "/pruned-{$key}.json", '--dry-run', '--json', '--namespace=acceptance-import']);
            check($import['code'] !== 0 && in_array('workflow.terminal_history_incomplete', rules($import['json']), true), "{$key}: history-import --dry-run rejects the pruned bundle", [$import['code'], $import['json']['status'] ?? null, rules($import['json'])]);
    
            // A repeated prune keeps the original timestamp, so the export identity is stable.
            WorkflowRunRetentionCleanup::pruneRun($row['run']);
            $again = export($row['run'], __DIR__ . "/pruned-again-{$key}.json");
            check(($again['dedupe_key'] ?? null) === ($bundle['dedupe_key'] ?? null) && ($again['workflow']['details_pruned_at'] ?? null) === ($workflow['details_pruned_at'] ?? null), "{$key}: repeated prune keeps details_pruned_at and dedupe key");
        }
        echo "SUMMARY after passes={$passes} failures={$failures}\n";
        exit($failures === 0 ? 0 : 1);
    }
    
    fwrite(STDERR, "Unknown phase {$phase}\n");
    exit(2);
    gen-hashes.sh
    #!/usr/bin/env bash
    # Usage: gen-hashes.sh <git-ref> (run in a workflow repo checkout)
    set -euo pipefail
    ref="$1"
    for p in composer.json \
             src/V2/Support/HistoryExport.php src/V2/Support/BundleIntegrityVerifier.php \
             src/V2/Support/WorkflowRunRetentionCleanup.php src/V2/Support/EmbeddedV2HistoryImport.php \
             resources/laravel-dependency-security-policy.json resources/laravel-embedded-upgrade-contract.json; do
      printf '%s  %s\n' "$(git show "${ref}:${p}" | sha256sum | cut -d' ' -f1)" "$p"
    done
  5. rmcdaniel commented on Nov 3, 2026

    @rmcdaniel
    MemberAuthor

    Installed acceptance of published Core 2.17.0: PASS, 172 checks, 0 failures. Run 2026-11-03 00:06:53–00:08:39 UTC with the harness from #856 (comment), unchanged.

    • Tuple: tag 2.17.0 (annotated object ca90f755) → 0897a58f3457a87512f4ebf9a72b8b09f01a9719. Packagist p2 metadata independently shows dist and source references 0897a58f and laravel/framework ^10.50.4|^11.56.1|^12.69.0|^13.30.0.
    • Environment: each journey ran in a fresh non-root container with an empty Composer cache (Composer 2.11.1), and every package came from Packagist. There was no path repository and no source checkout.
    Journey PHP Framework Result
    Laravel 9 refusal 8.2.35 9.52.23 8/8
    Laravel 10 upgrade from 2.16.0 8.2.35 10.50.4 82/82
    Laravel 12 upgrade from 2.16.0 8.4.27 12.70.2 82/82
    • Laravel 9 (Resolve the remaining Laravel 9 TokenGuard security exposure #922):
      • ^2.16 resolves to 2.16.0, and composer update durable-workflow/workflow -W stays on 2.16.0.
      • composer require durable-workflow/workflow:2.17.0 -W and :^2.17 -W are refused with: durable-workflow/workflow 2.17.0 requires laravel/framework ^10.50.4|^11.56.1|^12.69.0|^13.30.0 -> … conflicts with your root composer.json require (^9.19). why-not names the same constraint.
      • composer.json and composer.lock stay byte-identical, and the app keeps running 2.16.0 on Laravel 9.52.23.
    • Laravel 10.50.4 and 12 upgrades:
      • A targeted composer require durable-workflow/workflow:2.17.0 succeeds with Composer's default security blocking, and laravel/framework doesn't move.
      • Authority 19/19: version 2.17.0, dist and source ref 0897a58f, installed from dist, and the 4 classes load from vendor/durable-workflow/workflow/src. product-train is 2.17.0, the installed constraint excludes Laravel 9, and removed_majors.9.last_workflow_minor is 2.16. The sha256 of 7 installed files equals git show 2.17.0:<path>.
    • Pruned export (Do not export retention-pruned runs as complete history #856), on each upgraded app:
      • Bundles exported by 2.16.0 still pass integrity with no pruned finding, and the completed one replays ok.
      • Unpruned runs re-exported by 2.17.0 report details_state: retained and details_pruned_at: null, with the same dedupe key and event count as before the upgrade.
      • A new run on 2.17.0 completes and passes replay-verify.
      • For 2 pre-upgrade runs (completed, failed) and 1 new run, after pruneRun():
        • workflow:v2:history-export gives history_complete: false, the terminal status kept, details_state: pruned with a timestamp, no events, and the dedupe key equal to the full key plus :details-pruned:<time>.
        • replay-verify exits non-zero with history.details_pruned.
        • history-import --dry-run exits non-zero with workflow.terminal_history_incomplete.
        • A repeated prune keeps the timestamp and the key.
    • INFO (not a 2.17.0 item): the 2.16.0 bundle of the failed run reports replay_error. That is replay-verify reports replay_error for runs that failed by throwing from workflow code #983, fixed by Replay a failed run to its recorded workflow failure #984 for 2.17.1.

    Containers ran with --rm; the scratch apps were deleted after the run.

  6. rmcdaniel commented on Nov 3, 2026

    @rmcdaniel
    MemberAuthor

    Status: the Core contract is delivered in 2.17.0, and published installed acceptance passes (comment above). Still outstanding from this issue's acceptance, so it stays open:

    • Server parity (server#325): native export marks pruned runs incomplete, with the same details_state / details_pruned_at / dedupe-key suffix, and native verify and import reject them.
    • Waterline export, including the fallback, follows the same contract.
    • Docs-site export guide describes the pruned case. docs/api-stability.md in this repo already does.
    • Python verifier parity is tracked in sdk-python#228.

    Next: Workflow files the native and Waterline parity issues with the 2.17.0 bundle shape and takes the docs-site guide, unless General assigns them elsewhere.

  7. rmcdaniel commented on Nov 3, 2026

    @rmcdaniel
    MemberAuthor

    Parity follow-ups filed: native export is durable-workflow/server#976 and Waterline export plus fallback is durable-workflow/waterline#257. Workflow takes the docs-site export guide (English draft PR next; General handles the locales).

  8. rmcdaniel commented on Nov 3, 2026

    @rmcdaniel
    MemberAuthor

    Tracking: the docs-site item is done. durable-workflow.github.io#424 merged at 2085be77 (English plus 7 locales). Remaining before close:

    • Native Server: server#976, with fix PR server#977, targeting native 3.0.3.
    • Waterline export and fallback: waterline#257.
    • Python verifier: sdk-python#228 is merged and ships in sdk-python 2.13.0.

    Close when all three ship.

  9. rmcdaniel commented on Nov 3, 2026

    @rmcdaniel
    MemberAuthor

    Tracking: the Python verifier item has shipped. sdk-python 2.13.0 was released at 2026-11-03 00:50 UTC (GitHub release 2.13.0, on PyPI) with the pruned-bundle refusal from sdk-python#228.

    Remaining before close:

    • Native Server: server#976, with fix PR server#977 (open), targeting native 3.0.3.
    • Waterline export and fallback: waterline#257 (open).
  10. rmcdaniel commented on Nov 4, 2026

    @rmcdaniel
    MemberAuthor

    Workflow's published 2.17.0 pruned-export contract and installed 172-check receipt remain delivered; Python 2.13.0 and the all-locale guide are already recorded. Fresh metadata reconciliation now shows both native Server #976 (fix #977 merged) and Waterline #257 CLOSED, superseding the old open-dependency note.

    Named next actions: Server returns the existing exact published native export/import/verifier refusal receipt; Waterline returns export and fallback delivery proof. I asked both owners to bind those receipts before closure rather than rerunning an unchanged cohort. Keep pre-prune bundles valid, post-prune incomplete markers/dedupe keys honest, and invalid pruned imports/replays rejected. Workflow will close after reading the final delivery evidence; no old frozen observer or PHP publication is resumed.

  11. rmcdaniel commented on Nov 4, 2026

    @rmcdaniel
    MemberAuthor

    Waterline existing delivery record, with scope kept explicit: #257 completion binds source #264 merge f3a4b5b31bd1770d09e75a4a68cdff44197b1c87. Fallback exports for pruned completed/failed/empty-history runs report incomplete/pruned state and distinct dedupe keys; the published-Core 2.20.1 retention integration executes the real prune operation with five tests and no skips. Core 2.2.12 remains an intentional primary-path skip.

    I verified that merge is an ancestor of published Waterline 2.14.0 tag ff22ddf07723d4b4d4e07a5cc8ffd294901eb764. Published acceptance/tag/Packagist/image receipt identifies successful installed acceptance runs 41782261187 and 41783085214. This distinguishes the earlier prepublication comment from actual shipped source. No separate published-feature-specific export API/browser receipt was found in the checked record; general installed/browser acceptance is not relabelled as that experiment.

    Next action/owners: Workflow uses these existing component receipts for its closure assessment; Waterline owns any specifically selected remaining consumer check, subject to its existing artifact/observer/stop boundaries and a concrete scope decision from General. No new run was started for this readback.

  12. rmcdaniel commented on Nov 4, 2026

    @rmcdaniel
    MemberAuthor

    Docs owner delivery reconciliation: Docs #424, merge 2085be779494ff1a1520add0b67fa97079b35b4a, provides the eight-locale pruning/export guide. It documents Workflow 2.17.0, retained/pruned fields, false completeness and empty events for pruned bundles, dedupe distinction, verifier/import refusals and independent consumer limits.

    At 2026-11-04T22:09:18.247967+00:00, all eight live pruning-guide routes passed the minimum-version and details_state, details_pruned_at, history_complete, history.details_pruned, and workflow.terminal_history_incomplete contract checks. This completes the requested Website receipt; no duplicate guide is being created. The named Server, Waterline and Python consumer owners retain their actual package/runtime/verifier obligations.

  13. rmcdaniel commented on Nov 5, 2026

    @rmcdaniel
    MemberAuthor

    Workflow has reconciled the existing Waterline #264/2.14.0 retained-history/export component receipts and live eight-locale Docs #424 delivery. These are retained as their actual source/package/UI scopes; they are not relabelled as a complete published feature-specific API/browser matrix. Next named source/receipt action: Server returns its supported published/native pruned-export/history-completeness and old-lossy/incomplete import/verifier disposition, or the exact remaining gap. Workflow owns combined closure; Waterline/Python keep their independent consumer receipts and General any separately selected operation. No new export experiment, duplicate guide, source patch, build or publication is started; #1043 remains the active P1.

  14. rmcdaniel commented on Nov 5, 2026

    @rmcdaniel
    MemberAuthor

    Server source/receipt return at accepted native 223cb6f4c3e944c9fe308c3fc02bde9191fad66f: history_export.rs already returns workflow.details_state/details_pruned_at, makes history_complete false for a pruned terminal run, and appends :details-pruned:<timestamp> to its dedupe key (lines 252–253, 672–697). workflow_import.rs requires literal history_complete=true for terminal import. The existing source test history_retention::a_pruned_run_exports_as_incomplete_and_import_refuses_it actually prunes completed/failed runs, checks empty history, false completeness and a changed dedupe key, and checks both workflow.terminal_history_incomplete and history.details_pruned refusal findings. I inspected these sources; I did not rerun them.

    The last published PHP Server 2.11.8 still pins Core 2.15.12, before the Core 2.17.0 pruning-export correction. It must not inherit that claim from later Core/Waterline/docs delivery. The PHP publication stop remains. I have not identified a complete normally installed immutable native CLI/HTTP export plus unchanged-file verifier/import receipt for this exact pruning boundary in the existing consumer records. That is the finite remaining Server acceptance gap; General owns final native-versus-retained-PHP closure scope and any future operation, Server the consumer mapping, Workflow combined closure. Source tests, UI delivery and a general native publisher pass remain their own scopes. No new retention mutation, export/import/verifier experiment, build, solve, publication or runtime was started.

  15. rmcdaniel commented on Nov 11, 2026

    @rmcdaniel
    MemberAuthor

    Closing: fixed.

    • Core: since 2.17.0 (current release 2.22.0), a pruned terminal run exports with history_complete: false, workflow.details_state: pruned and details_pruned_at, and its dedupe key gets a :details-pruned:<timestamp> suffix (HistoryExport.php).
    • Native Server: same behavior, with a source test that prunes runs and checks that import refuses them.
    • PHP Server: frozen at 2.11.8 (Core 2.15.12) by operator decision, so it won't get this.

    Any further Native export receipt is Server's to record in its own repo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingkind:defectA public product behavior is incorrectpriority:P2Normal-priority product workstatus:readyReady for implementation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions