From 021669956969c621fa0cf2c2e8963e17677ce2b8 Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sun, 6 Sep 2026 12:53:44 -0600 Subject: [PATCH] Add fixture-only two-app reuse lifecycle pin-flip proof. Prove meeting-notes vs loop advance/remain and deprecation responses without claiming production release-train or BundleEmbedder host success. Co-authored-by: Cursor --- .github/workflows/ci.yml | 11 + AGENTS.md | 2 +- docs/adr/0006-two-app-reuse-contract.md | 2 +- docs/two-app-reuse-contract.md | 31 ++- .../two-app-reuse/lifecycle/catalog.json | 30 +++ .../two-app-reuse/lifecycle/evidence.json | 83 +++++++ .../two-app-reuse/lifecycle/scenario.json | 38 +++ scripts/ci/repository_checks.sh | 4 + scripts/ci/two_app_reuse_lifecycle.sh | 224 ++++++++++++++++++ 9 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 scripts/ci/fixtures/two-app-reuse/lifecycle/catalog.json create mode 100644 scripts/ci/fixtures/two-app-reuse/lifecycle/evidence.json create mode 100644 scripts/ci/fixtures/two-app-reuse/lifecycle/scenario.json create mode 100755 scripts/ci/two_app_reuse_lifecycle.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15ba6d3..83f6baa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,17 @@ jobs: - name: Two-app reuse contract run: bash scripts/ci/two_app_reuse_contract_check.sh + - name: Two-app reuse lifecycle (happy) + run: bash scripts/ci/two_app_reuse_lifecycle.sh + + - name: Two-app reuse lifecycle (unsupported must fail) + run: | + if bash scripts/ci/two_app_reuse_lifecycle.sh --case unsupported-deprecated-only; then + echo "FAIL: unsupported-deprecated-only must exit non-zero" >&2 + exit 1 + fi + echo "OK: unsupported-deprecated-only failed closed" + quality: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 5f518d4..aa625bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \ | `pin-ci-traverse-0-10` | **Done** (#292) | CI / embedder / runtime.wasm → Traverse v0.10.0 | | `llm-mcp-0-10-live-cutover` | **Done** (absorbed) | Mode A → `llm-mcp-traverse-starter-catalog`; Mode B → `llm-mcp-embedded-host` Future; pin already #292 | | `two-app-reuse-host-execute` | **Blocked** | Traverse [#1240](https://github.com/traverse-framework/traverse/issues/1240) — BundleEmbedder still fails WASI `proc_exit(0)` on 1.3.2 | -| `two-app-reuse-lifecycle` | **Ready** | Fixture pin-flip + documented deprecation for meeting-notes + loop (not BundleEmbedder; not production release-train) | +| `two-app-reuse-lifecycle` | **Done** (#283) | Fixture pin-flip + documented deprecation for meeting-notes + loop (not BundleEmbedder; not production release-train) | Full gap table + wave notes: [`docs/production-reference-plan.md`](docs/production-reference-plan.md). diff --git a/docs/adr/0006-two-app-reuse-contract.md b/docs/adr/0006-two-app-reuse-contract.md index ab5644a..be71284 100644 --- a/docs/adr/0006-two-app-reuse-contract.md +++ b/docs/adr/0006-two-app-reuse-contract.md @@ -13,7 +13,7 @@ Traverse #1168 requires proof that a published capability is a reusable platform Pin **both** `meeting-notes` and `loop` to public `meeting-notes.process` **1.3.2** (`registry_ref.version_range` exactly `1.3.2`, artifact digest `sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2`). Full pin, host routes, and current drift: [`../two-app-reuse-contract.md`](../two-app-reuse-contract.md). -This ADR does not implement alignment or execution. `#286` applies the pin and produces evidence. `#283` (`two-app-reuse-lifecycle`) covers fixture-level upgrade/deprecation outcomes (Ready — see decision-log 2026-09-05). +This ADR does not implement alignment or execution. `#286` applies the pin and produces evidence. `#283` (`two-app-reuse-lifecycle`) covers fixture-level upgrade/deprecation outcomes (Done — fixture-only; not production release-train; not BundleEmbedder — see decision-log 2026-09-05 and `docs/two-app-reuse-contract.md` Lifecycle section). ## Consequences diff --git a/docs/two-app-reuse-contract.md b/docs/two-app-reuse-contract.md index 6837544..6bdf086 100644 --- a/docs/two-app-reuse-contract.md +++ b/docs/two-app-reuse-contract.md @@ -100,7 +100,36 @@ Host CLI execute via public `BundleEmbedder` (`meeting-notes-cli` / `loop-cli`) | `#286` `two-app-reuse-execute` | Align meeting-notes pin; execute both apps; publish digest-equal evidence (Done) | | `#287` `two-app-reuse-execute-ci` | Fixture-only CI evidence (Done) | | `two-app-reuse-host-execute` | Public BundleEmbedder CLI execute — Blocked on Traverse #1240 | -| `#283` `two-app-reuse-lifecycle` | **Ready** — fixture pin-flip + documented deprecation for the same pair (not BundleEmbedder; not production release-train). See `docs/decision-log.md` 2026-09-05 | +| `#283` `two-app-reuse-lifecycle` | Fixture pin-flip + documented deprecation for the same pair (Done — fixture-only; **not** production release-train; **not** BundleEmbedder). See below and `docs/decision-log.md` 2026-09-05 | + +## Lifecycle (fixture-only) — `two-app-reuse-lifecycle` + +**Scope:** consumer upgrade/deprecation **outcomes** for the shared pin pair using checked-in fixtures under `scripts/ci/fixtures/two-app-reuse/lifecycle/`. + +**Non-claims (mandatory):** + +- This is **not** a production registry release-train proof (no new public `meeting-notes.process` publish required). +- This is **not** a BundleEmbedder / `meeting-notes-cli` / `loop-cli` host proof (`two-app-reuse-host-execute` / Traverse #1240). +- App-References does **not** author WASM or invent registry deprecation policy. + +### Fixture procedure + +1. Both apps start on shared pin `meeting-notes.process` **1.3.2** (digest `sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2` — same as `scripts/ci/fixtures/two-app-reuse/pin.json`). +2. Fixture catalog introduces stand-in next exact pin `1.4.0-fixture` with the **same** published digest (registry `1.1.0`–`1.3.2` already share that artifact; the stand-in avoids inventing WASM). +3. Per-app pin-flip choices (recorded in `lifecycle/scenario.json` + evidence): + - `meeting-notes` → **advance** to `1.4.0-fixture` (digest-compatible). + - `loop` → **remain** on `1.3.2` until a real next publish + host proof exist. +4. Deprecation step marks fixture baseline `1.3.2` as deprecated. Supported consumer responses (no invented policy): **migrate** / **continue_pinning** / **fail_closed**. + - `meeting-notes` → **migrate** to `1.4.0-fixture`. + - `loop` → **continue_pinning** `1.3.2` (immutable digest; explicit awareness). + +```bash +bash scripts/ci/two_app_reuse_lifecycle.sh +# negative: unsupported deprecated-only resolution must fail closed +bash scripts/ci/two_app_reuse_lifecycle.sh --case unsupported-deprecated-only # exit non-zero +``` + +Evidence artifact: [`scripts/ci/fixtures/two-app-reuse/lifecycle/evidence.json`](../scripts/ci/fixtures/two-app-reuse/lifecycle/evidence.json). ## Re-verification diff --git a/scripts/ci/fixtures/two-app-reuse/lifecycle/catalog.json b/scripts/ci/fixtures/two-app-reuse/lifecycle/catalog.json new file mode 100644 index 0000000..9df65d7 --- /dev/null +++ b/scripts/ci/fixtures/two-app-reuse/lifecycle/catalog.json @@ -0,0 +1,30 @@ +{ + "capability_id": "meeting-notes.process", + "namespace": "meeting-notes", + "scope": "fixture-only", + "non_claims": [ + "Not a production registry release-train proof.", + "Not a BundleEmbedder / host-CLI execute proof (see two-app-reuse-host-execute / Traverse #1240).", + "App-References does not author meeting-notes.process WASM or invent registry deprecation policy." + ], + "releases": { + "1.3.2": { + "release_version": "1.3.2", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2", + "contract_schema_version": "1.0.0", + "lifecycle": "active", + "pin_file": "../pin.json", + "artifact_url": "https://github.com/traverse-framework/registry/releases/download/artifacts/meeting-notes.process-1.1.0/meeting-process-agent.wasm", + "note": "Published immutable baseline shared by meeting-notes + loop (two-app-reuse-contract)." + }, + "1.4.0-fixture": { + "release_version": "1.4.0-fixture", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2", + "contract_schema_version": "1.0.0", + "lifecycle": "active", + "stand_in": true, + "artifact_url": "https://github.com/traverse-framework/registry/releases/download/artifacts/meeting-notes.process-1.1.0/meeting-process-agent.wasm", + "note": "Fixture-only stand-in next exact pin. Same published artifact digest as 1.3.2 (registry 1.1.0–1.3.2 share that digest). Demonstrates consumer pin-flip without a new public publish." + } + } +} diff --git a/scripts/ci/fixtures/two-app-reuse/lifecycle/evidence.json b/scripts/ci/fixtures/two-app-reuse/lifecycle/evidence.json new file mode 100644 index 0000000..c31eec0 --- /dev/null +++ b/scripts/ci/fixtures/two-app-reuse/lifecycle/evidence.json @@ -0,0 +1,83 @@ +{ + "ticket_id": "two-app-reuse-lifecycle", + "upstream": "https://github.com/traverse-framework/Traverse/issues/1168", + "scope": "fixture-only", + "non_claims": [ + "Not a production registry release-train proof.", + "Not a BundleEmbedder / host-CLI execute proof (see two-app-reuse-host-execute / Traverse #1240).", + "App-References does not author meeting-notes.process WASM or invent registry deprecation policy." + ], + "shared_capability_id": "meeting-notes.process", + "baseline": { + "release_version": "1.3.2", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2", + "lifecycle_before": "active", + "lifecycle_after_deprecation_step": "deprecated" + }, + "fixture_target": { + "release_version": "1.4.0-fixture", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2", + "stand_in": true, + "note": "Fixture-only stand-in next exact pin. Same published artifact digest as 1.3.2 (registry 1.1.0\u20131.3.2 share that digest). Demonstrates consumer pin-flip without a new public publish." + }, + "pin_flip": [ + { + "app_id": "meeting-notes", + "choice": "advance", + "before": { + "release_version": "1.3.2", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2" + }, + "after": { + "release_version": "1.4.0-fixture", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2" + }, + "compatibility_rationale": "Standalone meeting-notes adopts the fixture next exact pin when the stand-in release is digest-compatible with the shared baseline (same sha256 as 1.3.2)." + }, + { + "app_id": "loop", + "choice": "remain", + "before": { + "release_version": "1.3.2", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2" + }, + "after": { + "release_version": "1.3.2", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2" + }, + "compatibility_rationale": "Loop WF1 keeps the validated shared 1.3.2 pin until a real registry next release and BundleEmbedder host proof exist; remain is an explicit per-app choice, not a floating range." + } + ], + "deprecation": [ + { + "app_id": "meeting-notes", + "response": "migrate", + "deprecated_release": "1.3.2", + "resolved_pin": { + "release_version": "1.4.0-fixture", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2", + "lifecycle": "active" + }, + "rationale": "When the baseline pin is fixture-deprecated, meeting-notes migrates to the stand-in next pin already chosen at pin-flip." + }, + { + "app_id": "loop", + "response": "continue_pinning", + "deprecated_release": "1.3.2", + "resolved_pin": { + "release_version": "1.3.2", + "digest": "sha256:ec192a0c2104b08bee76418c5c6d44358036568d655d8465e506858d1aaadbf2", + "lifecycle": "deprecated" + }, + "rationale": "Loop continues pinning the immutable digest while recording fixture deprecation; continue_pinning is a supported consumer response (not invented registry policy)." + } + ], + "assert": { + "both_apps_start_on_baseline": true, + "per_app_advance_or_remain_recorded": true, + "deprecation_responses_recorded": true, + "fixture_only": true, + "not_production_release_train": true, + "not_bundle_embedder_host_proof": true + } +} diff --git a/scripts/ci/fixtures/two-app-reuse/lifecycle/scenario.json b/scripts/ci/fixtures/two-app-reuse/lifecycle/scenario.json new file mode 100644 index 0000000..edeaac4 --- /dev/null +++ b/scripts/ci/fixtures/two-app-reuse/lifecycle/scenario.json @@ -0,0 +1,38 @@ +{ + "ticket_id": "two-app-reuse-lifecycle", + "upstream": "https://github.com/traverse-framework/Traverse/issues/1168", + "baseline_pin": "1.3.2", + "target_pin": "1.4.0-fixture", + "apps": ["meeting-notes", "loop"], + "pin_flip": { + "meeting-notes": { + "choice": "advance", + "from": "1.3.2", + "to": "1.4.0-fixture", + "compatibility_rationale": "Standalone meeting-notes adopts the fixture next exact pin when the stand-in release is digest-compatible with the shared baseline (same sha256 as 1.3.2)." + }, + "loop": { + "choice": "remain", + "from": "1.3.2", + "to": "1.3.2", + "compatibility_rationale": "Loop WF1 keeps the validated shared 1.3.2 pin until a real registry next release and BundleEmbedder host proof exist; remain is an explicit per-app choice, not a floating range." + } + }, + "deprecation": { + "deprecated_release": "1.3.2", + "fixture_lifecycle_after": "deprecated", + "supported_responses": ["migrate", "continue_pinning", "fail_closed"], + "responses": { + "meeting-notes": { + "response": "migrate", + "resolved_pin": "1.4.0-fixture", + "rationale": "When the baseline pin is fixture-deprecated, meeting-notes migrates to the stand-in next pin already chosen at pin-flip." + }, + "loop": { + "response": "continue_pinning", + "resolved_pin": "1.3.2", + "rationale": "Loop continues pinning the immutable digest while recording fixture deprecation; continue_pinning is a supported consumer response (not invented registry policy)." + } + } + } +} diff --git a/scripts/ci/repository_checks.sh b/scripts/ci/repository_checks.sh index 18c05c3..102fbde 100644 --- a/scripts/ci/repository_checks.sh +++ b/scripts/ci/repository_checks.sh @@ -37,9 +37,13 @@ check "docs/two-app-reuse-contract.md" "Two-app reuse contract" check "docs/adr/0006-two-app-reuse-contract.md" "ADR 0006 two-app reuse contract" check "scripts/ci/two_app_reuse_contract_check.sh" "Two-app reuse contract check" check "scripts/ci/two_app_reuse_execute.sh" "Two-app reuse execute evidence" +check "scripts/ci/two_app_reuse_lifecycle.sh" "Two-app reuse lifecycle check" check "scripts/ci/fixtures/two-app-reuse/pin.json" "Two-app reuse pin fixture" check "scripts/ci/fixtures/two-app-reuse/meeting-notes.process-1.3.2.wasm" "Two-app reuse published wasm" check "scripts/ci/fixtures/two-app-reuse/meeting-notes.process-1.3.2.contract.json" "Two-app reuse published contract" +check "scripts/ci/fixtures/two-app-reuse/lifecycle/catalog.json" "Two-app reuse lifecycle catalog" +check "scripts/ci/fixtures/two-app-reuse/lifecycle/scenario.json" "Two-app reuse lifecycle scenario" +check "scripts/ci/fixtures/two-app-reuse/lifecycle/evidence.json" "Two-app reuse lifecycle evidence" # Platform clients check "apps/traverse-starter/ios-swift/TraverseStarter.xcodeproj" "ios-swift Xcode project" diff --git a/scripts/ci/two_app_reuse_lifecycle.sh b/scripts/ci/two_app_reuse_lifecycle.sh new file mode 100755 index 0000000..1086159 --- /dev/null +++ b/scripts/ci/two_app_reuse_lifecycle.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Fixture-only pin-flip + deprecation outcomes for meeting-notes + loop. +# Ticket: two-app-reuse-lifecycle (Traverse #1168 predecessor slice). +# +# Happy path (default): exit 0 and write evidence. +# Unsupported case: --case unsupported-deprecated-only → exit non-zero. +# +# Explicit non-claims: not production release-train proof; not BundleEmbedder proof. +set -euo pipefail + +REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel)}" +FIXTURE_DIR="$REPO_ROOT/scripts/ci/fixtures/two-app-reuse" +LIFECYCLE_DIR="$FIXTURE_DIR/lifecycle" +CATALOG_JSON="$LIFECYCLE_DIR/catalog.json" +SCENARIO_JSON="$LIFECYCLE_DIR/scenario.json" +PIN_JSON="$FIXTURE_DIR/pin.json" +EVIDENCE_OUT="${TWO_APP_REUSE_LIFECYCLE_EVIDENCE:-$LIFECYCLE_DIR/evidence.json}" +CASE="happy" + +while [ $# -gt 0 ]; do + case "$1" in + --case) + CASE="${2:?missing --case value}" + shift 2 + ;; + *) + echo "FAIL: unknown arg: $1" >&2 + echo "usage: $0 [--case happy|unsupported-deprecated-only]" >&2 + exit 2 + ;; + esac +done + +for path in "$CATALOG_JSON" "$SCENARIO_JSON" "$PIN_JSON"; do + if [ ! -f "$path" ]; then + echo "FAIL: missing $path" >&2 + exit 1 + fi +done + +export REPO_ROOT FIXTURE_DIR LIFECYCLE_DIR CATALOG_JSON SCENARIO_JSON PIN_JSON EVIDENCE_OUT CASE + +python3 - <<'PY' +import json +import os +import pathlib +import sys + +catalog = json.loads(pathlib.Path(os.environ["CATALOG_JSON"]).read_text()) +scenario = json.loads(pathlib.Path(os.environ["SCENARIO_JSON"]).read_text()) +pin = json.loads(pathlib.Path(os.environ["PIN_JSON"]).read_text()) +case = os.environ["CASE"] +evidence_out = pathlib.Path(os.environ["EVIDENCE_OUT"]) + +SUPPORTED_FLIP = {"advance", "remain"} +SUPPORTED_DEP = set(scenario["deprecation"]["supported_responses"]) + + +def fail(msg: str) -> None: + print(f"FAIL: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def release(key: str) -> dict: + releases = catalog.get("releases") or {} + if key not in releases: + fail(f"catalog missing release {key!r}") + return releases[key] + + +# --- baseline pin must match published execute fixture --- +baseline_key = scenario["baseline_pin"] +target_key = scenario["target_pin"] +baseline = release(baseline_key) +target = release(target_key) + +if pin["capability_id"] != catalog["capability_id"]: + fail("pin.json capability_id != catalog") +if pin["release_version"] != baseline["release_version"]: + fail("pin.json release_version != scenario baseline") +if pin["digest"] != baseline["digest"]: + fail("pin.json digest != scenario baseline digest") +if baseline["digest"] != target["digest"]: + fail( + "fixture stand-in target must stay digest-compatible with baseline " + "(App-Refs does not author a new WASM for lifecycle)" + ) +if not target.get("stand_in"): + fail("target_pin must be marked stand_in=true (fixture-only next pin)") + +apps = scenario["apps"] +if apps != ["meeting-notes", "loop"]: + fail(f"expected apps [meeting-notes, loop], got {apps!r}") + +# --- pin flip --- +flip_rows = [] +for app_id in apps: + decision = (scenario.get("pin_flip") or {}).get(app_id) + if not decision: + fail(f"missing pin_flip decision for {app_id}") + choice = decision.get("choice") + if choice not in SUPPORTED_FLIP: + fail(f"{app_id} pin_flip.choice={choice!r} not in {sorted(SUPPORTED_FLIP)}") + from_ver = decision.get("from") + to_ver = decision.get("to") + if from_ver != baseline_key: + fail(f"{app_id} pin_flip.from must be baseline {baseline_key}") + if choice == "advance": + if to_ver != target_key: + fail(f"{app_id} advance must land on target_pin {target_key}") + elif to_ver != baseline_key: + fail(f"{app_id} remain must keep baseline {baseline_key}") + if not decision.get("compatibility_rationale"): + fail(f"{app_id} missing compatibility_rationale") + after = release(to_ver) + flip_rows.append( + { + "app_id": app_id, + "choice": choice, + "before": { + "release_version": baseline["release_version"], + "digest": baseline["digest"], + }, + "after": { + "release_version": after["release_version"], + "digest": after["digest"], + }, + "compatibility_rationale": decision["compatibility_rationale"], + } + ) + +# --- deprecation handling --- +dep = scenario["deprecation"] +deprecated_key = dep["deprecated_release"] +if deprecated_key != baseline_key: + fail("deprecation.deprecated_release must equal baseline_pin for this fixture story") +if dep.get("fixture_lifecycle_after") != "deprecated": + fail("fixture must mark baseline deprecated after the deprecation step") + +dep_rows = [] +for app_id in apps: + response = (dep.get("responses") or {}).get(app_id) + if not response: + fail(f"missing deprecation response for {app_id}") + kind = response.get("response") + if kind not in SUPPORTED_DEP: + fail(f"{app_id} deprecation response={kind!r} not in {sorted(SUPPORTED_DEP)}") + resolved = response.get("resolved_pin") + if kind == "migrate": + if resolved != target_key: + fail(f"{app_id} migrate must resolve to {target_key}") + if release(resolved).get("lifecycle") == "deprecated": + fail(f"{app_id} migrate resolved a deprecated release") + elif kind == "continue_pinning": + if resolved != deprecated_key: + fail(f"{app_id} continue_pinning must keep {deprecated_key}") + elif kind == "fail_closed": + fail(f"{app_id} fail_closed is only valid in the unsupported negative case") + if not response.get("rationale"): + fail(f"{app_id} missing deprecation rationale") + resolved_rel = release(resolved) + dep_rows.append( + { + "app_id": app_id, + "response": kind, + "deprecated_release": deprecated_key, + "resolved_pin": { + "release_version": resolved_rel["release_version"], + "digest": resolved_rel["digest"], + "lifecycle": "deprecated" + if resolved == deprecated_key + else resolved_rel.get("lifecycle"), + }, + "rationale": response["rationale"], + } + ) + +if case == "unsupported-deprecated-only": + # Negative path: only the deprecated release exists; consumers that refuse + # continue_pinning / migrate have no supported resolution → fail closed. + print( + "FAIL: unsupported deprecated-only resolution " + "(no active stand-in pin; consumers cannot invent a floating range)", + file=sys.stderr, + ) + raise SystemExit(1) + +if case != "happy": + fail(f"unknown case {case!r}") + +evidence = { + "ticket_id": scenario["ticket_id"], + "upstream": scenario.get("upstream"), + "scope": catalog["scope"], + "non_claims": catalog["non_claims"], + "shared_capability_id": catalog["capability_id"], + "baseline": { + "release_version": baseline["release_version"], + "digest": baseline["digest"], + "lifecycle_before": baseline["lifecycle"], + "lifecycle_after_deprecation_step": "deprecated", + }, + "fixture_target": { + "release_version": target["release_version"], + "digest": target["digest"], + "stand_in": True, + "note": target.get("note"), + }, + "pin_flip": flip_rows, + "deprecation": dep_rows, + "assert": { + "both_apps_start_on_baseline": True, + "per_app_advance_or_remain_recorded": True, + "deprecation_responses_recorded": True, + "fixture_only": True, + "not_production_release_train": True, + "not_bundle_embedder_host_proof": True, + }, +} + +evidence_out.write_text(json.dumps(evidence, indent=2) + "\n") +print(json.dumps(evidence, indent=2)) +print(f"OK: two-app reuse lifecycle evidence → {evidence_out}") +PY