From e0c9e0db94536d38c17eb95fa430f038649bfbf2 Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Mon, 20 Jul 2026 00:30:30 -0700 Subject: [PATCH] Add explicit rollback bootstrap and maintenance model --- README.md | 4 +- docs/certification.md | 33 +++- docs/maintenance.md | 117 +++++++++++++ lib/opencode_compat/runtime_tuple_promoter.rb | 138 ++++++++++++++- scripts/promote_runtime_tuple.rb | 43 ++++- test/runtime_tuple_promoter_test.rb | 162 ++++++++++++++++++ 6 files changed, 486 insertions(+), 11 deletions(-) create mode 100644 docs/maintenance.md diff --git a/README.md b/README.md index 310cdaf..6b603dc 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,9 @@ durable certification documents and Git history are the archive. This keeps a fast-moving upstream from making every future run retest every old release. See [docs/certification.md](docs/certification.md) for promotion, canary, and -rollback evidence requirements. +rollback evidence requirements, and [docs/maintenance.md](docs/maintenance.md) +for consumer ownership, release order, runner boundaries, and expected failure +modes. ## License diff --git a/docs/certification.md b/docs/certification.md index 6d6c571..02703b7 100644 --- a/docs/certification.md +++ b/docs/certification.md @@ -155,14 +155,31 @@ have real passing evidence can the manifest honestly contain certified `current` and `previous` tuples. Until then, `promotion_readiness` remains blocked and the candidate PR must not be treated as a deploy authorization. -The schema-v1 promotion command deliberately cannot perform the first degraded -bootstrap transition when `current` is a known-failing baseline. Do not bypass -that guard by hand-editing the manifest or relabeling alpha2 evidence. A -separate reviewed state-machine change must first add an explicit -`bootstrap-current-only` state that preserves the failed baseline as -uncertified emergency provenance and leaves `previous` null. Itemized rollback -certification is complete only after a later, materially distinct passing tuple -can move the first certified current into `previous`. +Use the explicit degraded-bootstrap transition for the first passing rollback +tuple. It requires passing evidence bound to the complete candidate fingerprint +and a literal acknowledgement; it preserves the failed baseline as uncertified +`emergency_provenance`, leaves `previous` null, and records the temporary +`bootstrap-current-only` state: + +```sh +ruby scripts/promote_runtime_tuple.rb bootstrap-current \ + --consumer travelwolf \ + --consumer-commit FULL_40_CHARACTER_ROLLBACK_COMMIT \ + --status pass \ + --certified-at 2026-07-20T12:00:00Z \ + --evidence evidence/travelwolf-rollback.json \ + --acknowledge-degraded-rollback \ + accept-degraded-rollback-with-failed-emergency-provenance \ + --dry-run +``` + +Review the dry run, repeat without `--dry-run`, and then treat that passing +tuple as the base for a normal promotion. Normal promotion moves it into +`previous`, removes the degraded rollback marker, and keeps the original failed +baseline only as historical emergency provenance. The repository becomes +`certified` only when every consumer has exact passing `current` and `previous` +tuples. Never use bootstrap to replace an existing previous tuple or to certify +a baseline already known to fail. ## Rollback diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..05186d0 --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,117 @@ +# Maintaining OpenCode consumers + +OpenCode compatibility is an executable tuple, not a gem version. The tuple is +the exact client commit, Rails adapter commit where used, OpenCode image digest, +consumer commit, compatibility profile, and passing evidence. A change to any +coordinate creates a new candidate. + +## Dependency ownership + +The adjacent projects intentionally do not all use the same gems: + +| Consumer | OpenCode boundary | Owned dependency | +| --- | --- | --- | +| Ajent Rails | Ruby REST/SSE plus persisted Rails turns | `opencode-ruby` and `opencode-rails` | +| Travelwolf | Ruby REST/SSE plus persisted Rails turns and Sprite lifecycle | `opencode-ruby` and `opencode-rails` | +| Mushu | Ruby REST/SSE with application-owned conversation, claim, recovery, Telegram scope, and idempotency | `opencode-ruby` only | +| Greenroom | Direct voice-stream worker | No Ruby adapter gem | +| Leela | Custom strict-v2 server, security, and toolchain lane | No Ruby adapter gem | +| opencode-ajent | Native CLI and plugin hook/event lane | No Ruby adapter gem | +| inference | Provider configuration, hooks, routing, and migration lane | No Ruby adapter gem | +| Context Kit | MCP and OpenCode configuration producer | No Ruby adapter gem | + +Do not add `opencode-rails` to an application that owns different persistence +semantics, and do not route plugin, provider, voice, or strict-v2 behavior +through the Ruby REST/SSE adapter merely to make versions look uniform. Share +fixtures, provenance, and promotion policy across those lanes instead. + +Generated release snapshots, detached operational copies, editor locks, caches, +and linked task worktrees are evidence or tooling. They are not additional +consumers and must not be bulk-upgraded. + +## Supported window + +Support the exact current and previous certified runtime tuples. Do not claim +compatibility with arbitrary future OpenCode versions. Additive fields and +unknown events should remain tolerant, while terminal text, request count, +ownership, persistence, and cleanup invariants remain strict. + +The Ruby/SSE profile covers the endpoints and events the client actually uses, +including session creation and deletion, asynchronous prompts, event +subscription, status, terminal idle/status events, part deltas and updates, +authoritative assistant messages, questions, and permissions. A passing shared +profile does not certify Rails persistence, voice streaming, plugin hooks, +provider migrations, or generated MCP configuration. + +## Release and promotion order + +1. The watcher records a new upstream release tag and resolved OCI digest in a + PR. It never merges or deploys. +2. Update fixtures for any observed protocol change before changing the client. +3. Build `opencode-ruby` and `opencode-rails` as one release train. Rails must + resolve the exact Ruby version and commit being tested. +4. Run the shared fixture corpus, Ruby 3.2 through 4.0 lockstep matrix, and every + exact public image still used by a current, previous, or candidate tuple. +5. Run only the application-owned profiles for each consumer, in its own PR and + isolated environment. Health checks alone do not certify a tuple. +6. Promote and deploy one consumer at a time. Record the exact production + commit, image/base digest, loaded client commits, and live result. +7. Commit reviewed evidence and move the old passing current tuple to previous. +8. Publish annotated gem tags only after the exact commit candidate is green and + the trusted publisher is configured. Publication never implies deployment. + +Commit pins are valid for an unpublished candidate, but the durable published +state must record the annotated tag object and peeled commit. Tags or `latest` +may be kept as human-readable provenance only; execution coordinates use full +Git commits and `image@sha256:...` references. + +## Custom images + +Record each provenance layer separately: + +- consumer commit; +- exact output registry digest or Docker image ID; +- exact base image digest; +- custom OpenCode source commit, when the base is a fork; +- build-source commit when it differs from the deployment commit. + +The tuple fingerprint binds these values. On the next rebuild of an older +unlabelled private image, add OCI labels for the custom OpenCode source, reported +version, consumer build revision, and base digest, then make preflight compare +the labels. Do not relabel an already certified image: that changes its digest. + +## Runner and forge contract + +Runner upgrades are a separate compatibility surface from OpenCode upgrades. +Workflows must install their required Ruby/toolchain explicitly, declare Bash +for scripts that use Bash syntax, and avoid relying on ambient runner packages. + +GitHub and Gitea do not implement every Actions feature identically. Keep the +same tests on both forges, but use forge-specific execution where necessary: +GitHub retains review artifacts and parallel dynamic image jobs; Gitea runs the +same manifest image set sequentially and makes no artifact-retention claim. +Neither path may mutate a runner or deploy a consumer. + +A runner-only workflow repair needs exact-head CI, not an application canary. +A client, runtime image, event, persistence, or toolchain change needs the +profile and consumer canaries described above. + +## Expected breakages + +- Prompt submission before event subscription can miss a terminal event and + hang a turn. +- Changes to terminal or message-part events can duplicate or lose final text. +- Usage events can undercount multi-step requests if totals are overwritten. +- Reconnect logic can replay a prompt and create duplicate model requests. +- Asset builds can fail when runtime configuration is evaluated without the + image variables available only at deploy time. +- A workflow can pass locally but fail under `sh` when it uses Bash arrays or + `mapfile`. +- Plugin hook names, provider schema, config/MCP schema, or CLI flags can break + direct consumers even when the Ruby profile stays green. +- A custom fork can silently lose its required ordering or permission patch if + only an opaque output digest is retained. + +When one of these changes, update the owning profile and consumer evidence. Do +not weaken a strict invariant to make a new upstream release pass. + diff --git a/lib/opencode_compat/runtime_tuple_promoter.rb b/lib/opencode_compat/runtime_tuple_promoter.rb index 4fe9529..e05cb56 100644 --- a/lib/opencode_compat/runtime_tuple_promoter.rb +++ b/lib/opencode_compat/runtime_tuple_promoter.rb @@ -15,6 +15,8 @@ module OpenCodeCompat IMMUTABLE_IMAGE = /\A[^@\s]+@sha256:[0-9a-f]{64}\z/ UTC_TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\z/ CERTIFICATION_STATUS = "pass" + DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT = + "accept-degraded-rollback-with-failed-emergency-provenance" NON_CERTIFIABLE_TUPLE_STATUSES = %w[observed-production-contract-failed].freeze TUPLE_METADATA_KEYS = %w[ certification @@ -82,6 +84,37 @@ module OpenCodeCompat end end + def bootstrap_current(consumer:, consumer_commit:, certification:, acknowledgement:, dry_run: false) + validate_full_commit!(consumer_commit, "consumer commit") + unless acknowledgement == DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT + raise PromotionError, + "bootstrap requires explicit acknowledgement #{DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT.inspect}" + end + + if dry_run + manifest = read_manifest + return bootstrap_current_manifest( + manifest, + consumer: consumer, + consumer_commit: consumer_commit, + certification: certification, + acknowledgement: acknowledgement + ) + end + + with_current_manifest_lock do |manifest| + bootstrapped = bootstrap_current_manifest( + manifest, + consumer: consumer, + consumer_commit: consumer_commit, + certification: certification, + acknowledgement: acknowledgement + ) + atomic_write(bootstrapped) + bootstrapped + end + end + private def read_manifest @@ -124,7 +157,55 @@ module OpenCodeCompat consumer_entry["previous"] = certified_previous consumer_entry["current"] = certified_candidate consumer_entry["candidate"] = nil - manifest["migration_state"] = all_consumers_certified?(manifest) ? "certified" : "candidate" + consumer_entry.delete("rollback_state") + refresh_promotion_state!(manifest) + manifest + end + + def bootstrap_current_manifest(manifest, consumer:, consumer_commit:, certification:, acknowledgement:) + consumer_entry = fetch_consumer!(manifest, consumer) + profile = fetch_profile!(consumer_entry, consumer) + candidate = prepare_candidate!(consumer_entry, consumer_commit) + current = consumer_entry.fetch("current") do + raise PromotionError, "#{consumer} has no current tuple to retain as emergency provenance" + end + validate_tuple!(current, "#{consumer} current") + unless NON_CERTIFIABLE_TUPLE_STATUSES.include?(current["status"]) + raise PromotionError, "degraded bootstrap is only for a current tuple known to fail the contract" + end + unless consumer_entry["previous"].nil? + raise PromotionError, "degraded bootstrap cannot replace an existing previous tuple" + end + if consumer_entry.key?("emergency_provenance") || consumer_entry.key?("rollback_state") + raise PromotionError, "degraded bootstrap has already been recorded for this consumer" + end + + candidate_fingerprint = tuple_fingerprint(candidate, consumer: consumer, profile: profile) + current_fingerprint = tuple_fingerprint(current, consumer: consumer, profile: profile) + if candidate_fingerprint == current_fingerprint + raise PromotionError, "#{consumer} candidate is identical to its failed current tuple" + end + + certified_candidate = certify_tuple!( + candidate, + consumer: consumer, + profile: profile, + supplied: certification, + expected_fingerprint: candidate_fingerprint, + label: "bootstrap candidate" + ) + + consumer_entry["emergency_provenance"] = deep_copy(current) + consumer_entry["current"] = certified_candidate + consumer_entry["candidate"] = nil + consumer_entry["previous"] = nil + consumer_entry["rollback_state"] = { + "status" => "degraded-no-certified-previous", + "acknowledgement" => acknowledgement, + "recorded_at" => certification.fetch("certified_at"), + "emergency_provenance_status" => current.fetch("status") + } + refresh_promotion_state!(manifest) manifest end @@ -455,6 +536,61 @@ module OpenCodeCompat end end + def all_consumers_bootstrapped?(manifest) + manifest.fetch("consumers").all? do |consumer, entry| + next false unless entry["candidate"].nil? && entry["previous"].nil? + next false unless entry.dig("rollback_state", "status") == "degraded-no-certified-previous" + next false unless NON_CERTIFIABLE_TUPLE_STATUSES.include?(entry.dig("emergency_provenance", "status")) + + profile = fetch_profile!(entry, consumer) + tuple = entry["current"] + next false unless tuple.is_a?(Hash) && tuple["status"] == "certified" + + validate_tuple!(tuple, "#{consumer} current") + validate_recorded_certification!( + tuple, + consumer: consumer, + profile: profile, + expected_fingerprint: tuple_fingerprint(tuple, consumer: consumer, profile: profile), + label: "#{consumer} current" + ) + true + rescue PromotionError + false + end + end + + def migration_state_for(manifest) + return "certified" if all_consumers_certified?(manifest) + return "bootstrap-current-only" if all_consumers_bootstrapped?(manifest) + + "candidate" + end + + def refresh_promotion_state!(manifest) + manifest["migration_state"] = migration_state_for(manifest) + manifest["promotion_readiness"] = case manifest.fetch("migration_state") + when "certified" + { + "status" => "certified", + "reason" => "Every consumer has exact current and previous passing tuples.", + "required_action" => "Promote only a newly certified, materially changed tuple." + } + when "bootstrap-current-only" + { + "status" => "bootstrap-current-only", + "reason" => "Every current tuple is certified, but no independently passing previous tuple exists yet.", + "required_action" => "Certify the next meaningful release so normal promotion retains the current tuple as previous." + } + else + { + "status" => "candidate", + "reason" => "At least one consumer transition remains incomplete.", + "required_action" => "Finish exact tuple certification without treating failed emergency provenance as rollback evidence." + } + end + end + def with_current_manifest_lock loop do retry_with_new_inode = false diff --git a/scripts/promote_runtime_tuple.rb b/scripts/promote_runtime_tuple.rb index a0974e3..06d75c6 100755 --- a/scripts/promote_runtime_tuple.rb +++ b/scripts/promote_runtime_tuple.rb @@ -20,6 +20,10 @@ parser = OptionParser.new do |opts| --status pass --certified-at TIMESTAMP --evidence evidence/FILE.json \\ [--previous-status pass --previous-certified-at TIMESTAMP \\ --previous-evidence evidence/FILE.json] [--dry-run] + ruby scripts/promote_runtime_tuple.rb bootstrap-current --consumer NAME --consumer-commit SHA \\ + --status pass --certified-at TIMESTAMP --evidence evidence/FILE.json \\ + --acknowledge-degraded-rollback \\ + accept-degraded-rollback-with-failed-emergency-provenance [--dry-run] USAGE opts.on("--consumer NAME") { |value| options["consumer"] = value } @@ -30,6 +34,9 @@ parser = OptionParser.new do |opts| opts.on("--previous-status STATUS") { |value| options["previous_status"] = value } opts.on("--previous-certified-at TIMESTAMP") { |value| options["previous_certified_at"] = value } opts.on("--previous-evidence PATH") { |value| options["previous_evidence"] << value } + opts.on("--acknowledge-degraded-rollback VALUE") do |value| + options["degraded_rollback_acknowledgement"] = value + end opts.on("--dry-run") { options["dry_run"] = true } end @@ -94,8 +101,42 @@ begin "migration_state" => promoted.fetch("migration_state") ) end + when "bootstrap-current" + required!( + options, + "consumer", + "consumer_commit", + "status", + "certified_at", + "evidence", + "degraded_rollback_acknowledgement" + ) + bootstrapped = promoter.bootstrap_current( + consumer: options.fetch("consumer"), + consumer_commit: options.fetch("consumer_commit"), + certification: { + "status" => options.fetch("status"), + "certified_at" => options.fetch("certified_at"), + "evidence" => options.fetch("evidence") + }, + acknowledgement: options.fetch("degraded_rollback_acknowledgement"), + dry_run: options.fetch("dry_run", false) + ) + if options.fetch("dry_run", false) + puts JSON.pretty_generate(bootstrapped) + else + consumer = bootstrapped.fetch("consumers").fetch(options.fetch("consumer")) + puts JSON.pretty_generate( + "consumer" => options.fetch("consumer"), + "current_consumer_commit" => consumer.dig("current", "consumer_commit"), + "current_tuple_sha256" => consumer.dig("current", "certification", "tuple_sha256"), + "previous" => consumer.fetch("previous"), + "rollback_state" => consumer.fetch("rollback_state"), + "migration_state" => bootstrapped.fetch("migration_state") + ) + end else - raise OptionParser::InvalidArgument, "command must be fingerprint or promote" + raise OptionParser::InvalidArgument, "command must be fingerprint, promote, or bootstrap-current" end rescue OpenCodeCompat::PromotionError, OptionParser::ParseError => e warn "error: #{e.message}" diff --git a/test/runtime_tuple_promoter_test.rb b/test/runtime_tuple_promoter_test.rb index c22d474..5231409 100644 --- a/test/runtime_tuple_promoter_test.rb +++ b/test/runtime_tuple_promoter_test.rb @@ -10,14 +10,19 @@ class RuntimeTuplePromoterTest < Minitest::Test CONSUMER = "example" CURRENT_COMMIT = "1" * 40 CANDIDATE_COMMIT = "2" * 40 + NEXT_COMMIT = "7" * 40 RUBY_CURRENT = "3" * 40 RUBY_CANDIDATE = "4" * 40 + RUBY_NEXT = "8" * 40 RAILS_CURRENT = "5" * 40 RAILS_CANDIDATE = "6" * 40 + RAILS_NEXT = "9" * 40 CURRENT_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'a' * 64}" CANDIDATE_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'b' * 64}" + NEXT_IMAGE = "ghcr.io/anomalyco/opencode@sha256:#{'c' * 64}" CURRENT_TIME = "2026-07-17T12:00:00Z" CANDIDATE_TIME = "2026-07-18T12:00:00Z" + NEXT_TIME = "2026-07-19T12:00:00Z" def setup @root = Dir.mktmpdir("opencode-compat-promotion") @@ -175,6 +180,163 @@ class RuntimeTuplePromoterTest < Minitest::Test assert_equal before, File.binread(manifest_path) end + def test_explicit_degraded_bootstrap_preserves_failed_emergency_provenance + manifest = valid_manifest + manifest.dig("consumers", CONSUMER, "current")["status"] = "observed-production-contract-failed" + write_manifest(manifest) + candidate_fingerprint = @promoter.fingerprints( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT + ).fetch("candidate_tuple_sha256") + evidence = write_evidence( + "bootstrap-candidate.json", + commit: CANDIDATE_COMMIT, + timestamp: CANDIDATE_TIME, + fingerprint: candidate_fingerprint + ) + before = File.binread(manifest_path) + + preview = @promoter.bootstrap_current( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, evidence), + acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT, + dry_run: true + ) + + assert_equal before, File.binread(manifest_path) + assert_equal "bootstrap-current-only", preview.fetch("migration_state") + + bootstrapped = @promoter.bootstrap_current( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, evidence), + acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT + ) + consumer = bootstrapped.dig("consumers", CONSUMER) + + assert_equal "certified", consumer.dig("current", "status") + assert_equal CANDIDATE_COMMIT, consumer.dig("current", "consumer_commit") + assert_nil consumer["candidate"] + assert_nil consumer["previous"] + assert_equal "observed-production-contract-failed", consumer.dig("emergency_provenance", "status") + assert_nil consumer.dig("emergency_provenance", "certification") + assert_equal "degraded-no-certified-previous", consumer.dig("rollback_state", "status") + assert_equal "bootstrap-current-only", bootstrapped.dig("promotion_readiness", "status") + assert_equal bootstrapped, JSON.parse(File.read(manifest_path)) + end + + def test_bootstrap_then_normal_promotion_retains_the_first_passing_tuple_as_previous + manifest = valid_manifest + manifest.dig("consumers", CONSUMER, "current")["status"] = "observed-production-contract-failed" + write_manifest(manifest) + bootstrap_fingerprint = @promoter.fingerprints( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT + ).fetch("candidate_tuple_sha256") + bootstrap_evidence = write_evidence( + "bootstrap-rollback.json", + commit: CANDIDATE_COMMIT, + timestamp: CANDIDATE_TIME, + fingerprint: bootstrap_fingerprint + ) + @promoter.bootstrap_current( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, bootstrap_evidence), + acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT + ) + + manifest = JSON.parse(File.read(manifest_path)) + manifest.dig("consumers", CONSUMER)["candidate"] = { + "status" => "compatibility-certified", + "certified_at" => NEXT_TIME, + "consumer_commit" => NEXT_COMMIT, + "opencode_ruby" => {"version" => "0.0.1.alpha8", "git_commit" => RUBY_NEXT}, + "opencode_rails" => {"version" => "0.0.1.alpha8", "git_commit" => RAILS_NEXT}, + "runtime" => {"image" => NEXT_IMAGE, "reported_version" => "1.19.0"} + } + write_manifest(manifest) + next_fingerprint = @promoter.fingerprints( + consumer: CONSUMER, + consumer_commit: NEXT_COMMIT + ).fetch("candidate_tuple_sha256") + next_evidence = write_evidence( + "next-current.json", + commit: NEXT_COMMIT, + timestamp: NEXT_TIME, + fingerprint: next_fingerprint + ) + + promoted = @promoter.promote( + consumer: CONSUMER, + consumer_commit: NEXT_COMMIT, + certification: certification(NEXT_TIME, next_evidence) + ) + consumer = promoted.dig("consumers", CONSUMER) + + assert_equal NEXT_COMMIT, consumer.dig("current", "consumer_commit") + assert_equal CANDIDATE_COMMIT, consumer.dig("previous", "consumer_commit") + assert_equal "certified", consumer.dig("previous", "status") + assert_equal "observed-production-contract-failed", consumer.dig("emergency_provenance", "status") + refute consumer.key?("rollback_state") + assert_equal "certified", promoted.fetch("migration_state") + assert_equal "certified", promoted.dig("promotion_readiness", "status") + end + + def test_degraded_bootstrap_requires_exact_acknowledgement + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.bootstrap_current( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: {}, + acknowledgement: "yes", + dry_run: true + ) + end + + assert_match(/explicit acknowledgement/, error.message) + end + + def test_degraded_bootstrap_rejects_a_passing_baseline_or_existing_previous_tuple + candidate, = write_matching_evidence + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.bootstrap_current( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, candidate), + acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT, + dry_run: true + ) + end + assert_match(/only for a current tuple known to fail/, error.message) + + manifest = valid_manifest + manifest.dig("consumers", CONSUMER, "current")["status"] = "observed-production-contract-failed" + manifest.dig("consumers", CONSUMER)["previous"] = manifest.dig("consumers", CONSUMER, "current").dup + write_manifest(manifest) + candidate_fingerprint = @promoter.fingerprints( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT + ).fetch("candidate_tuple_sha256") + evidence = write_evidence( + "bootstrap-existing-previous.json", + commit: CANDIDATE_COMMIT, + timestamp: CANDIDATE_TIME, + fingerprint: candidate_fingerprint + ) + error = assert_raises(OpenCodeCompat::PromotionError) do + @promoter.bootstrap_current( + consumer: CONSUMER, + consumer_commit: CANDIDATE_COMMIT, + certification: certification(CANDIDATE_TIME, evidence), + acknowledgement: OpenCodeCompat::RuntimeTuplePromoter::DEGRADED_BOOTSTRAP_ACKNOWLEDGEMENT, + dry_run: true + ) + end + assert_match(/cannot replace an existing previous/, error.message) + end + def test_rejects_evidence_that_does_not_match_the_complete_tuple candidate, previous = write_matching_evidence manifest = JSON.parse(File.read(manifest_path))