diff --git a/.github/workflows/container-security.yml b/.github/workflows/container-security.yml index 7f6bd12..94ba5d1 100644 --- a/.github/workflows/container-security.yml +++ b/.github/workflows/container-security.yml @@ -73,23 +73,43 @@ jobs: run: bash -n scripts/container-security-scan.sh - name: Build ${{ matrix.target }} image + id: build-image run: | set -euo pipefail mkdir -p "$REPORT_DIR" - DOCKER_BUILDKIT=1 docker build \ - -f docker/app/Dockerfile \ - --target "${{ matrix.target }}" \ - --build-arg "OPENCODE_VERSION=${OPENCODE_VERSION}" \ - --build-arg "CODEX_VERSION=${CODEX_VERSION}" \ - --build-arg "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" \ - --build-arg "CODEBUDDY_VERSION=${CODEBUDDY_VERSION}" \ - --build-arg "SYMPHONY_UID=${SYMPHONY_UID}" \ - --build-arg "SYMPHONY_GID=${SYMPHONY_GID}" \ - -t "$IMAGE_NAME" \ - . + + build_succeeded=false + for attempt in 1 2 3; do + echo "Docker build attempt ${attempt}/3" + if DOCKER_BUILDKIT=1 docker build \ + -f docker/app/Dockerfile \ + --target "${{ matrix.target }}" \ + --build-arg "OPENCODE_VERSION=${OPENCODE_VERSION}" \ + --build-arg "CODEX_VERSION=${CODEX_VERSION}" \ + --build-arg "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" \ + --build-arg "CODEBUDDY_VERSION=${CODEBUDDY_VERSION}" \ + --build-arg "SYMPHONY_UID=${SYMPHONY_UID}" \ + --build-arg "SYMPHONY_GID=${SYMPHONY_GID}" \ + -t "$IMAGE_NAME" \ + .; then + build_succeeded=true + break + fi + + if [ "$attempt" -lt 3 ]; then + sleep $((attempt * 15)) + fi + done + + if [ "$build_succeeded" != "true" ]; then + exit 1 + fi + + echo "built=true" >> "$GITHUB_OUTPUT" - name: Trivy vulnerability gate - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + if: steps.build-image.outputs.built == 'true' + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.IMAGE_NAME }} format: table @@ -98,8 +118,8 @@ jobs: exit-code: "1" - name: Trivy JSON report - if: always() - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + if: steps.build-image.outputs.built == 'true' + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.IMAGE_NAME }} format: json @@ -109,8 +129,8 @@ jobs: exit-code: "0" - name: Generate SPDX SBOM - if: always() - uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + if: steps.build-image.outputs.built == 'true' + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: image: ${{ env.IMAGE_NAME }} format: spdx-json @@ -118,8 +138,8 @@ jobs: dependency-snapshot: false - name: Generate CycloneDX SBOM - if: always() - uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + if: steps.build-image.outputs.built == 'true' + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: image: ${{ env.IMAGE_NAME }} format: cyclonedx-json @@ -128,7 +148,7 @@ jobs: - name: Upload container security reports if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: container-security-${{ matrix.target }} path: ${{ env.REPORT_DIR }} diff --git a/.gitignore b/.gitignore index 4ed10c4..3201f77 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ Thumbs.db # Local runtime logs and specs (Existing) # ========================================== /log/ -specs/ \ No newline at end of file +specs/ + +maestro-book diff --git a/elixir/lib/symphony_elixir/platform/priv_assets.ex b/elixir/lib/symphony_elixir/platform/priv_assets.ex new file mode 100644 index 0000000..fac3829 --- /dev/null +++ b/elixir/lib/symphony_elixir/platform/priv_assets.ex @@ -0,0 +1,200 @@ +defmodule SymphonyElixir.Platform.PrivAssets do + @moduledoc false + + @default_otp_app :symphony_elixir + @cache_rootname "symphony-elixir-priv-assets" + + @spec app_priv_root!(Path.t(), keyword()) :: Path.t() + def app_priv_root!(relative_dir, opts \\ []) when is_binary(relative_dir) and is_list(opts) do + otp_app = Keyword.get(opts, :otp_app, @default_otp_app) + relative_dir = validate_relative_dir!(relative_dir) + + otp_app + |> priv_dir!() + |> real_priv_dir!() + |> Path.join(relative_dir) + |> Path.expand() + end + + defp priv_dir!(otp_app) when is_atom(otp_app) and not is_nil(otp_app) do + case :code.priv_dir(otp_app) do + path when is_list(path) -> + to_string(path) + + {:error, reason} -> + raise ArgumentError, + "could not resolve priv directory for otp_app #{inspect(otp_app)}: #{inspect(reason)}" + end + end + + defp priv_dir!(otp_app) do + raise ArgumentError, "application priv asset otp_app must be an atom, got #{inspect(otp_app)}" + end + + defp real_priv_dir!(priv_dir) when is_binary(priv_dir) do + if File.dir?(priv_dir) do + priv_dir + else + extracted_priv_dir!(priv_dir) + end + end + + defp extracted_priv_dir!(expected_priv_dir) do + script_path = escript_path!() + cache_root = extraction_cache_root!(script_path) + priv_dir = ensure_extracted_priv_dir!(script_path, cache_root, expected_priv_dir) + add_extracted_code_paths(cache_root) + + if File.dir?(priv_dir) do + priv_dir + else + raise ArgumentError, + "could not resolve extracted application priv directory: #{inspect(priv_dir)}" + end + end + + defp add_extracted_code_paths(cache_root) do + [cache_root, "*", "ebin"] + |> Path.join() + |> Path.wildcard() + |> Enum.sort() + |> Enum.each(&:code.add_patha(String.to_charlist(&1))) + end + + defp escript_path! do + script_path = + :escript.script_name() + |> List.to_string() + |> Path.expand() + + cond do + script_path in ["", Path.expand("-e")] -> + raise ArgumentError, "could not resolve escript path for application priv assets" + + File.regular?(script_path) -> + script_path + + true -> + raise ArgumentError, + "could not resolve escript path for application priv assets: #{inspect(script_path)}" + end + end + + defp extraction_cache_root!(script_path) do + case File.stat(script_path) do + {:ok, %File.Stat{size: size, mtime: mtime}} -> + cache_key = :erlang.phash2({script_path, size, mtime}) |> Integer.to_string(36) + Path.join([System.tmp_dir!(), @cache_rootname, cache_key]) + + {:error, reason} -> + raise ArgumentError, + "could not stat escript for application priv assets: #{inspect(script_path)} reason=#{inspect(reason)}" + end + end + + defp ensure_extracted_priv_dir!(script_path, cache_root, expected_priv_dir) do + expected_priv_dir = Path.expand(expected_priv_dir) + + case find_extracted_priv_dir(cache_root, script_path, expected_priv_dir) do + {:ok, priv_dir} -> + priv_dir + + :error -> + File.rm_rf!(cache_root) + File.mkdir_p!(cache_root) + + script_path + |> escript_archive!() + |> unzip_archive!(cache_root) + + case find_extracted_priv_dir(cache_root, script_path, expected_priv_dir) do + {:ok, priv_dir} -> + priv_dir + + :error -> + raise ArgumentError, + "could not find bundled application priv directory after extracting #{inspect(script_path)}" + end + end + end + + defp find_extracted_priv_dir(cache_root, script_path, expected_priv_dir) do + direct_candidate = + expected_priv_dir + |> Path.relative_to(script_path) + |> direct_candidate(cache_root) + + cond do + is_binary(direct_candidate) and File.dir?(direct_candidate) -> + {:ok, direct_candidate} + + true -> + [cache_root, "**", "priv"] + |> Path.join() + |> Path.wildcard() + |> Enum.find(&File.dir?/1) + |> case do + nil -> :error + priv_dir -> {:ok, priv_dir} + end + end + end + + defp direct_candidate(relative_path, cache_root) do + if Path.type(relative_path) == :relative do + Path.join(cache_root, relative_path) + end + end + + defp escript_archive!(script_path) do + case :escript.extract(String.to_charlist(script_path), []) do + {:ok, sections} -> + case Keyword.fetch(sections, :archive) do + {:ok, archive_bin} when is_binary(archive_bin) -> + archive_bin + + {:ok, _section} -> + raise ArgumentError, "invalid escript archive for application priv assets" + + :error -> + raise ArgumentError, "missing escript archive for application priv assets" + end + + {:error, reason} -> + raise ArgumentError, "could not extract escript archive for application priv assets: #{inspect(reason)}" + end + end + + defp unzip_archive!(archive_bin, cache_root) do + case :zip.extract(archive_bin, [{:cwd, String.to_charlist(cache_root)}]) do + {:ok, _paths} -> + cache_root + + {:error, reason} -> + raise ArgumentError, "could not unzip escript archive for application priv assets: #{inspect(reason)}" + end + end + + defp validate_relative_dir!(relative_dir) do + relative_dir = String.trim(relative_dir) + segments = Path.split(relative_dir) + + cond do + relative_dir == "" -> + raise ArgumentError, "application priv asset root must be non-empty" + + Path.type(relative_dir) == :absolute -> + raise ArgumentError, "application priv asset root must be relative" + + contains_forbidden_relative_segment?(segments) -> + raise ArgumentError, "application priv asset root must stay under the application priv directory" + + true -> + Path.join(segments) + end + end + + defp contains_forbidden_relative_segment?(segments) do + Enum.any?(segments, &(&1 in [".", ".."])) + end +end diff --git a/elixir/lib/symphony_elixir/storage/migrator.ex b/elixir/lib/symphony_elixir/storage/migrator.ex index 03c8548..3b51071 100644 --- a/elixir/lib/symphony_elixir/storage/migrator.ex +++ b/elixir/lib/symphony_elixir/storage/migrator.ex @@ -7,6 +7,7 @@ defmodule SymphonyElixir.Storage.Migrator do for the local service profile. """ + alias SymphonyElixir.Platform.PrivAssets alias SymphonyElixir.Storage.{ErrorCodes, Repo} @spec child_spec(keyword()) :: Supervisor.child_spec() @@ -31,7 +32,7 @@ defmodule SymphonyElixir.Storage.Migrator do @spec migrate(module()) :: :ok | {:error, map()} def migrate(repo \\ Repo) do - path = Ecto.Migrator.migrations_path(repo) + path = migrations_path(repo) compiler_options = Code.compiler_options() try do @@ -54,4 +55,36 @@ defmodule SymphonyElixir.Storage.Migrator do reason: reason } end + + defp migrations_path(repo) do + repo + |> repo_priv_root() + |> Path.join("migrations") + |> PrivAssets.app_priv_root!(otp_app: repo_otp_app(repo)) + end + + defp repo_priv_root(repo) do + repo + |> repo_config() + |> Keyword.get(:priv, "priv/storage_repo") + |> strip_priv_prefix() + end + + defp repo_otp_app(repo) do + repo + |> repo_config() + |> Keyword.get(:otp_app, :symphony_elixir) + end + + defp repo_config(repo) do + if function_exported?(repo, :config, 0) do + repo.config() + else + [] + end + end + + defp strip_priv_prefix("priv/" <> rest), do: rest + defp strip_priv_prefix("priv"), do: "" + defp strip_priv_prefix(priv) when is_binary(priv), do: priv end diff --git a/elixir/lib/symphony_elixir/storage/redaction/default_backend.ex b/elixir/lib/symphony_elixir/storage/redaction/default_backend.ex index e642afa..2488c5d 100644 --- a/elixir/lib/symphony_elixir/storage/redaction/default_backend.ex +++ b/elixir/lib/symphony_elixir/storage/redaction/default_backend.ex @@ -3,6 +3,8 @@ defmodule SymphonyElixir.Storage.Redaction.DefaultBackend do @behaviour SymphonyElixir.Storage.Redaction + alias SymphonyElixir.Observability.Redaction, as: ObservabilityRedaction + @redacted "[REDACTED]" @sensitive_key_patterns [ ~r/password/i, @@ -49,6 +51,7 @@ defmodule SymphonyElixir.Storage.Redaction.DefaultBackend do end defp redact_value(tuple) when is_tuple(tuple), do: tuple |> Tuple.to_list() |> redact_value() |> List.to_tuple() + defp redact_value(value) when is_binary(value), do: ObservabilityRedaction.redact_string(value) defp redact_value(value), do: value defp sensitive_key?(key) when is_atom(key), do: key |> Atom.to_string() |> sensitive_key?() diff --git a/elixir/lib/symphony_elixir/storage/scrubber.ex b/elixir/lib/symphony_elixir/storage/scrubber.ex new file mode 100644 index 0000000..4b9b7c7 --- /dev/null +++ b/elixir/lib/symphony_elixir/storage/scrubber.ex @@ -0,0 +1,99 @@ +defmodule SymphonyElixir.Storage.Scrubber do + @moduledoc """ + Fail-closed storage scrubbing boundary for evidence-like payloads. + + Callers use this before handing payloads to durable storage or external + rendering targets. Backend selection stays with `Storage.Redaction`; this + module owns safe error shaping and type-preserving wrappers. + """ + + alias SymphonyElixir.Storage.ErrorCodes + alias SymphonyElixir.Storage.Redaction + + @redaction_backend_opt :storage_redaction_backend + @redaction_opts_key :storage_redaction_opts + + @spec scrub(term(), keyword()) :: {:ok, term()} | {:error, map()} + def scrub(value, opts \\ []) when is_list(opts) do + case Redaction.redact(value, redaction_opts(opts)) do + {:error, reason} -> {:error, redaction_failed(reason)} + redacted -> {:ok, redacted} + end + rescue + error -> {:error, redaction_failed(%{reason: :backend_failed, exception: inspect(error.__struct__)})} + catch + kind, reason -> {:error, redaction_failed(%{reason: :backend_failed, kind: kind, reason_type: type_name(reason)})} + end + + @spec scrub_map(map(), keyword()) :: {:ok, map()} | {:error, map()} + def scrub_map(value, opts \\ []) when is_map(value) and is_list(opts) do + case scrub(value, opts) do + {:ok, scrubbed} when is_map(scrubbed) -> + {:ok, scrubbed} + + {:ok, scrubbed} -> + {:error, redaction_failed(%{reason: :invalid_redaction_result, return_type: type_name(scrubbed)})} + + {:error, reason} -> + {:error, reason} + end + end + + @spec scrub_map_list([map()], keyword()) :: {:ok, [map()]} | {:error, map()} + def scrub_map_list(values, opts \\ []) when is_list(values) and is_list(opts) do + case scrub(values, opts) do + {:ok, scrubbed} when is_list(scrubbed) -> + if Enum.all?(scrubbed, &is_map/1) do + {:ok, scrubbed} + else + {:error, redaction_failed(%{reason: :invalid_redaction_result, return_type: "list"})} + end + + {:ok, scrubbed} -> + {:error, redaction_failed(%{reason: :invalid_redaction_result, return_type: type_name(scrubbed)})} + + {:error, reason} -> + {:error, reason} + end + end + + defp redaction_opts(opts) do + opts + |> Keyword.get(@redaction_opts_key, []) + |> normalize_redaction_opts() + |> put_backend(Keyword.get(opts, @redaction_backend_opt)) + end + + defp normalize_redaction_opts(opts) when is_list(opts) do + if Keyword.keyword?(opts), do: opts, else: [] + end + + defp normalize_redaction_opts(_opts), do: [] + + defp put_backend(opts, nil), do: opts + defp put_backend(opts, backend), do: Keyword.put(opts, :backend, backend) + + defp redaction_failed(reason) do + %{ + code: ErrorCodes.redaction_failed(), + message: "Payload could not be scrubbed before storage.", + payload_summary: bounded_reason(reason) + } + end + + defp bounded_reason(reason) when is_map(reason) do + Map.take(reason, [:code, :reason, :backend, :backend_type, :return_type, :exception, :kind, :reason_type]) + end + + defp bounded_reason(reason), do: %{reason_type: type_name(reason)} + + defp type_name(value) when is_binary(value), do: "string" + defp type_name(value) when is_atom(value), do: "atom" + defp type_name(value) when is_integer(value), do: "integer" + defp type_name(value) when is_float(value), do: "float" + defp type_name(value) when is_boolean(value), do: "boolean" + defp type_name(value) when is_list(value), do: "list" + defp type_name(value) when is_map(value), do: "map" + defp type_name(value) when is_tuple(value), do: "tuple" + defp type_name(_value), do: "term" +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery.ex index 260f993..18fa50f 100644 --- a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery.ex +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery.ex @@ -14,7 +14,19 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery do alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.CompletionValidator alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ConfigValidator alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Manifest - alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ChangeProposalReconcile + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.{ + ChangeProposalReconcile, + ProductionProfileEvidenceBundle, + ProductionProfileEvidenceHandoff, + ProductionProfileEvidenceRequest, + ProductionProfilePlan, + ProductionProfilePreflightCollect, + ProductionProfileStatus, + ProductionProfileTemplate, + ProductionProfileValidate + } + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Profile alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Readiness alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Runtime @@ -35,7 +47,18 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery do end @impl true - def operator_commands, do: [ChangeProposalReconcile] + def operator_commands, + do: [ + ChangeProposalReconcile, + ProductionProfilePlan, + ProductionProfileEvidenceRequest, + ProductionProfileEvidenceBundle, + ProductionProfileEvidenceHandoff, + ProductionProfileValidate, + ProductionProfileTemplate, + ProductionProfilePreflightCollect, + ProductionProfileStatus + ] @impl true def tool_result_recorders, do: [ToolResultRecorder] diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_bundle.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_bundle.ex new file mode 100644 index 0000000..7af82b3 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_bundle.ex @@ -0,0 +1,256 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceBundle do + @moduledoc """ + Operator command for Coding PR Delivery Phase 2 evidence bundle readiness. + + The command reads bounded metadata files for the Phase 2 plan, provider + preflight report, evidence request, and completed evidence packets. It does + not read referenced evidence files, call providers, mutate workflow state, + approve production, or enable gates. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_bundle" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @validation_failed_exit_code 1 + @switches [ + plan: :string, + phase2_plan_file: :string, + evidence_request_file: :string, + preflight_report_file: :string, + evidence_packet_file: :string, + plan_id: :string, + tapd_cnb_shadow_run_id: :string, + linear_cnb_shadow_run_id: :string, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + + @type bundle_result :: {:ok, map()} | {:error, map()} + @type deps :: %{ + required(:read_file) => (String.t() -> {:ok, binary()} | {:error, term()}), + required(:production_evidence_bundle) => (String.t() | map(), keyword() -> bundle_result()) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + read_file: &File.read/1, + production_evidence_bundle: fn input, opts -> ProductionProfile.production_evidence_bundle(input, opts) end + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + render_bundle(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp render_bundle(parsed_opts, deps) do + with {:ok, input} <- bundle_input(parsed_opts, deps), + {:ok, opts} <- bundle_opts(parsed_opts, deps), + {:ok, bundle} <- deps.production_evidence_bundle.(input, opts) do + {Jason.encode!(bundle, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", 0} + else + {:error, {:usage, message}} -> + {"", message <> "\n" <> usage(), @usage_error_exit_code} + + {:error, reason} -> + {Jason.encode!(bundle_error(reason), pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", @validation_failed_exit_code} + end + end + + defp bundle_input(parsed_opts, deps) do + case Keyword.get(parsed_opts, :phase2_plan_file) do + nil -> {:ok, parsed_opts |> Keyword.get(:plan, "tiered_reference") |> String.trim()} + file_path -> read_json_file(file_path, "Phase 2 plan", deps) + end + end + + defp bundle_opts(parsed_opts, deps) do + opts = + [] + |> maybe_put(:plan_id, parsed_opts[:plan_id]) + |> maybe_put(:tapd_cnb_shadow_run_id, parsed_opts[:tapd_cnb_shadow_run_id]) + |> maybe_put(:linear_cnb_shadow_run_id, parsed_opts[:linear_cnb_shadow_run_id]) + + with {:ok, opts} <- maybe_put_json_file(opts, parsed_opts, deps, :evidence_request_file, :evidence_request, "Evidence request"), + {:ok, opts} <- maybe_put_json_file(opts, parsed_opts, deps, :preflight_report_file, :preflight_report, "Preflight report"), + {:ok, packets} <- read_evidence_packet_files(parsed_opts, deps) do + {:ok, Keyword.put(opts, :evidence_packets, packets)} + end + end + + defp maybe_put_json_file(opts, parsed_opts, deps, file_key, opt_key, label) do + case Keyword.get(parsed_opts, file_key) do + nil -> + {:ok, opts} + + file_path -> + with {:ok, packet} <- read_json_file(file_path, label, deps) do + {:ok, Keyword.put(opts, opt_key, packet)} + end + end + end + + defp read_evidence_packet_files(parsed_opts, deps) do + parsed_opts + |> Keyword.get_values(:evidence_packet_file) + |> Enum.reduce_while({:ok, []}, fn file_path, {:ok, acc} -> + case read_json_file(file_path, "Evidence packet", deps) do + {:ok, packet} -> {:cont, {:ok, [packet | acc]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, packets} -> {:ok, Enum.reverse(packets)} + {:error, reason} -> {:error, reason} + end + end + + defp read_json_file(file_path, label, deps) do + with {:ok, contents} <- deps.read_file.(file_path), + {:ok, decoded} <- Jason.decode(contents), + true <- is_map(decoded) do + {:ok, decoded} + else + {:error, _reason} -> + {:error, {:usage, "#{label} file could not be read or parsed."}} + + false -> + {:error, {:usage, "#{label} file must contain an object."}} + end + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp bundle_error(reason) do + %{ + "schema" => "coding_pr_delivery.production_evidence_bundle_result.v1", + "status" => "invalid", + "valid" => false, + "errors" => errors(reason), + "raw_input_included" => false, + "does_not_read_referenced_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(%{"errors" => errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(reason), do: [error_to_map(reason)] + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "production_evidence_bundle_error", "message" => "Evidence bundle projection failed."} + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(%{read_file: read_file, production_evidence_bundle: bundle}) + when is_function(read_file, 1) and is_function(bundle, 2), + do: :ok + + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp usage do + """ + Command arguments: + [--plan |--phase2-plan-file ] [--evidence-request-file ] + [--preflight-report-file ] [--evidence-packet-file ]... [--json|--pretty] + [--tapd-cnb-shadow-run-id ] [--linear-cnb-shadow-run-id ] + + Supported plans: + tiered_reference + linear_github_ready + tapd_cnb_shadow + linear_cnb_shadow + + This command emits a bounded Phase 2 evidence bundle readiness packet. It + does not read referenced evidence files, call providers, mutate workflow + state, approve production, or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_handoff.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_handoff.ex new file mode 100644 index 0000000..921c841 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_handoff.ex @@ -0,0 +1,257 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceHandoff do + @moduledoc """ + Operator command for Coding PR Delivery Phase 2 evidence handoff packages. + + The command reads bounded metadata files for the Phase 2 plan, provider + preflight report, evidence request, and completed evidence packets. It emits a + single provider-owner handoff package without reading referenced evidence + files, calling providers, mutating workflow state, approving production, or + enabling gates. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_handoff" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @validation_failed_exit_code 1 + @switches [ + plan: :string, + phase2_plan_file: :string, + evidence_request_file: :string, + preflight_report_file: :string, + evidence_packet_file: :string, + plan_id: :string, + tapd_cnb_shadow_run_id: :string, + linear_cnb_shadow_run_id: :string, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + + @type handoff_result :: {:ok, map()} | {:error, map()} + @type deps :: %{ + required(:read_file) => (String.t() -> {:ok, binary()} | {:error, term()}), + required(:production_evidence_handoff) => (String.t() | map(), keyword() -> handoff_result()) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + read_file: &File.read/1, + production_evidence_handoff: fn input, opts -> ProductionProfile.production_evidence_handoff(input, opts) end + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + render_handoff(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp render_handoff(parsed_opts, deps) do + with {:ok, input} <- handoff_input(parsed_opts, deps), + {:ok, opts} <- handoff_opts(parsed_opts, deps), + {:ok, handoff} <- deps.production_evidence_handoff.(input, opts) do + {Jason.encode!(handoff, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", 0} + else + {:error, {:usage, message}} -> + {"", message <> "\n" <> usage(), @usage_error_exit_code} + + {:error, reason} -> + {Jason.encode!(handoff_error(reason), pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", @validation_failed_exit_code} + end + end + + defp handoff_input(parsed_opts, deps) do + case Keyword.get(parsed_opts, :phase2_plan_file) do + nil -> {:ok, parsed_opts |> Keyword.get(:plan, "tiered_reference") |> String.trim()} + file_path -> read_json_file(file_path, "Phase 2 plan", deps) + end + end + + defp handoff_opts(parsed_opts, deps) do + opts = + [] + |> maybe_put(:plan_id, parsed_opts[:plan_id]) + |> maybe_put(:tapd_cnb_shadow_run_id, parsed_opts[:tapd_cnb_shadow_run_id]) + |> maybe_put(:linear_cnb_shadow_run_id, parsed_opts[:linear_cnb_shadow_run_id]) + + with {:ok, opts} <- maybe_put_json_file(opts, parsed_opts, deps, :evidence_request_file, :evidence_request, "Evidence request"), + {:ok, opts} <- maybe_put_json_file(opts, parsed_opts, deps, :preflight_report_file, :preflight_report, "Preflight report"), + {:ok, packets} <- read_evidence_packet_files(parsed_opts, deps) do + {:ok, Keyword.put(opts, :evidence_packets, packets)} + end + end + + defp maybe_put_json_file(opts, parsed_opts, deps, file_key, opt_key, label) do + case Keyword.get(parsed_opts, file_key) do + nil -> + {:ok, opts} + + file_path -> + with {:ok, packet} <- read_json_file(file_path, label, deps) do + {:ok, Keyword.put(opts, opt_key, packet)} + end + end + end + + defp read_evidence_packet_files(parsed_opts, deps) do + parsed_opts + |> Keyword.get_values(:evidence_packet_file) + |> Enum.reduce_while({:ok, []}, fn file_path, {:ok, acc} -> + case read_json_file(file_path, "Evidence packet", deps) do + {:ok, packet} -> {:cont, {:ok, [packet | acc]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, packets} -> {:ok, Enum.reverse(packets)} + {:error, reason} -> {:error, reason} + end + end + + defp read_json_file(file_path, label, deps) do + with {:ok, contents} <- deps.read_file.(file_path), + {:ok, decoded} <- Jason.decode(contents), + true <- is_map(decoded) do + {:ok, decoded} + else + {:error, _reason} -> + {:error, {:usage, "#{label} file could not be read or parsed."}} + + false -> + {:error, {:usage, "#{label} file must contain an object."}} + end + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp handoff_error(reason) do + %{ + "schema" => "coding_pr_delivery.production_evidence_handoff_result.v1", + "status" => "invalid", + "valid" => false, + "errors" => errors(reason), + "raw_input_included" => false, + "does_not_read_referenced_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(%{"errors" => errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(reason), do: [error_to_map(reason)] + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "production_evidence_handoff_error", "message" => "Evidence handoff projection failed."} + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(%{read_file: read_file, production_evidence_handoff: handoff}) + when is_function(read_file, 1) and is_function(handoff, 2), + do: :ok + + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp usage do + """ + Command arguments: + [--plan |--phase2-plan-file ] [--evidence-request-file ] + [--preflight-report-file ] [--evidence-packet-file ]... [--json|--pretty] + [--tapd-cnb-shadow-run-id ] [--linear-cnb-shadow-run-id ] + + Supported plans: + tiered_reference + linear_github_ready + tapd_cnb_shadow + linear_cnb_shadow + + This command emits a bounded Phase 2 provider-evidence handoff package. It + does not read referenced evidence files, call providers, mutate workflow + state, approve production, or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_request.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_request.ex new file mode 100644 index 0000000..4b78cd4 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_request.ex @@ -0,0 +1,217 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceRequest do + @moduledoc """ + Operator command for Coding PR Delivery Phase 2 evidence requests. + + The command emits a bounded provider-owner handoff packet naming required + credentials, auth checks, concrete targets, preflight probes, and evidence + files. It does not read referenced evidence files, call providers, mutate + workflow state, approve production, or enable gates. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_request" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @validation_failed_exit_code 1 + @switches [ + plan: :string, + phase2_plan_file: :string, + plan_id: :string, + tapd_cnb_shadow_run_id: :string, + linear_cnb_shadow_run_id: :string, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + + @type request_result :: {:ok, map()} | {:error, map()} + @type deps :: %{ + required(:read_file) => (String.t() -> {:ok, binary()} | {:error, term()}), + required(:production_evidence_request) => (String.t() | map(), keyword() -> request_result()) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + read_file: &File.read/1, + production_evidence_request: fn input, opts -> ProductionProfile.production_evidence_request(input, opts) end + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + render_request(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp render_request(parsed_opts, deps) do + with {:ok, input} <- request_input(parsed_opts, deps), + {:ok, request} <- deps.production_evidence_request.(input, request_opts(parsed_opts)) do + {Jason.encode!(request, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", 0} + else + {:error, {:usage, message}} -> + {"", message <> "\n" <> usage(), @usage_error_exit_code} + + {:error, reason} -> + {Jason.encode!(request_error(reason), pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", @validation_failed_exit_code} + end + end + + defp request_input(parsed_opts, deps) do + case Keyword.get(parsed_opts, :phase2_plan_file) do + nil -> {:ok, parsed_opts |> Keyword.get(:plan, "tiered_reference") |> String.trim()} + file_path -> read_json_file(file_path, deps) + end + end + + defp request_opts(parsed_opts) do + [] + |> maybe_put(:plan_id, parsed_opts[:plan_id]) + |> maybe_put(:tapd_cnb_shadow_run_id, parsed_opts[:tapd_cnb_shadow_run_id]) + |> maybe_put(:linear_cnb_shadow_run_id, parsed_opts[:linear_cnb_shadow_run_id]) + end + + defp read_json_file(file_path, deps) do + with {:ok, contents} <- deps.read_file.(file_path), + {:ok, decoded} <- Jason.decode(contents), + true <- is_map(decoded) do + {:ok, decoded} + else + {:error, _reason} -> + {:error, {:usage, "Phase 2 plan file could not be read or parsed."}} + + false -> + {:error, {:usage, "Phase 2 plan file must contain an object."}} + end + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp request_error(reason) do + %{ + "schema" => "coding_pr_delivery.production_evidence_request_result.v1", + "status" => "invalid", + "valid" => false, + "errors" => errors(reason), + "raw_input_included" => false, + "does_not_read_referenced_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(%{"errors" => errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(reason), do: [error_to_map(reason)] + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "production_evidence_request_error", "message" => "Evidence request projection failed."} + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(%{read_file: read_file, production_evidence_request: request}) + when is_function(read_file, 1) and is_function(request, 2), + do: :ok + + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp usage do + """ + Command arguments: + [--plan |--phase2-plan-file ] [--json|--pretty] + [--tapd-cnb-shadow-run-id ] [--linear-cnb-shadow-run-id ] + + Supported plans: + tiered_reference + linear_github_ready + tapd_cnb_shadow + linear_cnb_shadow + + This command emits a bounded Phase 2 provider-evidence request packet. It + does not read referenced evidence files, call providers, mutate workflow + state, approve production, or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_plan.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_plan.ex new file mode 100644 index 0000000..6a13b1a --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_plan.ex @@ -0,0 +1,194 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfilePlan do + @moduledoc """ + Operator command for Coding PR Delivery production-profile plan export. + + The command owns Coding PR Delivery production-plan argument validation and + rendering. Platform CLI entrypoints dispatch to this module through the + workflow extension operator-command registry. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_plan" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @switches [ + phase: :string, + plan: :string, + plan_id: :string, + tapd_cnb_shadow_run_id: :string, + linear_cnb_shadow_run_id: :string, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + @phases ["phase2", "phase4"] + + @type deps :: %{ + required(:phase2_evidence_plan) => (String.t(), keyword() -> {:ok, map()} | {:error, map()}), + required(:phase4_review_plan) => (String.t(), keyword() -> {:ok, map()} | {:error, map()}) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + phase2_evidence_plan: fn plan, opts -> ProductionProfile.phase2_evidence_plan(plan, opts) end, + phase4_review_plan: fn plan, opts -> ProductionProfile.phase4_review_plan(plan, opts) end + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + render_plan(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp render_plan(parsed_opts, deps) do + phase = parsed_opts |> Keyword.get(:phase, "phase2") |> String.trim() + plan = parsed_opts |> Keyword.get(:plan, "tiered_reference") |> String.trim() + + if phase in @phases do + phase + |> build_plan(plan, build_opts(parsed_opts), deps) + |> render_plan_result(parsed_opts) + else + {"", "Unsupported phase. Expected one of: #{Enum.join(@phases, ", ")}\n" <> usage(), @usage_error_exit_code} + end + end + + defp build_plan("phase2", plan, opts, deps), do: deps.phase2_evidence_plan.(plan, opts) + defp build_plan("phase4", plan, opts, deps), do: deps.phase4_review_plan.(plan, opts) + + defp build_opts(parsed_opts) do + [] + |> maybe_put(:plan_id, parsed_opts[:plan_id]) + |> maybe_put(:tapd_cnb_shadow_run_id, parsed_opts[:tapd_cnb_shadow_run_id]) + |> maybe_put(:linear_cnb_shadow_run_id, parsed_opts[:linear_cnb_shadow_run_id]) + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp render_plan_result({:ok, plan}, parsed_opts) do + output = Jason.encode!(plan, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n" + {output, "", 0} + end + + defp render_plan_result({:error, reason}, _parsed_opts) do + message = Map.get(reason, :message) || Map.get(reason, "message") || "production-profile plan is invalid" + {"", message <> "\n", @usage_error_exit_code} + end + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(%{phase2_evidence_plan: phase2, phase4_review_plan: phase4}) + when is_function(phase2, 2) and is_function(phase4, 2), + do: :ok + + defp validate_deps(%{phase2_evidence_plan: phase2}) when not is_function(phase2, 2) do + {:error, {:phase2_evidence_plan_not_function, Diagnostics.type_name(phase2)}} + end + + defp validate_deps(%{phase4_review_plan: phase4}) when not is_function(phase4, 2) do + {:error, {:phase4_review_plan_not_function, Diagnostics.type_name(phase4)}} + end + + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp format_internal_error({:phase2_evidence_plan_not_function, type}), + do: "Coding PR Delivery operator command options are invalid: reason=phase2_evidence_plan_not_function value_type=#{type}\n" + + defp format_internal_error({:phase4_review_plan_not_function, type}), + do: "Coding PR Delivery operator command options are invalid: reason=phase4_review_plan_not_function value_type=#{type}\n" + + defp usage do + """ + Command arguments: + [--phase phase2|phase4] [--plan ] [--json|--pretty] + [--phase phase4] [--plan linear_cnb_shadow] --linear-cnb-shadow-run-id [--json|--pretty] + + Supported plans: + tiered_reference + linear_github_ready + tapd_cnb_shadow + linear_cnb_shadow + + This command exports deterministic Coding PR Delivery production-profile + plans only. It does not collect live evidence, read evidence files, call + providers, mutate workflow state, approve production, or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_preflight_collect.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_preflight_collect.ex new file mode 100644 index 0000000..8ef72e4 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_preflight_collect.ex @@ -0,0 +1,237 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfilePreflightCollect do + @moduledoc """ + Operator command for Phase 2 read-only provider preflight collection. + + The command builds a Coding PR Delivery Phase 2 evidence plan, runs only the + planned read-only smoke probes whose prerequisites are present, and emits a + bounded preflight report metadata packet. It does not store raw provider + output, pass write/destructive flags, mutate workflow state, approve + production, or enable gates. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReportCollector + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_preflight_collect" + @schema "coding_pr_delivery.production_profile_preflight_collect_result.v1" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @validation_failed_exit_code 1 + @switches [ + plan: :string, + plan_id: :string, + repo: :string, + pr: :string, + tapd_cnb_shadow_run_id: :string, + linear_cnb_shadow_run_id: :string, + evidence_prefix: :string, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + + @type deps :: %{ + required(:collect_preflight_report) => (String.t(), keyword() -> {:ok, map()} | {:error, map()}) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + collect_preflight_report: fn plan, opts -> + with {:ok, phase2_plan} <- ProductionProfile.phase2_evidence_plan(plan, opts) do + PreflightReportCollector.collect(phase2_plan, opts) + end + end + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + collect_preflight(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp collect_preflight(parsed_opts, deps) do + plan = parsed_opts |> Keyword.get(:plan, "tiered_reference") |> String.trim() + + case deps.collect_preflight_report.(plan, collect_opts(parsed_opts)) do + {:ok, report} -> + result = collect_result(report, true, Map.get(report, "status"), []) + {Jason.encode!(result, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", 0} + + {:error, reason} -> + result = collect_result(nil, false, "invalid", errors(reason)) + {Jason.encode!(result, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", @validation_failed_exit_code} + end + end + + defp collect_opts(parsed_opts) do + [] + |> maybe_put(:plan_id, parsed_opts[:plan_id]) + |> maybe_put(:repo_slug, parsed_opts[:repo]) + |> maybe_put(:change_proposal_number, parsed_opts[:pr]) + |> maybe_put(:tapd_cnb_shadow_run_id, parsed_opts[:tapd_cnb_shadow_run_id]) + |> maybe_put(:linear_cnb_shadow_run_id, parsed_opts[:linear_cnb_shadow_run_id]) + |> maybe_put(:evidence_prefix, parsed_opts[:evidence_prefix]) + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp collect_result(report, valid?, status, errors) do + %{ + "schema" => @schema, + "kind" => "preflight_report", + "status" => status, + "valid" => valid?, + "preflight_report_schema" => template_schema(report), + "preflight_report" => report, + "summary" => summary(report), + "errors" => errors, + "raw_output_included" => false, + "does_call_read_only_providers_when_prerequisites_present" => true, + "does_not_pass_write_or_destructive_flags" => true, + "does_not_read_evidence_files" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp template_schema(report) when is_map(report), do: Map.get(report, "schema") + defp template_schema(_report), do: nil + + defp summary(report) when is_map(report) do + %{ + "phase2_plan_kind" => value_at(report, ["phase2_evidence_plan", "plan_kind"]), + "status" => Map.get(report, "status"), + "planned_preflight_command_count" => Map.get(report, "planned_preflight_command_count"), + "preflight_result_count" => Map.get(report, "preflight_result_count"), + "raw_output_included" => Map.get(report, "raw_output_included") + } + end + + defp summary(_report), do: %{} + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(%{"errors" => errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(reason), do: [error_to_map(reason)] + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "preflight_collect_error", "message" => "Preflight collection failed."} + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(%{collect_preflight_report: collect}) when is_function(collect, 2), do: :ok + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + if is_map(current) and Map.has_key?(current, key) do + {:cont, Map.get(current, key)} + else + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp usage do + """ + Command arguments: + [--plan ] [--repo ] [--pr ] [--json|--pretty] + [--tapd-cnb-shadow-run-id ] [--linear-cnb-shadow-run-id ] + [--evidence-prefix evidence/preflight] + + Supported plans: + tiered_reference + linear_github_ready + tapd_cnb_shadow + linear_cnb_shadow + + This command runs planned read-only provider preflight smoke probes only + when prerequisites are present, then emits bounded preflight report + metadata. It does not pass write/destructive flags, store raw provider + output, mutate workflow state, approve production, or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_status.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_status.ex new file mode 100644 index 0000000..2626d59 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_status.ex @@ -0,0 +1,226 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileStatus do + @moduledoc """ + Operator command for Coding PR Delivery production-profile status projection. + + The command builds a bounded status packet from a Phase 2 plan and optional + preflight report metadata. It does not read referenced evidence files, call + providers, mutate workflow state, approve production, or enable gates. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_status" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @validation_failed_exit_code 1 + @switches [ + plan: :string, + phase2_plan_file: :string, + preflight_report_file: :string, + plan_id: :string, + tapd_cnb_shadow_run_id: :string, + linear_cnb_shadow_run_id: :string, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + + @type status_result :: {:ok, map()} | {:error, map()} + @type deps :: %{ + required(:read_file) => (String.t() -> {:ok, binary()} | {:error, term()}), + required(:production_status) => (String.t() | map(), keyword() -> status_result()) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + read_file: &File.read/1, + production_status: fn input, opts -> ProductionProfile.production_status(input, opts) end + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + render_status(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp render_status(parsed_opts, deps) do + with {:ok, input} <- status_input(parsed_opts, deps), + {:ok, opts} <- status_opts(parsed_opts, deps), + {:ok, report} <- deps.production_status.(input, opts) do + {Jason.encode!(report, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", 0} + else + {:error, {:usage, message}} -> + {"", message <> "\n" <> usage(), @usage_error_exit_code} + + {:error, reason} -> + {Jason.encode!(status_error(reason), pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", @validation_failed_exit_code} + end + end + + defp status_input(parsed_opts, deps) do + case Keyword.get(parsed_opts, :phase2_plan_file) do + nil -> {:ok, parsed_opts |> Keyword.get(:plan, "tiered_reference") |> String.trim()} + file_path -> read_json_file(file_path, "Phase 2 plan", deps) + end + end + + defp status_opts(parsed_opts, deps) do + opts = + [] + |> maybe_put(:plan_id, parsed_opts[:plan_id]) + |> maybe_put(:tapd_cnb_shadow_run_id, parsed_opts[:tapd_cnb_shadow_run_id]) + |> maybe_put(:linear_cnb_shadow_run_id, parsed_opts[:linear_cnb_shadow_run_id]) + + case Keyword.get(parsed_opts, :preflight_report_file) do + nil -> + {:ok, opts} + + file_path -> + with {:ok, report} <- read_json_file(file_path, "Preflight report", deps) do + {:ok, Keyword.put(opts, :preflight_report, report)} + end + end + end + + defp read_json_file(file_path, label, deps) do + with {:ok, contents} <- deps.read_file.(file_path), + {:ok, decoded} <- Jason.decode(contents), + true <- is_map(decoded) do + {:ok, decoded} + else + {:error, _reason} -> + {:error, {:usage, "#{label} file could not be read or parsed."}} + + false -> + {:error, {:usage, "#{label} file must contain an object."}} + end + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp status_error(reason) do + %{ + "schema" => "coding_pr_delivery.production_profile_status_result.v1", + "status" => "invalid", + "valid" => false, + "errors" => errors(reason), + "raw_input_included" => false, + "does_not_read_referenced_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(%{"errors" => errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(reason), do: [error_to_map(reason)] + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "production_profile_status_error", "message" => "Status projection failed."} + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(%{read_file: read_file, production_status: status}) when is_function(read_file, 1) and is_function(status, 2), do: :ok + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp usage do + """ + Command arguments: + [--plan |--phase2-plan-file ] [--preflight-report-file ] [--json|--pretty] + [--tapd-cnb-shadow-run-id ] [--linear-cnb-shadow-run-id ] + + Supported plans: + tiered_reference + linear_github_ready + tapd_cnb_shadow + linear_cnb_shadow + + This command emits a bounded Coding PR Delivery production-profile status + packet. It does not read referenced evidence files, call providers, mutate + workflow state, approve production, or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_template.ex new file mode 100644 index 0000000..614fbd9 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_template.ex @@ -0,0 +1,341 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileTemplate do + @moduledoc """ + Operator command for Coding PR Delivery production-profile packet templates. + + The command reads one metadata JSON packet and projects the next bounded + fill-template in the Phase 2/4 handoff. It does not read referenced evidence + files, call providers, mutate workflow state, approve production, or enable + gates. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_template" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @validation_failed_exit_code 1 + @schema "coding_pr_delivery.production_profile_template_result.v1" + @switches [ + kind: :string, + file: :string, + provider_matrix_entry_id: :string, + repository: :string, + side_effect_mode: :string, + environment: :string, + observation_days: :integer, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + @template_kinds [ + "preflight_report_template", + "evidence_packet_template", + "review_packet_template", + "enablement_request_template", + "operator_apply_record_template", + "observation_status_template" + ] + + @type template_result :: {:ok, map()} | {:error, map()} + + @type deps :: %{ + required(:read_file) => (String.t() -> {:ok, binary()} | {:error, term()}), + required(:phase2_preflight_report_template) => (map() -> template_result()), + required(:phase2_evidence_packet_template) => (map() -> template_result()), + required(:phase4_review_packet_template) => (map() -> template_result()), + required(:enablement_request_template) => (map(), keyword() -> template_result()), + required(:operator_apply_record_template) => (map() -> template_result()), + required(:observation_status_template) => (map() -> template_result()) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + read_file: &File.read/1, + phase2_preflight_report_template: &ProductionProfile.phase2_preflight_report_template/1, + phase2_evidence_packet_template: &ProductionProfile.phase2_evidence_packet_template/1, + phase4_review_packet_template: &ProductionProfile.phase4_review_packet_template/1, + enablement_request_template: fn decision, opts -> ProductionProfile.enablement_request_template(decision, opts) end, + operator_apply_record_template: &ProductionProfile.operator_apply_record_template/1, + observation_status_template: &ProductionProfile.observation_status_template/1 + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + render_template(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp render_template(parsed_opts, deps) do + with {:ok, kind} <- required_kind(parsed_opts), + {:ok, file_path} <- required_file(parsed_opts), + {:ok, payload} <- read_json_file(file_path, deps), + {:ok, template} <- dispatch_kind(kind, payload, parsed_opts, deps) do + result = template_result(kind, true, "ready", template, []) + {Jason.encode!(result, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", 0} + else + {:error, {:usage, message}} -> + {"", message <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:validation, kind, reason}} -> + result = template_result(kind, false, "invalid", nil, errors(reason)) + {Jason.encode!(result, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", @validation_failed_exit_code} + end + end + + defp required_kind(parsed_opts) do + kind = parsed_opts |> Keyword.get(:kind, "") |> String.trim() + + cond do + kind == "" -> + {:error, {:usage, "--kind is required"}} + + kind in @template_kinds -> + {:ok, kind} + + true -> + {:error, {:usage, "Unsupported template kind. Expected one of: #{Enum.join(@template_kinds, ", ")}"}} + end + end + + defp required_file(parsed_opts) do + file = parsed_opts |> Keyword.get(:file, "") |> String.trim() + + if file == "" do + {:error, {:usage, "--file is required"}} + else + {:ok, file} + end + end + + defp read_json_file(file_path, deps) do + with {:ok, contents} <- deps.read_file.(file_path), + {:ok, decoded} <- Jason.decode(contents), + true <- is_map(decoded) do + {:ok, decoded} + else + {:error, _reason} -> + {:error, {:usage, "Unable to read or parse metadata JSON file."}} + + false -> + {:error, {:usage, "Metadata JSON file must contain an object."}} + end + end + + defp dispatch_kind("preflight_report_template", payload, _opts, deps) do + payload + |> deps.phase2_preflight_report_template.() + |> dispatch_result("preflight_report_template") + end + + defp dispatch_kind("evidence_packet_template", payload, _opts, deps) do + payload + |> deps.phase2_evidence_packet_template.() + |> dispatch_result("evidence_packet_template") + end + + defp dispatch_kind("review_packet_template", payload, _opts, deps) do + payload + |> deps.phase4_review_packet_template.() + |> dispatch_result("review_packet_template") + end + + defp dispatch_kind("enablement_request_template", payload, opts, deps) do + payload + |> deps.enablement_request_template.(enablement_opts(opts)) + |> dispatch_result("enablement_request_template") + end + + defp dispatch_kind("operator_apply_record_template", payload, _opts, deps) do + payload + |> deps.operator_apply_record_template.() + |> dispatch_result("operator_apply_record_template") + end + + defp dispatch_kind("observation_status_template", payload, _opts, deps) do + payload + |> deps.observation_status_template.() + |> dispatch_result("observation_status_template") + end + + defp dispatch_result({:ok, template}, _kind), do: {:ok, template} + defp dispatch_result({:error, reason}, kind), do: {:error, {:validation, kind, reason}} + + defp enablement_opts(parsed_opts) do + [] + |> maybe_put(:provider_matrix_entry_ids, Keyword.get_values(parsed_opts, :provider_matrix_entry_id)) + |> maybe_put(:repositories, Keyword.get_values(parsed_opts, :repository)) + |> maybe_put(:side_effect_mode, parsed_opts[:side_effect_mode]) + |> maybe_put(:environment, parsed_opts[:environment]) + |> maybe_put(:observation_days, parsed_opts[:observation_days]) + end + + defp maybe_put(opts, _key, []), do: opts + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp template_result(kind, valid?, status, template, errors) do + %{ + "schema" => @schema, + "kind" => kind, + "status" => status, + "valid" => valid?, + "template_schema" => template_schema(template), + "completed_packet_schema" => completed_packet_schema(template), + "template" => template, + "errors" => errors, + "does_not_collect_live_evidence" => true, + "does_not_read_referenced_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp template_schema(template) when is_map(template), do: Map.get(template, "schema") + defp template_schema(_template), do: nil + + defp completed_packet_schema(template) when is_map(template), do: Map.get(template, "completed_packet_schema") + defp completed_packet_schema(_template), do: nil + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(%{"errors" => errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(reason), do: [error_to_map(reason)] + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "template_error", "message" => "Template generation failed."} + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(deps) when is_map(deps) do + required_deps() + |> Enum.find(fn {key, arity} -> not is_function(Map.get(deps, key), arity) end) + |> case do + nil -> :ok + {key, _arity} -> {:error, {:dependency_invalid, key, Diagnostics.type_name(Map.get(deps, key))}} + end + end + + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp required_deps do + [ + read_file: 1, + phase2_preflight_report_template: 1, + phase2_evidence_packet_template: 1, + phase4_review_packet_template: 1, + enablement_request_template: 2, + operator_apply_record_template: 1, + observation_status_template: 1 + ] + end + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:dependency_invalid, key, type}), + do: "Coding PR Delivery operator command options are invalid: reason=dependency_invalid dependency=#{key} value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp usage do + """ + Command arguments: + --kind --file [--json|--pretty] + + Template kinds: + preflight_report_template Input is a Phase 2 evidence plan. + evidence_packet_template Input is a production claim. + review_packet_template Input is a completed evidence packet. + enablement_request_template Input is a ready review decision. + operator_apply_record_template Input is a ready operator apply plan. + observation_status_template Input is an accepted operator apply record. + + Enablement template options: + --provider-matrix-entry-id May be repeated. + --repository May be repeated. + --side-effect-mode + --environment + --observation-days + + This command builds packet templates only. It does not collect live + evidence, read evidence files, call providers, mutate workflow state, + approve production, or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_validate.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_validate.ex new file mode 100644 index 0000000..385b9af --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_validate.ex @@ -0,0 +1,457 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileValidate do + @moduledoc """ + Operator command for Coding PR Delivery production-profile packet validation. + + The command reads one metadata JSON packet, validates or projects it through + the production-profile facade, and emits a bounded JSON result. It does not + read referenced evidence files, call providers, mutate workflow state, approve + production, or enable gates. + """ + + @behaviour SymphonyElixir.Workflow.Extension.OperatorCommand + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extension.OperatorCommand + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + @command_id "symphony.workflow.extension.coding_pr_delivery.production_profile_validate" + @usage_error_exit_code 64 + @internal_error_exit_code 70 + @validation_failed_exit_code 1 + @schema "coding_pr_delivery.production_profile_validation_result.v1" + @switches [ + kind: :string, + file: :string, + json: :boolean, + pretty: :boolean, + help: :boolean + ] + @validation_kinds [ + "provider_matrix", + "provider_matrix_entry", + "typed_tool_exception", + "claim", + "preflight_report", + "evidence_packet", + "review_packet", + "enablement_request", + "operator_apply_record", + "observation_status" + ] + @projection_kinds ["review_decision", "observation_decision"] + @kinds @validation_kinds ++ @projection_kinds + + @type validation_result :: {:ok, map()} | {:error, map()} + + @type deps :: %{ + required(:read_file) => (String.t() -> {:ok, binary()} | {:error, term()}), + required(:validate_provider_matrix) => (map() -> validation_result()), + required(:validate_provider_matrix_entry) => (map() -> validation_result()), + required(:validate_typed_tool_exception) => (map() -> validation_result()), + required(:validate_claim) => (map() -> validation_result()), + required(:validate_preflight_report) => (map() -> validation_result()), + required(:validate_evidence_packet) => (map() -> validation_result()), + required(:validate_review_packet) => (map() -> validation_result()), + required(:review_decision) => (map() -> {:ok, map()}), + required(:validate_enablement_request) => (map() -> validation_result()), + required(:validate_operator_apply_record) => (map() -> validation_result()), + required(:validate_observation_status) => (map() -> validation_result()), + required(:observation_decision) => (map() -> {:ok, map()}) + } + + @impl true + def id, do: @command_id + + @impl true + @spec evaluate([String.t()], keyword()) :: OperatorCommand.result() + def evaluate(argv, command_opts \\ []) do + with :ok <- validate_argv(argv), + {:ok, deps} <- command_deps(command_opts) do + evaluate_argv(argv, deps) + else + {:error, {:usage_error, reason}} -> + {"", format_usage_error(reason) <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:internal_error, reason}} -> + {"", format_internal_error(reason), @internal_error_exit_code} + end + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + read_file: &File.read/1, + validate_provider_matrix: &ProductionProfile.validate_provider_matrix/1, + validate_provider_matrix_entry: &ProductionProfile.validate_provider_matrix_entry/1, + validate_typed_tool_exception: &ProductionProfile.validate_typed_tool_exception/1, + validate_claim: &ProductionProfile.validate_claim/1, + validate_preflight_report: &ProductionProfile.validate_preflight_report/1, + validate_evidence_packet: &ProductionProfile.validate_evidence_packet/1, + validate_review_packet: &ProductionProfile.validate_review_packet/1, + review_decision: &ProductionProfile.review_decision/1, + validate_enablement_request: &ProductionProfile.validate_enablement_request/1, + validate_operator_apply_record: &ProductionProfile.validate_operator_apply_record/1, + validate_observation_status: &ProductionProfile.validate_observation_status/1, + observation_decision: &ProductionProfile.observation_decision/1 + } + end + + defp evaluate_argv(argv, deps) do + case OptionParser.parse(argv, strict: @switches, aliases: [h: :help]) do + {parsed_opts, [], []} -> + if parsed_opts[:help] do + {usage(), "", 0} + else + validate_packet(parsed_opts, deps) + end + + {_opts, unexpected, []} -> + {"", "Unexpected argument count: #{length(unexpected)}\n" <> usage(), @usage_error_exit_code} + + {_opts, _argv, invalid} -> + {"", "Invalid option count: #{length(invalid)}\n" <> usage(), @usage_error_exit_code} + end + end + + defp validate_packet(parsed_opts, deps) do + with {:ok, kind} <- required_kind(parsed_opts), + {:ok, file_path} <- required_file(parsed_opts), + {:ok, payload} <- read_json_file(file_path, deps), + {:ok, result, exit_code} <- dispatch_kind(kind, payload, deps) do + {Jason.encode!(result, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", exit_code} + else + {:error, {:usage, message}} -> + {"", message <> "\n" <> usage(), @usage_error_exit_code} + + {:error, {:validation, result}} -> + {Jason.encode!(result, pretty: Keyword.get(parsed_opts, :pretty, false)) <> "\n", "", @validation_failed_exit_code} + end + end + + defp required_kind(parsed_opts) do + kind = parsed_opts |> Keyword.get(:kind, "") |> String.trim() + + cond do + kind == "" -> + {:error, {:usage, "--kind is required"}} + + kind in @kinds -> + {:ok, kind} + + true -> + {:error, {:usage, "Unsupported packet kind. Expected one of: #{Enum.join(@kinds, ", ")}"}} + end + end + + defp required_file(parsed_opts) do + file = parsed_opts |> Keyword.get(:file, "") |> String.trim() + + if file == "" do + {:error, {:usage, "--file is required"}} + else + {:ok, file} + end + end + + defp read_json_file(file_path, deps) do + with {:ok, contents} <- deps.read_file.(file_path), + {:ok, decoded} <- Jason.decode(contents), + true <- is_map(decoded) do + {:ok, decoded} + else + {:error, _reason} -> + {:error, {:usage, "Unable to read or parse metadata JSON file."}} + + false -> + {:error, {:usage, "Metadata JSON file must contain an object."}} + end + end + + defp dispatch_kind(kind, payload, deps) when kind in @validation_kinds do + kind + |> validate_kind(payload, deps) + |> case do + {:ok, normalized} -> + {:ok, validation_result(kind, true, "valid", summary(kind, normalized), []), 0} + + {:error, reason} -> + {:error, {:validation, validation_result(kind, false, "invalid", %{}, errors(reason))}} + end + end + + defp dispatch_kind("review_decision" = kind, payload, deps) do + {:ok, decision} = deps.review_decision.(payload) + {:ok, projection_result(kind, decision, Map.get(decision, "status")), 0} + end + + defp dispatch_kind("observation_decision" = kind, payload, deps) do + {:ok, decision} = deps.observation_decision.(payload) + {:ok, projection_result(kind, decision, Map.get(decision, "status")), 0} + end + + defp validate_kind("provider_matrix", payload, deps), do: deps.validate_provider_matrix.(payload) + defp validate_kind("provider_matrix_entry", payload, deps), do: deps.validate_provider_matrix_entry.(payload) + defp validate_kind("typed_tool_exception", payload, deps), do: deps.validate_typed_tool_exception.(payload) + defp validate_kind("claim", payload, deps), do: deps.validate_claim.(payload) + defp validate_kind("preflight_report", payload, deps), do: deps.validate_preflight_report.(payload) + defp validate_kind("evidence_packet", payload, deps), do: deps.validate_evidence_packet.(payload) + defp validate_kind("review_packet", payload, deps), do: deps.validate_review_packet.(payload) + defp validate_kind("enablement_request", payload, deps), do: deps.validate_enablement_request.(payload) + defp validate_kind("operator_apply_record", payload, deps), do: deps.validate_operator_apply_record.(payload) + defp validate_kind("observation_status", payload, deps), do: deps.validate_observation_status.(payload) + + defp validation_result(kind, valid?, status, summary, errors) do + %{ + "schema" => @schema, + "kind" => kind, + "status" => status, + "valid" => valid?, + "summary" => summary, + "errors" => errors, + "normalized_packet_included" => false, + "raw_input_included" => false, + "does_not_read_referenced_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp projection_result(kind, projection, status) do + %{ + "schema" => @schema, + "kind" => kind, + "status" => status, + "valid" => status in ["ready_for_approval", "passed"], + "projection" => projection, + "errors" => [], + "normalized_packet_included" => false, + "raw_input_included" => false, + "does_not_read_referenced_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + end + + defp summary("claim", packet) do + provider_matrix = Map.get(packet, "provider_matrix", []) + + %{ + "profile_instance_id" => Map.get(packet, "profile_instance_id"), + "provider_matrix_entry_count" => length(provider_matrix), + "provider_matrix_entry_ids" => ids(provider_matrix), + "side_effect_modes" => unique_values(provider_matrix, "side_effect_mode") + } + end + + defp summary("evidence_packet", packet) do + %{ + "profile_instance_id" => Map.get(packet, "profile_instance_id"), + "scenario_evidence_count" => count(packet, "scenario_evidence"), + "non_claim_acknowledgement_count" => count(packet, "non_claim_acknowledgements"), + "provider_matrix_entry_ids" => ids(get_in(packet, ["runbook", "entries"]) || []) + } + end + + defp summary("preflight_report", packet) do + %{ + "status" => Map.get(packet, "status"), + "phase2_plan_kind" => value_at(packet, ["phase2_evidence_plan", "plan_kind"]), + "planned_preflight_command_count" => Map.get(packet, "planned_preflight_command_count"), + "preflight_result_count" => Map.get(packet, "preflight_result_count"), + "raw_output_included" => Map.get(packet, "raw_output_included") + } + end + + defp summary("review_packet", packet) do + %{ + "review_packet_id" => Map.get(packet, "review_packet_id"), + "profile_instance_id" => Map.get(packet, "profile_instance_id"), + "owner_signoff_count" => count(packet, "owner_signoffs"), + "deterministic_test_count" => count(packet, "deterministic_test_matrix") + } + end + + defp summary("enablement_request", packet) do + %{ + "enablement_request_id" => Map.get(packet, "enablement_request_id"), + "environment" => value_at(packet, ["scope", "environment"]), + "side_effect_mode" => value_at(packet, ["scope", "side_effect_mode"]), + "provider_matrix_entry_ids" => value_at(packet, ["scope", "provider_matrix_entry_ids"]) || [] + } + end + + defp summary("operator_apply_record", packet) do + %{ + "apply_record_id" => Map.get(packet, "apply_record_id"), + "applied_by" => value_at(packet, ["apply_metadata", "applied_by"]), + "operator_confirmation" => value_at(packet, ["apply_metadata", "operator_confirmation"]) + } + end + + defp summary("observation_status", packet) do + %{ + "observation_status_id" => Map.get(packet, "observation_status_id"), + "status" => Map.get(packet, "status"), + "criteria_result_count" => count(packet, "criteria_results") + } + end + + defp summary(_kind, packet) do + %{ + "profile_instance_id" => Map.get(packet, "profile_instance_id"), + "provider_matrix_entry_ids" => ids(Map.get(packet, "provider_matrix", [])) + } + end + + defp ids(entries) when is_list(entries) do + entries + |> Enum.map(&Map.get(&1, "id", Map.get(&1, "entry_id"))) + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end + + defp ids(_entries), do: [] + + defp unique_values(entries, field) when is_list(entries) do + entries + |> Enum.map(&Map.get(&1, field)) + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end + + defp count(packet, field) do + case Map.get(packet, field) do + values when is_list(values) -> length(values) + _missing -> 0 + end + end + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(%{"errors" => errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + defp errors(reason), do: [error_to_map(reason)] + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "validation_error", "message" => "Packet validation failed."} + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + if is_map(current) and Map.has_key?(current, key) do + {:cont, Map.get(current, key)} + else + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp validate_argv(argv) when is_list(argv) do + if Enum.all?(argv, &is_binary/1) do + :ok + else + {:error, {:usage_error, {:argv_contains_non_string, first_invalid_type(argv)}}} + end + end + + defp validate_argv(argv), do: {:error, {:usage_error, {:argv_not_list, Diagnostics.type_name(argv)}}} + + defp command_deps(command_opts) when is_list(command_opts) do + with true <- Keyword.keyword?(command_opts), + deps <- Keyword.get(command_opts, :deps, runtime_deps()), + :ok <- validate_deps(deps) do + {:ok, deps} + else + false -> + {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + {:error, reason} -> + {:error, {:internal_error, reason}} + end + end + + defp command_deps(command_opts), + do: {:error, {:internal_error, {:command_opts_not_keyword, Diagnostics.type_name(command_opts)}}} + + defp validate_deps(deps) when is_map(deps) do + required_deps() + |> Enum.find(fn {key, arity} -> not is_function(Map.get(deps, key), arity) end) + |> case do + nil -> :ok + {key, _arity} -> {:error, {:dependency_invalid, key, Diagnostics.type_name(Map.get(deps, key))}} + end + end + + defp validate_deps(deps), do: {:error, {:deps_invalid, Diagnostics.type_name(deps)}} + + defp required_deps do + [ + read_file: 1, + validate_provider_matrix: 1, + validate_provider_matrix_entry: 1, + validate_typed_tool_exception: 1, + validate_claim: 1, + validate_preflight_report: 1, + validate_evidence_packet: 1, + validate_review_packet: 1, + review_decision: 1, + validate_enablement_request: 1, + validate_operator_apply_record: 1, + validate_observation_status: 1, + observation_decision: 1 + ] + end + + defp first_invalid_type(argv) do + argv + |> Enum.find(&(not is_binary(&1))) + |> Diagnostics.type_name() + end + + defp format_usage_error({:argv_not_list, type}), do: "Command argv must be a list of strings: value_type=#{type}" + defp format_usage_error({:argv_contains_non_string, type}), do: "Command argv must contain only strings: value_type=#{type}" + + defp format_internal_error({:command_opts_not_keyword, type}), + do: "Coding PR Delivery operator command options are invalid: reason=command_opts_not_keyword value_type=#{type}\n" + + defp format_internal_error({:dependency_invalid, key, type}), + do: "Coding PR Delivery operator command options are invalid: reason=dependency_invalid dependency=#{key} value_type=#{type}\n" + + defp format_internal_error({:deps_invalid, type}), + do: "Coding PR Delivery operator command options are invalid: reason=deps_invalid value_type=#{type}\n" + + defp usage do + """ + Command arguments: + --kind --file [--json|--pretty] + + Validation kinds: + provider_matrix + provider_matrix_entry + typed_tool_exception + claim + preflight_report + evidence_packet + review_packet + enablement_request + operator_apply_record + observation_status + + Projection kinds: + review_decision Input is a review packet. + observation_decision Input is an observation status packet. + + This command validates metadata packets only. It does not read referenced + evidence files, call providers, mutate workflow state, approve production, + or enable gates. + """ + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile.ex new file mode 100644 index 0000000..1d7e445 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile.ex @@ -0,0 +1,130 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile do + @moduledoc """ + Facade for Coding PR Delivery production-profile admission checks. + + The functions here are pure review-packet helpers. They validate production + evidence metadata and build diagnostic runbooks; they do not call providers, + mutate workflow state, or enable production gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + Claim, + EnablementRequest, + EnablementRequestTemplate, + EvidenceBundle, + EvidenceHandoff, + EvidencePacket, + EvidencePacketTemplate, + EvidenceRequest, + EvidenceRunbook, + ObservationDecision, + ObservationStatus, + ObservationStatusTemplate, + OperatorApplyPlan, + OperatorApplyRecord, + OperatorApplyRecordTemplate, + Phase2ClaimTemplate, + Phase2EvidencePlan, + Phase4ReviewPlan, + PreflightReport, + PreflightReportTemplate, + ProviderMatrix, + ReviewDecision, + ReviewPacket, + ReviewPacketTemplate, + StatusReport, + TypedToolException + } + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec side_effect_modes() :: [String.t()] + defdelegate side_effect_modes, to: ProviderMatrix + + @spec validate_provider_matrix(map()) :: validation_result() + defdelegate validate_provider_matrix(claim), to: ProviderMatrix, as: :validate_claim + + @spec validate_provider_matrix_entry(map()) :: validation_result() + defdelegate validate_provider_matrix_entry(entry), to: ProviderMatrix, as: :validate_entry + + @spec validate_typed_tool_exception(map()) :: validation_result() + defdelegate validate_typed_tool_exception(record), to: TypedToolException, as: :validate_record + + @spec validate_claim(map()) :: validation_result() + defdelegate validate_claim(claim), to: Claim, as: :validate + + @spec phase2_claim_templates() :: [String.t()] + defdelegate phase2_claim_templates, to: Phase2ClaimTemplate, as: :templates + + @spec phase2_claim_template(Phase2ClaimTemplate.template() | String.t(), keyword()) :: validation_result() + defdelegate phase2_claim_template(template, opts \\ []), to: Phase2ClaimTemplate, as: :build + + @spec phase2_evidence_plans() :: [String.t()] + defdelegate phase2_evidence_plans, to: Phase2EvidencePlan, as: :plans + + @spec phase2_evidence_plan(Phase2EvidencePlan.plan() | String.t(), keyword()) :: validation_result() + defdelegate phase2_evidence_plan(plan, opts \\ []), to: Phase2EvidencePlan, as: :build + + @spec production_evidence_request(EvidenceRequest.input(), keyword()) :: validation_result() + defdelegate production_evidence_request(input, opts \\ []), to: EvidenceRequest, as: :build + + @spec production_evidence_bundle(EvidenceBundle.input(), keyword()) :: validation_result() + defdelegate production_evidence_bundle(input, opts \\ []), to: EvidenceBundle, as: :build + + @spec production_evidence_handoff(EvidenceHandoff.input(), keyword()) :: validation_result() + defdelegate production_evidence_handoff(input, opts \\ []), to: EvidenceHandoff, as: :build + + @spec phase4_review_plan(Phase4ReviewPlan.input(), keyword()) :: validation_result() + defdelegate phase4_review_plan(input, opts \\ []), to: Phase4ReviewPlan, as: :build + + @spec production_status(StatusReport.input(), keyword()) :: validation_result() + defdelegate production_status(input, opts \\ []), to: StatusReport, as: :build + + @spec validate_preflight_report(map()) :: validation_result() + defdelegate validate_preflight_report(report), to: PreflightReport, as: :validate + + @spec phase2_preflight_report_template(map()) :: validation_result() + defdelegate phase2_preflight_report_template(phase2_plan), to: PreflightReportTemplate, as: :build + + @spec build_evidence_runbook(map()) :: validation_result() + defdelegate build_evidence_runbook(claim), to: EvidenceRunbook, as: :build + + @spec validate_evidence_packet(map()) :: validation_result() + defdelegate validate_evidence_packet(packet), to: EvidencePacket, as: :validate + + @spec phase2_evidence_packet_template(map()) :: validation_result() + defdelegate phase2_evidence_packet_template(claim), to: EvidencePacketTemplate, as: :build + + @spec validate_review_packet(map()) :: validation_result() + defdelegate validate_review_packet(packet), to: ReviewPacket, as: :validate + + @spec phase4_review_packet_template(map()) :: validation_result() + defdelegate phase4_review_packet_template(evidence_packet), to: ReviewPacketTemplate, as: :build + + @spec review_decision(map()) :: {:ok, map()} + defdelegate review_decision(packet), to: ReviewDecision, as: :build + + @spec validate_enablement_request(map()) :: validation_result() + defdelegate validate_enablement_request(request), to: EnablementRequest, as: :validate + + @spec enablement_request_template(map(), keyword()) :: validation_result() + defdelegate enablement_request_template(review_decision, opts \\ []), to: EnablementRequestTemplate, as: :build + + @spec operator_apply_plan(map()) :: {:ok, map()} + defdelegate operator_apply_plan(request), to: OperatorApplyPlan, as: :build + + @spec validate_operator_apply_record(map()) :: validation_result() + defdelegate validate_operator_apply_record(record), to: OperatorApplyRecord, as: :validate + + @spec operator_apply_record_template(map()) :: validation_result() + defdelegate operator_apply_record_template(plan), to: OperatorApplyRecordTemplate, as: :build + + @spec validate_observation_status(map()) :: validation_result() + defdelegate validate_observation_status(status), to: ObservationStatus, as: :validate + + @spec observation_status_template(map()) :: validation_result() + defdelegate observation_status_template(apply_record), to: ObservationStatusTemplate, as: :build + + @spec observation_decision(map()) :: {:ok, map()} + defdelegate observation_decision(status), to: ObservationDecision, as: :build +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/claim.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/claim.ex new file mode 100644 index 0000000..4d77169 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/claim.ex @@ -0,0 +1,227 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Claim do + @moduledoc """ + Admission checks for complete Coding PR Delivery production claims. + + This validator composes provider-matrix, structured-plan governance, and + optional typed-tool exception evidence into one review-packet boundary. It is + pure and does not enable providers, gates, or workflow side effects. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ProviderMatrix + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.TypedToolException + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + @error_code "coding_pr_delivery_production_claim_invalid" + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate(term()) :: validation_result() + def validate(claim) when is_map(claim) do + profile_instance_id = value_at(claim, ["profile_instance_id"]) + provider_matrix_result = ProviderMatrix.validate_claim(claim) + provider_entries = normalized_provider_entries(provider_matrix_result) + + errors = + [] + |> collect_required_string(claim, ["profile_instance_id"]) + |> collect_nested_errors(provider_matrix_result, []) + |> collect_governance_packets(claim, profile_instance_id, provider_entries) + |> collect_typed_tool_exceptions(claim, provider_entries) + + if errors == [] do + {:ok, normalize_claim(claim, provider_matrix_result)} + else + {:error, invalid(errors)} + end + end + + def validate(_claim) do + {:error, invalid([issue("invalid_type", [], "Production claim must be an object.")])} + end + + defp collect_governance_packets(errors, claim, profile_instance_id, provider_entries) do + packets = value_at(claim, ["production_governance"]) + + cond do + not is_list(packets) or packets == [] -> + errors ++ [issue("required_field_missing", ["production_governance"], "Production governance packets must be a non-empty array.")] + + true -> + governance_errors = + packets + |> Enum.with_index() + |> Enum.flat_map(fn {packet, index} -> governance_packet_errors(packet, index, profile_instance_id, provider_entries) end) + + errors ++ governance_errors ++ governance_coverage_errors(packets, provider_entries) + end + end + + defp governance_packet_errors(packet, index, profile_instance_id, provider_entries) when is_map(packet) do + path = ["production_governance", index] + entry_id = value_at(packet, ["provider_matrix_entry_id"]) + normalized_entry_ids = Enum.map(provider_entries, &Map.get(&1, "id")) + + [] + |> maybe_add( + not non_empty_string?(entry_id), + issue("required_field_missing", path ++ ["provider_matrix_entry_id"], "Governance packet must name its provider-matrix entry.") + ) + |> maybe_add( + non_empty_string?(entry_id) and entry_id not in normalized_entry_ids, + issue("unknown_provider_matrix_entry", path ++ ["provider_matrix_entry_id"], "Governance packet references a provider-matrix entry outside this claim.") + ) + |> maybe_add( + non_empty_string?(profile_instance_id) and value_at(packet, ["profile_instance_id"]) != profile_instance_id, + issue("profile_instance_mismatch", path ++ ["profile_instance_id"], "Governance packet profile instance must match the production claim.") + ) + |> collect_nested_errors(Governance.validate_packet(packet), path) + end + + defp governance_packet_errors(_packet, index, _profile_instance_id, _provider_entries) do + [issue("invalid_type", ["production_governance", index], "Production governance packet must be an object.")] + end + + defp governance_coverage_errors(packets, provider_entries) do + entry_ids = provider_entries |> Enum.map(&Map.get(&1, "id")) |> Enum.reject(&is_nil/1) + governance_ids = packets |> Enum.filter(&is_map/1) |> Enum.map(&value_at(&1, ["provider_matrix_entry_id"])) |> Enum.reject(&is_nil/1) + governance_id_counts = Enum.frequencies(governance_ids) + + missing = + entry_ids + |> Enum.reject(&(&1 in governance_ids)) + |> Enum.map(fn _entry_id -> + issue("missing_governance_packet", ["production_governance"], "Each provider-matrix entry must have one production governance packet.") + end) + + duplicate = + governance_id_counts + |> Enum.filter(fn {_id, count} -> count > 1 end) + |> Enum.map(fn {_id, _count} -> + issue("duplicate_governance_packet", ["production_governance"], "A provider-matrix entry must not have duplicate governance packets.") + end) + + missing ++ duplicate + end + + defp collect_typed_tool_exceptions(errors, claim, provider_entries) do + exceptions = value_at(claim, ["typed_tool_exceptions"]) + + cond do + is_nil(exceptions) -> + errors + + not is_list(exceptions) -> + errors ++ [issue("invalid_type", ["typed_tool_exceptions"], "Typed-tool exceptions must be an array when present.")] + + true -> + exception_errors = + exceptions + |> Enum.with_index() + |> Enum.flat_map(fn {exception, index} -> typed_tool_exception_errors(exception, index, provider_entries) end) + + errors ++ exception_errors + end + end + + defp typed_tool_exception_errors(exception, index, provider_entries) when is_map(exception) do + path = ["typed_tool_exceptions", index] + result = TypedToolException.validate_record(exception) + + [] + |> collect_nested_errors(result, path) + |> maybe_add( + match?({:ok, _}, result) and not Enum.any?(provider_entries, &exception_matches_entry?(exception, &1)), + issue("exception_provider_scope_unmatched", path, "Typed-tool exception must match a provider-matrix entry in this claim.") + ) + end + + defp typed_tool_exception_errors(_exception, index, _provider_entries) do + [issue("invalid_type", ["typed_tool_exceptions", index], "Typed-tool exception must be an object.")] + end + + defp exception_matches_entry?(exception, entry) when is_map(exception) and is_map(entry) do + value_at(exception, ["workflow_profile"]) == Map.get(entry, "workflow_profile") and + value_at(exception, ["tracker", "kind"]) == value_at(entry, ["tracker", "kind"]) and + value_at(exception, ["repo_provider", "kind"]) == value_at(entry, ["repo_provider", "kind"]) and + value_at(exception, ["agent_provider", "kind"]) == value_at(entry, ["agent_provider", "kind"]) and + value_at(exception, ["repository_class"]) == Map.get(entry, "repository_class") + end + + defp exception_matches_entry?(_exception, _entry), do: false + + defp collect_nested_errors(errors, {:ok, _value}, _path), do: errors + + defp collect_nested_errors(errors, {:error, %{errors: nested_errors}}, path) when is_list(nested_errors) do + errors ++ Enum.map(nested_errors, &prefix_error(&1, path)) + end + + defp collect_nested_errors(errors, {:error, reason}, path) do + errors ++ [issue(error_code(reason), path, error_message(reason))] + end + + defp normalized_provider_entries({:ok, %{"provider_matrix" => entries}}) when is_list(entries), do: entries + defp normalized_provider_entries(_result), do: [] + + defp normalize_claim(claim, {:ok, provider_matrix}) do + %{ + "profile_instance_id" => value_at(claim, ["profile_instance_id"]), + "provider_matrix" => Map.get(provider_matrix, "provider_matrix"), + "production_governance" => value_at(claim, ["production_governance"]), + "typed_tool_exceptions" => value_at(claim, ["typed_tool_exceptions"]) || [] + } + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, path)), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery production claim is invalid.", + errors: errors + } + end + + defp prefix_error(error, path) do + %{ + code: error_code(error), + path: path ++ error_path(error), + message: error_message(error) + } + end + + defp error_code(error), do: Map.get(error, :code) || Map.get(error, "code") || "invalid" + defp error_path(error), do: Map.get(error, :path) || Map.get(error, "path") || [] + defp error_message(error), do: Map.get(error, :message) || Map.get(error, "message") || "Invalid production claim." + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message) do + %{ + code: code, + path: path, + message: message + } + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request.ex new file mode 100644 index 0000000..90444cf --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request.ex @@ -0,0 +1,385 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequest do + @moduledoc """ + Admission checks for production enablement requests. + + This validator accepts a bounded review-decision packet plus an explicit + rollout scope, gate plan, observation window, rollback plan, and approval + metadata. It validates that an enablement request is reviewable and scoped; it + does not apply settings, call providers, mutate workflow state, or enable + production gates. + """ + + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @schema "coding_pr_delivery.production_enablement_request.v1" + @error_code "coding_pr_delivery_enablement_request_invalid" + @review_decision_schema "coding_pr_delivery.production_review_decision.v1" + @side_effect_modes ["read_only", "shadow_no_write", "review_handoff_write", "ready_to_land_write"] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate(map()) :: validation_result() + def validate(request) when is_map(request) do + review_decision = value_at(request, ["review_decision"]) + scope = value_at(request, ["scope"]) + gate_values = value_at(request, ["gate_values"]) + requested_mode = value_at(scope, ["side_effect_mode"]) + selected_entry_ids = value_at(scope, ["provider_matrix_entry_ids"]) + + errors = + [] + |> collect_required_string(request, ["enablement_request_id"]) + |> collect_required_string(request, ["requested_by"]) + |> collect_requested_at(request) + |> collect_review_decision(review_decision) + |> collect_scope(scope, review_decision) + |> collect_gate_values(gate_values, requested_mode) + |> collect_observation_window(request) + |> collect_rollback(request) + |> collect_non_claim_acknowledgement(request, review_decision, selected_entry_ids) + |> collect_approvals(request) + |> collect_activation_control(request) + + if errors == [] do + {:ok, normalize(request)} + else + {:error, invalid(errors)} + end + end + + def validate(_request) do + {:error, invalid([issue("invalid_type", [], "Enablement request must be an object.")])} + end + + defp collect_requested_at(errors, request) do + requested_at = value_at(request, ["requested_at"]) + + errors + |> collect_required_string(request, ["requested_at"]) + |> maybe_add( + non_empty_string?(requested_at) and not valid_datetime?(requested_at), + issue("invalid_timestamp", ["requested_at"], "Requested-at timestamp must be ISO8601.") + ) + end + + defp collect_review_decision(errors, decision) when is_map(decision) do + blockers = value_at(decision, ["blockers"]) + + errors + |> maybe_add( + value_at(decision, ["schema"]) != @review_decision_schema, + issue("invalid_review_decision_schema", ["review_decision", "schema"], "Review decision schema is invalid.") + ) + |> maybe_add( + value_at(decision, ["status"]) != "ready_for_approval", + issue("review_decision_not_ready", ["review_decision", "status"], "Enablement requires a ready review decision.") + ) + |> maybe_add( + blockers != [], + issue("review_decision_blocked", ["review_decision", "blockers"], "Enablement requires a review decision with no blockers.") + ) + |> maybe_add( + value_at(decision, ["does_not_enable_production"]) != true, + issue("review_decision_side_effect_boundary", ["review_decision", "does_not_enable_production"], "Review decision must be non-mutating.") + ) + |> maybe_add( + value_at(decision, ["raw_evidence_payload_included"]) != false, + issue("raw_evidence_payload_present", ["review_decision", "raw_evidence_payload_included"], "Review decision must not include raw evidence payloads.") + ) + |> maybe_add( + not non_empty_string?(value_at(decision, ["profile_instance_id"])), + issue("required_field_missing", ["review_decision", "profile_instance_id"], "Review decision must name the profile instance.") + ) + |> maybe_add( + not is_list(value_at(decision, ["provider_entries"])) or value_at(decision, ["provider_entries"]) == [], + issue("required_field_missing", ["review_decision", "provider_entries"], "Review decision must include provider entries.") + ) + end + + defp collect_review_decision(errors, _decision) do + errors ++ [issue("required_field_missing", ["review_decision"], "Review decision must be an object.")] + end + + defp collect_scope(errors, scope, review_decision) when is_map(scope) do + requested_ids = value_at(scope, ["provider_matrix_entry_ids"]) + requested_mode = value_at(scope, ["side_effect_mode"]) + decision_entries = decision_provider_entries(review_decision) + decision_entry_ids = Enum.map(decision_entries, &Map.get(&1, "entry_id")) + selected_entries = Enum.filter(decision_entries, &(Map.get(&1, "entry_id") in List.wrap(requested_ids))) + + errors + |> collect_required_string(scope, ["scope", "environment"]) + |> maybe_add( + value_at(scope, ["environment"]) not in ["staging", "production"], + issue("invalid_environment", ["scope", "environment"], "Environment must be staging or production.") + ) + |> collect_string_list(scope, ["scope", "repositories"], "Repository scope must be a non-empty string array.") + |> collect_string_list(scope, ["scope", "provider_matrix_entry_ids"], "Provider entry scope must be a non-empty string array.") + |> collect_required_string(scope, ["scope", "side_effect_mode"]) + |> maybe_add( + non_empty_string?(requested_mode) and requested_mode not in @side_effect_modes, + issue("invalid_side_effect_mode", ["scope", "side_effect_mode"], "Requested side-effect mode is unsupported.") + ) + |> collect_unknown_provider_entries(requested_ids, decision_entry_ids) + |> collect_mode_escalation(requested_mode, selected_entries) + end + + defp collect_scope(errors, _scope, _review_decision) do + errors ++ [issue("required_field_missing", ["scope"], "Enablement scope must be an object.")] + end + + defp collect_unknown_provider_entries(errors, requested_ids, decision_entry_ids) when is_list(requested_ids) do + requested_ids + |> Enum.reject(&(&1 in decision_entry_ids)) + |> Enum.map(fn entry_id -> + issue("unknown_provider_matrix_entry", ["scope", "provider_matrix_entry_ids"], "Requested provider entry is not present in the ready review decision.", %{provider_matrix_entry_id: entry_id}) + end) + |> then(&(errors ++ &1)) + end + + defp collect_unknown_provider_entries(errors, _requested_ids, _decision_entry_ids), do: errors + + defp collect_mode_escalation(errors, requested_mode, selected_entries) when is_list(selected_entries) do + selected_entries + |> Enum.reject(&(Map.get(&1, "side_effect_mode") == requested_mode)) + |> Enum.map(fn entry -> + issue("side_effect_mode_escalation", ["scope", "side_effect_mode"], "Enablement request must not broaden a provider entry's reviewed side-effect mode.", %{ + provider_matrix_entry_id: Map.get(entry, "entry_id"), + reviewed_side_effect_mode: Map.get(entry, "side_effect_mode"), + requested_side_effect_mode: requested_mode + }) + end) + |> then(&(errors ++ &1)) + end + + defp collect_gate_values(errors, gate_values, requested_mode) when is_map(gate_values) do + transition_gate = Gates.transition_readiness_required_gate_key() + + errors + |> maybe_add( + Map.has_key?(gate_values, "review_handoff_required"), + issue("legacy_gate_name", ["gate_values", "review_handoff_required"], "Legacy review_handoff_required gate must not be used.") + ) + |> maybe_add( + not Map.has_key?(gate_values, transition_gate), + issue("missing_transition_readiness_gate", ["gate_values"], "Gate values must include the external transition readiness gate.") + ) + |> maybe_add( + Map.has_key?(gate_values, transition_gate) and not is_boolean(Map.get(gate_values, transition_gate)), + issue("invalid_gate_value", ["gate_values", transition_gate], "Transition readiness gate must be a boolean.") + ) + |> maybe_add( + requested_mode == "ready_to_land_write" and Map.get(gate_values, transition_gate) != true, + issue("ready_to_land_without_transition_gate", ["gate_values", transition_gate], "Ready-to-land enablement requires transition readiness enforcement.") + ) + end + + defp collect_gate_values(errors, _gate_values, _requested_mode) do + errors ++ [issue("required_field_missing", ["gate_values"], "Gate values must be an object.")] + end + + defp collect_observation_window(errors, request) do + window = value_at(request, ["observation_window"]) + days = value_at(window, ["duration_days"]) + + errors + |> collect_required_map(request, ["observation_window"]) + |> maybe_add( + not is_integer(days) or days <= 0, + issue("invalid_observation_window", ["observation_window", "duration_days"], "Observation window duration must be a positive integer.") + ) + |> collect_string_list(window, ["observation_window", "success_criteria"], "Observation success criteria must be a non-empty string array.") + end + + defp collect_rollback(errors, request) do + rollback = value_at(request, ["rollback"]) + gates = value_at(rollback, ["disable_gates"]) + transition_gate = Gates.transition_readiness_required_gate_key() + + errors + |> collect_required_map(request, ["rollback"]) + |> collect_required_string(rollback, ["rollback", "owner"]) + |> collect_string_list(rollback, ["rollback", "disable_gates"], "Rollback disable gates must be a non-empty string array.") + |> maybe_add( + is_list(gates) and transition_gate not in gates, + issue("missing_rollback_gate", ["rollback", "disable_gates"], "Rollback must include the transition readiness gate.") + ) + |> maybe_add( + value_at(rollback, ["verified"]) != true, + issue("rollback_not_verified", ["rollback", "verified"], "Rollback must be verified before enablement is requested.") + ) + end + + defp collect_non_claim_acknowledgement(errors, request, review_decision, requested_ids) do + acknowledged = value_at(request, ["acknowledged_non_claims"]) + expected = expected_non_claims(review_decision, requested_ids) + + errors = + collect_string_list(errors, request, ["acknowledged_non_claims"], "Acknowledged non-claims must be a non-empty string array.") + + if is_list(acknowledged) do + expected + |> Enum.reject(&(&1 in acknowledged)) + |> Enum.map(fn non_claim -> + issue("missing_non_claim_acknowledgement", ["acknowledged_non_claims"], "Enablement request must acknowledge every selected provider non-claim.", %{non_claim: non_claim}) + end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp collect_approvals(errors, request) do + approvals = value_at(request, ["approvals"]) + + cond do + not is_list(approvals) or approvals == [] -> + errors ++ [issue("required_field_missing", ["approvals"], "Approvals must be a non-empty array.")] + + true -> + errors ++ + (approvals + |> Enum.with_index() + |> Enum.flat_map(fn {approval, index} -> approval_errors(approval, index) end)) + end + end + + defp approval_errors(approval, index) when is_map(approval) do + path = ["approvals", index] + approved_at = value_at(approval, ["approved_at"]) + + [] + |> collect_required_string(approval, path ++ ["role"]) + |> collect_required_string(approval, path ++ ["owner"]) + |> collect_required_string(approval, path ++ ["approved_at"]) + |> maybe_add( + non_empty_string?(approved_at) and not valid_datetime?(approved_at), + issue("invalid_timestamp", path ++ ["approved_at"], "Approved-at timestamp must be ISO8601.") + ) + |> maybe_add( + value_at(approval, ["decision"]) != "approved", + issue("approval_not_approved", path ++ ["decision"], "Enablement approval decision must be approved.") + ) + end + + defp approval_errors(_approval, index) do + [issue("invalid_type", ["approvals", index], "Approval must be an object.")] + end + + defp collect_activation_control(errors, request) do + control = value_at(request, ["activation_control"]) + + errors + |> collect_required_map(request, ["activation_control"]) + |> collect_required_string(control, ["activation_control", "change_ticket"]) + |> maybe_add( + value_at(control, ["requires_operator_apply"]) != true, + issue("missing_operator_apply", ["activation_control", "requires_operator_apply"], "Enablement request must require an explicit operator apply.") + ) + |> maybe_add( + value_at(control, ["applies_immediately"]) != false, + issue("immediate_activation", ["activation_control", "applies_immediately"], "Enablement admission must not apply changes immediately.") + ) + end + + defp decision_provider_entries(decision) when is_map(decision) do + case value_at(decision, ["provider_entries"]) do + entries when is_list(entries) -> entries + _missing -> [] + end + end + + defp decision_provider_entries(_decision), do: [] + + defp expected_non_claims(decision, requested_ids) when is_list(requested_ids) do + decision + |> decision_provider_entries() + |> Enum.filter(&(Map.get(&1, "entry_id") in requested_ids)) + |> Enum.flat_map(&Map.get(&1, "non_claims", [])) + |> Enum.uniq() + end + + defp expected_non_claims(_decision, _requested_ids), do: [] + + defp normalize(request) do + %{ + "schema" => @schema, + "enablement_request_id" => value_at(request, ["enablement_request_id"]), + "profile_instance_id" => value_at(request, ["review_decision", "profile_instance_id"]), + "review_packet_id" => value_at(request, ["review_decision", "review_packet_id"]), + "scope" => value_at(request, ["scope"]), + "gate_values" => value_at(request, ["gate_values"]), + "observation_window" => value_at(request, ["observation_window"]), + "rollback" => value_at(request, ["rollback"]), + "acknowledged_non_claims" => value_at(request, ["acknowledged_non_claims"]), + "approvals" => value_at(request, ["approvals"]), + "activation_control" => value_at(request, ["activation_control"]), + "does_not_enable_production" => true + } + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery enablement request is invalid.", + errors: errors + } + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, [List.last(path)]) + + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message, extra \\ %{}) do + Map.merge( + %{ + code: code, + path: path, + message: message + }, + extra + ) + end + + defp valid_datetime?(value) when is_binary(value) do + match?({:ok, _datetime, _offset}, DateTime.from_iso8601(value)) + end + + defp valid_datetime?(_value), do: false + + defp string_list?(value) when is_list(value), do: Enum.all?(value, &non_empty_string?/1) + defp string_list?(_value), do: false + + defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != "" + defp non_empty_string?(_value), do: false + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_template.ex new file mode 100644 index 0000000..7612d12 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_template.ex @@ -0,0 +1,323 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequestTemplate do + @moduledoc """ + Builds production enablement-request fill templates from ready review decisions. + + The template is the handoff between a reviewer-approved decision and a human + production switch request. It validates the review decision boundary and + projects scoped request fields, but it does not apply settings, call + providers, mutate workflow state, or enable production gates. + """ + + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @schema "coding_pr_delivery.production_enablement_request_template.v1" + @completed_packet_schema "coding_pr_delivery.production_enablement_request.v1" + @error_code "coding_pr_delivery_enablement_request_template_invalid" + @review_decision_schema "coding_pr_delivery.production_review_decision.v1" + @side_effect_modes ["read_only", "shadow_no_write", "review_handoff_write", "ready_to_land_write"] + + @type result :: {:ok, map()} | {:error, map()} + + @spec build(map(), keyword()) :: result() + def build(review_decision, opts \\ []) + + def build(review_decision, opts) when is_map(review_decision) and is_list(opts) do + selected_ids = selected_entry_ids(review_decision, opts) + selected_entries = selected_entries(review_decision, selected_ids) + selected_mode = selected_side_effect_mode(selected_entries, opts) + + errors = + [] + |> collect_review_decision(review_decision) + |> collect_selected_entries(review_decision, selected_ids, selected_entries) + |> collect_selected_mode(selected_mode, selected_entries) + + if errors == [] do + {:ok, template(review_decision, selected_entries, selected_mode, opts)} + else + {:error, invalid(errors)} + end + end + + def build(_review_decision, opts) when is_list(opts) do + {:error, invalid([issue("invalid_type", [], "Review decision must be an object.")])} + end + + def build(_review_decision, _opts) do + {:error, invalid([issue("invalid_options", [], "Enablement request template options must be a keyword list.")])} + end + + defp collect_review_decision(errors, decision) do + blockers = value_at(decision, ["blockers"]) + + errors + |> maybe_add( + value_at(decision, ["schema"]) != @review_decision_schema, + issue("invalid_review_decision_schema", ["review_decision", "schema"], "Review decision schema is invalid.") + ) + |> maybe_add( + value_at(decision, ["status"]) != "ready_for_approval", + issue("review_decision_not_ready", ["review_decision", "status"], "Enablement template requires a ready review decision.") + ) + |> maybe_add( + blockers != [], + issue("review_decision_blocked", ["review_decision", "blockers"], "Enablement template requires a review decision with no blockers.") + ) + |> maybe_add( + value_at(decision, ["does_not_enable_production"]) != true, + issue("review_decision_side_effect_boundary", ["review_decision", "does_not_enable_production"], "Review decision must be non-mutating.") + ) + |> maybe_add( + value_at(decision, ["raw_evidence_payload_included"]) != false, + issue("raw_evidence_payload_present", ["review_decision", "raw_evidence_payload_included"], "Review decision must not include raw evidence payloads.") + ) + |> maybe_add( + not non_empty_string?(value_at(decision, ["profile_instance_id"])), + issue("required_field_missing", ["review_decision", "profile_instance_id"], "Review decision must name the profile instance.") + ) + |> maybe_add( + not is_list(value_at(decision, ["provider_entries"])) or value_at(decision, ["provider_entries"]) == [], + issue("required_field_missing", ["review_decision", "provider_entries"], "Review decision must include provider entries.") + ) + end + + defp collect_selected_entries(errors, review_decision, selected_ids, selected_entries) do + decision_entry_ids = + review_decision + |> provider_entries() + |> Enum.map(&Map.get(&1, "entry_id")) + + errors + |> maybe_add( + selected_ids == [], + issue("required_field_missing", ["provider_matrix_entry_ids"], "Enablement template requires at least one provider entry.") + ) + |> then(fn errors -> + selected_ids + |> Enum.reject(&(&1 in decision_entry_ids)) + |> Enum.map(fn entry_id -> + issue("unknown_provider_matrix_entry", ["provider_matrix_entry_ids"], "Selected provider entry is not present in the review decision.", %{provider_matrix_entry_id: entry_id}) + end) + |> then(&(errors ++ &1)) + end) + |> maybe_add( + selected_ids != [] and selected_entries == [], + issue("required_field_missing", ["provider_matrix_entry_ids"], "Selected provider entries must resolve to review decision entries.") + ) + end + + defp collect_selected_mode(errors, mode, selected_entries) do + modes = + selected_entries + |> Enum.map(&Map.get(&1, "side_effect_mode")) + |> Enum.uniq() + + errors + |> maybe_add( + not non_empty_string?(mode), + issue("ambiguous_side_effect_mode", ["side_effect_mode"], "Selected provider entries require one explicit side-effect mode.") + ) + |> maybe_add( + non_empty_string?(mode) and mode not in @side_effect_modes, + issue("invalid_side_effect_mode", ["side_effect_mode"], "Selected side-effect mode is unsupported.", %{allowed_values: @side_effect_modes}) + ) + |> maybe_add( + length(modes) > 1 and non_empty_string?(mode), + issue("mixed_side_effect_modes", ["provider_matrix_entry_ids"], "Selected provider entries must share one reviewed side-effect mode.") + ) + |> maybe_add( + length(modes) == 1 and non_empty_string?(mode) and hd(modes) != mode, + issue("side_effect_mode_mismatch", ["side_effect_mode"], "Selected side-effect mode must match the reviewed provider entry mode.", %{ + reviewed_side_effect_mode: hd(modes), + selected_side_effect_mode: mode + }) + ) + end + + defp template(review_decision, selected_entries, selected_mode, opts) do + selected_ids = Enum.map(selected_entries, &Map.get(&1, "entry_id")) + + %{ + "schema" => @schema, + "completed_packet_schema" => @completed_packet_schema, + "profile_instance_id" => value_at(review_decision, ["profile_instance_id"]), + "review_packet_id" => value_at(review_decision, ["review_packet_id"]), + "template_authority" => "enablement_request_shape_only", + "does_not_enable_production" => true, + "review_decision" => review_decision, + "selected_provider_entries" => selected_entries, + "enablement_request_field_template" => %{ + "enablement_request_id" => "fill-enable-request-id", + "requested_by" => "fill-requester", + "requested_at" => "fill-requested-at", + "review_decision" => review_decision, + "scope" => scope(selected_ids, selected_mode, opts), + "gate_values" => gate_values(selected_mode), + "observation_window" => observation_window(selected_mode, opts), + "rollback" => rollback(selected_entries), + "acknowledged_non_claims" => non_claims(selected_entries), + "approvals" => [], + "activation_control" => %{ + "change_ticket" => "fill-change-ticket", + "requires_operator_apply" => true, + "applies_immediately" => false + } + }, + "fields_to_complete" => [ + "enablement_request_id", + "requested_by", + "requested_at", + "scope.repositories", + "approvals", + "activation_control.change_ticket" + ] + } + end + + defp selected_entry_ids(review_decision, opts) do + case Keyword.get(opts, :provider_matrix_entry_ids) do + ids when is_list(ids) -> + Enum.filter(ids, &non_empty_string?/1) + + id when is_binary(id) -> + [id] + + _missing -> + review_decision + |> provider_entries() + |> Enum.map(&Map.get(&1, "entry_id")) + |> Enum.filter(&non_empty_string?/1) + end + end + + defp selected_entries(review_decision, selected_ids) do + review_decision + |> provider_entries() + |> Enum.filter(&(Map.get(&1, "entry_id") in selected_ids)) + end + + defp selected_side_effect_mode(selected_entries, opts) do + case Keyword.get(opts, :side_effect_mode) do + mode when is_binary(mode) -> + mode + + _missing -> + selected_entries + |> Enum.map(&Map.get(&1, "side_effect_mode")) + |> Enum.uniq() + |> case do + [mode] -> mode + _ambiguous -> nil + end + end + end + + defp scope(selected_ids, selected_mode, opts) do + %{ + "environment" => Keyword.get(opts, :environment, "production"), + "repositories" => Keyword.get(opts, :repositories, ["fill-repository"]), + "provider_matrix_entry_ids" => selected_ids, + "side_effect_mode" => selected_mode + } + end + + defp gate_values("ready_to_land_write") do + %{ + Gates.transition_readiness_required_gate_key() => true, + Gates.enabled_gate_key() => true + } + end + + defp gate_values(_mode) do + %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + } + end + + defp observation_window("shadow_no_write", opts) do + %{ + "duration_days" => Keyword.get(opts, :observation_days, 14), + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + } + end + + defp observation_window(_mode, opts) do + %{ + "duration_days" => Keyword.get(opts, :observation_days, 14), + "success_criteria" => ["zero unexpected transitions", "zero rollback violations"] + } + end + + defp rollback(selected_entries) do + %{ + "owner" => rollback_owner(selected_entries), + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true + } + end + + defp rollback_owner(selected_entries) do + selected_entries + |> Enum.map(&value_at(&1, ["rollback", "owner"])) + |> Enum.find("workflow-runtime", &non_empty_string?/1) + end + + defp non_claims(selected_entries) do + selected_entries + |> Enum.flat_map(&Map.get(&1, "non_claims", [])) + |> Enum.filter(&non_empty_string?/1) + |> Enum.uniq() + end + + defp provider_entries(review_decision) do + case value_at(review_decision, ["provider_entries"]) do + entries when is_list(entries) -> entries + _missing -> [] + end + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery enablement request template is invalid.", + errors: errors + } + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message, extra \\ %{}) do + Map.merge( + %{ + code: code, + path: path, + message: message + }, + extra + ) + end + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_bundle.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_bundle.ex new file mode 100644 index 0000000..23bbe20 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_bundle.ex @@ -0,0 +1,511 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceBundle do + @moduledoc """ + Builds a bounded Phase 2 evidence bundle readiness projection. + + The bundle reconciles a Phase 2 evidence plan, the provider-owner evidence + request, an optional provider preflight report, and completed evidence packet + metadata. It does not read referenced evidence files, call providers, mutate + workflow state, approve production, or enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + EvidencePacket, + EvidenceRequest, + Phase2EvidencePlan, + PreflightReport + } + + @schema "coding_pr_delivery.production_evidence_bundle.v1" + @phase2_schema "coding_pr_delivery.phase2_evidence_plan.v1" + @request_schema "coding_pr_delivery.production_evidence_request.v1" + + @type input :: Phase2EvidencePlan.plan() | String.t() | map() + @type result :: {:ok, map()} | {:error, map()} + + @spec build(input(), keyword()) :: result() + def build(input, opts \\ []) + + def build(input, opts) when is_list(opts) do + with {:ok, phase2_plan} <- phase2_plan(input, opts), + {:ok, request} <- EvidenceRequest.build(phase2_plan, plan_opts(opts)), + {:ok, evidence_packets} <- evidence_packets(opts) do + request_state = request_state(request, Keyword.get(opts, :evidence_request)) + preflight_state = preflight_state(phase2_plan, Keyword.get(opts, :preflight_report)) + evidence_state = evidence_state(phase2_plan, evidence_packets) + provider_statuses = provider_statuses(phase2_plan, evidence_state) + blockers = blockers(request_state, preflight_state, evidence_state, provider_statuses) + + {:ok, bundle(phase2_plan, request_state, preflight_state, evidence_state, provider_statuses, blockers)} + end + end + + def build(_input, _opts) do + {:error, invalid([issue("invalid_options", [], "Evidence bundle options must be a keyword list.")])} + end + + defp phase2_plan(%{"schema" => @phase2_schema} = plan, _opts), do: {:ok, plan} + + defp phase2_plan(plan, opts) do + Phase2EvidencePlan.build(plan, plan_opts(opts)) + end + + defp plan_opts(opts) do + opts + |> Keyword.take([:plan_id, :tapd_cnb_shadow_run_id, :linear_cnb_shadow_run_id]) + end + + defp evidence_packets(opts) do + cond do + Keyword.has_key?(opts, :evidence_packets) -> + case Keyword.fetch!(opts, :evidence_packets) do + packets when is_list(packets) -> {:ok, packets} + _packets -> {:error, invalid([issue("invalid_evidence_packets", [:evidence_packets], "Evidence packets must be a list.")])} + end + + Keyword.has_key?(opts, :evidence_packet) -> + {:ok, [Keyword.fetch!(opts, :evidence_packet)]} + + true -> + {:ok, []} + end + end + + defp request_state(expected_request, nil) do + %{ + provided?: false, + valid?: true, + status: "generated", + request: expected_request, + errors: [] + } + end + + defp request_state(expected_request, provided_request) when is_map(provided_request) do + errors = + [] + |> maybe_add( + Map.get(provided_request, "schema") != @request_schema, + issue("invalid_evidence_request_schema", ["evidence_request", "schema"], "Evidence request schema is invalid.") + ) + |> maybe_add( + Map.get(provided_request, "phase2_plan_id") != Map.get(expected_request, "phase2_plan_id"), + issue("evidence_request_plan_mismatch", ["evidence_request", "phase2_plan_id"], "Evidence request must match the Phase 2 plan id.") + ) + |> maybe_add( + Map.get(provided_request, "phase2_plan_kind") != Map.get(expected_request, "phase2_plan_kind"), + issue("evidence_request_plan_mismatch", ["evidence_request", "phase2_plan_kind"], "Evidence request must match the Phase 2 plan kind.") + ) + |> maybe_add( + Map.get(provided_request, "provider_request_count") != Map.get(expected_request, "provider_request_count"), + issue("evidence_request_provider_count_mismatch", ["evidence_request", "provider_request_count"], "Evidence request must match provider count.") + ) + + %{ + provided?: true, + valid?: errors == [], + status: if(errors == [], do: "provided", else: "invalid"), + request: expected_request, + errors: errors + } + end + + defp request_state(expected_request, _provided_request) do + %{ + provided?: true, + valid?: false, + status: "invalid", + request: expected_request, + errors: [issue("invalid_evidence_request", ["evidence_request"], "Evidence request must be an object.")] + } + end + + defp preflight_state(_phase2_plan, nil) do + %{provided?: false, valid?: false, status: "missing", errors: [], report: nil} + end + + defp preflight_state(phase2_plan, report) do + case PreflightReport.validate(report) do + {:ok, normalized} -> + mismatch_errors = phase2_mismatch_errors(phase2_plan, Map.get(normalized, "phase2_evidence_plan"), "preflight_report") + + %{ + provided?: true, + valid?: mismatch_errors == [], + status: if(mismatch_errors == [], do: Map.get(normalized, "status"), else: "invalid"), + planned_count: Map.get(normalized, "planned_preflight_command_count", 0), + result_count: Map.get(normalized, "preflight_result_count", 0), + passed_count: count_results(normalized, "passed"), + blocked_count: count_results(normalized, "blocked"), + errors: mismatch_errors, + report: normalized + } + + {:error, reason} -> + %{provided?: true, valid?: false, status: "invalid", errors: errors(reason), report: nil} + end + end + + defp evidence_state(phase2_plan, packets) do + expected_sets = expected_entry_sets(phase2_plan) + + packet_states = + packets + |> Enum.with_index() + |> Enum.map(fn {packet, index} -> evidence_packet_state(packet, index, expected_sets) end) + + %{ + provided_count: length(packets), + packet_states: packet_states, + valid_count: Enum.count(packet_states, & &1.valid?), + invalid_count: Enum.count(packet_states, &(not &1.valid?)), + expected_sets: expected_sets + } + end + + defp evidence_packet_state(packet, index, expected_sets) do + case EvidencePacket.validate(packet) do + {:ok, normalized} -> + entry_ids = evidence_packet_entry_ids(normalized) + matching_templates = matching_templates(entry_ids, expected_sets) + + %{ + index: index, + valid?: true, + status: "valid", + provider_matrix_entry_ids: entry_ids, + matching_templates: matching_templates, + scenario_evidence_count: length(Map.get(normalized, "scenario_evidence", [])), + errors: [] + } + + {:error, reason} -> + %{ + index: index, + valid?: false, + status: "invalid", + provider_matrix_entry_ids: [], + matching_templates: [], + scenario_evidence_count: 0, + errors: errors(reason) + } + end + end + + defp provider_statuses(phase2_plan, evidence_state) do + packet_states = Map.fetch!(evidence_state, :packet_states) + + phase2_plan + |> Map.get("provider_plans", []) + |> Enum.map(fn provider_plan -> + template = Map.get(provider_plan, "template") + entry_ids = Map.get(provider_plan, "provider_matrix_entry_ids", []) + matching_packets = Enum.filter(packet_states, &(template in &1.matching_templates)) + + %{ + "template" => template, + "tier" => Map.get(provider_plan, "tier"), + "provider_matrix_entry_ids" => entry_ids, + "tracker_kinds" => Map.get(provider_plan, "tracker_kinds", []), + "repo_provider_kinds" => Map.get(provider_plan, "repo_provider_kinds", []), + "side_effect_modes" => Map.get(provider_plan, "side_effect_modes", []), + "evidence_packet_status" => evidence_status(matching_packets), + "matching_evidence_packet_count" => length(matching_packets), + "review_packet_template_ready" => length(matching_packets) == 1 + } + end) + end + + defp evidence_status([]), do: "missing" + defp evidence_status([_packet]), do: "valid" + defp evidence_status(_packets), do: "duplicate" + + defp blockers(request_state, preflight_state, evidence_state, provider_statuses) do + [] + |> request_blockers(request_state) + |> preflight_blockers(preflight_state) + |> evidence_packet_blockers(evidence_state) + |> provider_status_blockers(provider_statuses) + end + + defp request_blockers(blockers, %{valid?: true}), do: blockers + + defp request_blockers(blockers, %{errors: errors}) do + [ + %{ + "code" => "evidence_request_invalid", + "message" => "Provided evidence request must match the Phase 2 evidence plan.", + "error_count" => length(errors), + "errors" => Enum.map(errors, &error_to_map/1) + } + | blockers + ] + end + + defp preflight_blockers(blockers, %{provided?: false}) do + [ + %{ + "code" => "provider_preflight_report_required", + "message" => "Provider preflight report is required before Phase 2 evidence can enter review." + } + | blockers + ] + end + + defp preflight_blockers(blockers, %{valid?: false, errors: errors}) do + [ + %{ + "code" => "provider_preflight_report_invalid", + "message" => "Provider preflight report must validate against the Phase 2 plan.", + "error_count" => length(errors), + "errors" => Enum.map(errors, &error_to_map/1) + } + | blockers + ] + end + + defp preflight_blockers(blockers, %{status: "passed"}), do: blockers + + defp preflight_blockers(blockers, %{status: "blocked", report: report}) do + [ + %{ + "code" => "provider_preflight_blocked", + "message" => "Provider preflight must pass before completed evidence can enter review.", + "blocked_count" => count_results(report, "blocked") + } + | blockers + ] + end + + defp preflight_blockers(blockers, _preflight), do: blockers + + defp evidence_packet_blockers(blockers, %{provided_count: 0}) do + [ + %{ + "code" => "completed_evidence_packet_required", + "message" => "At least one completed evidence packet is required before Phase 4 review." + } + | blockers + ] + end + + defp evidence_packet_blockers(blockers, %{packet_states: packet_states}) do + invalid = + packet_states + |> Enum.filter(&(not &1.valid?)) + |> Enum.map(fn packet_state -> + %{ + "code" => "completed_evidence_packet_invalid", + "message" => "Completed evidence packet metadata must validate.", + "packet_index" => packet_state.index, + "error_count" => length(packet_state.errors), + "errors" => Enum.map(packet_state.errors, &error_to_map/1) + } + end) + + unknown = + packet_states + |> Enum.filter(&(&1.valid? and &1.matching_templates == [])) + |> Enum.map(fn packet_state -> + %{ + "code" => "completed_evidence_packet_unmatched", + "message" => "Completed evidence packet provider entries must match a Phase 2 provider plan.", + "packet_index" => packet_state.index, + "provider_matrix_entry_ids" => packet_state.provider_matrix_entry_ids + } + end) + + invalid ++ unknown ++ blockers + end + + defp provider_status_blockers(blockers, provider_statuses) do + provider_statuses + |> Enum.reject(&(&1["evidence_packet_status"] == "valid")) + |> Enum.map(fn status -> + %{ + "code" => "provider_evidence_packet_#{status["evidence_packet_status"]}", + "message" => "Each Phase 2 provider plan must have exactly one matching completed evidence packet.", + "template" => status["template"], + "provider_matrix_entry_ids" => status["provider_matrix_entry_ids"] + } + end) + |> Kernel.++(blockers) + end + + defp bundle(phase2_plan, request_state, preflight_state, evidence_state, provider_statuses, blockers) do + %{ + "schema" => @schema, + "status" => if(blockers == [], do: "ready_for_phase4_review", else: "blocked"), + "phase4_ready" => blockers == [], + "phase2_plan_id" => Map.get(phase2_plan, "plan_id"), + "phase2_plan_kind" => Map.get(phase2_plan, "plan_kind"), + "provider_plan_count" => length(Map.get(phase2_plan, "provider_plans", [])), + "evidence_request" => request_summary(request_state), + "preflight" => preflight_summary(preflight_state), + "evidence_packets" => evidence_summary(evidence_state), + "provider_bundle_statuses" => provider_statuses, + "blockers" => blockers, + "does_not_collect_live_evidence" => true, + "does_not_read_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true, + "raw_input_included" => false, + "normalized_artifacts_included" => false + } + end + + defp request_summary(%{request: request} = state) do + %{ + "provided" => Map.get(state, :provided?), + "valid" => Map.get(state, :valid?), + "status" => Map.get(state, :status), + "provider_request_count" => Map.get(request, "provider_request_count", 0), + "error_count" => length(Map.get(state, :errors, [])) + } + end + + defp preflight_summary(%{provided?: false}) do + %{ + "provided" => false, + "valid" => false, + "status" => "missing", + "planned_preflight_command_count" => 0, + "preflight_result_count" => 0, + "passed_count" => 0, + "blocked_count" => 0, + "error_count" => 0 + } + end + + defp preflight_summary(preflight) do + %{ + "provided" => Map.get(preflight, :provided?), + "valid" => Map.get(preflight, :valid?), + "status" => Map.get(preflight, :status), + "planned_preflight_command_count" => Map.get(preflight, :planned_count, 0), + "preflight_result_count" => Map.get(preflight, :result_count, 0), + "passed_count" => Map.get(preflight, :passed_count, 0), + "blocked_count" => Map.get(preflight, :blocked_count, 0), + "error_count" => length(Map.get(preflight, :errors, [])) + } + end + + defp evidence_summary(evidence_state) do + %{ + "provided_count" => Map.get(evidence_state, :provided_count), + "valid_count" => Map.get(evidence_state, :valid_count), + "invalid_count" => Map.get(evidence_state, :invalid_count), + "expected_provider_plan_count" => length(Map.get(evidence_state, :expected_sets, [])), + "packets" => Enum.map(Map.get(evidence_state, :packet_states, []), &packet_summary/1) + } + end + + defp packet_summary(packet_state) do + %{ + "packet_index" => packet_state.index, + "status" => packet_state.status, + "provider_matrix_entry_ids" => packet_state.provider_matrix_entry_ids, + "matching_templates" => packet_state.matching_templates, + "scenario_evidence_count" => packet_state.scenario_evidence_count, + "error_count" => length(packet_state.errors) + } + end + + defp expected_entry_sets(phase2_plan) do + phase2_plan + |> Map.get("provider_plans", []) + |> Enum.map(fn provider_plan -> + %{ + template: Map.get(provider_plan, "template"), + entry_ids: provider_plan |> Map.get("provider_matrix_entry_ids", []) |> Enum.sort() + } + end) + end + + defp matching_templates(entry_ids, expected_sets) do + sorted_ids = Enum.sort(entry_ids) + + expected_sets + |> Enum.filter(&(&1.entry_ids == sorted_ids)) + |> Enum.map(& &1.template) + end + + defp evidence_packet_entry_ids(packet) do + packet + |> value_at(["production_claim", "provider_matrix"]) + |> case do + entries when is_list(entries) -> + entries + |> Enum.map(&Map.get(&1, "id")) + |> Enum.filter(&non_empty_string?/1) + |> Enum.sort() + + _entries -> + [] + end + end + + defp phase2_mismatch_errors(phase2_plan, compared_plan, label) do + [] + |> maybe_add( + Map.get(compared_plan || %{}, "schema") != @phase2_schema, + issue("#{label}_phase2_schema_mismatch", [label, "phase2_evidence_plan", "schema"], "Nested Phase 2 evidence plan schema is invalid.") + ) + |> maybe_add( + Map.get(compared_plan || %{}, "plan_id") != Map.get(phase2_plan, "plan_id"), + issue("#{label}_phase2_plan_mismatch", [label, "phase2_evidence_plan", "plan_id"], "Nested Phase 2 evidence plan id must match.") + ) + |> maybe_add( + Map.get(compared_plan || %{}, "plan_kind") != Map.get(phase2_plan, "plan_kind"), + issue("#{label}_phase2_plan_mismatch", [label, "phase2_evidence_plan", "plan_kind"], "Nested Phase 2 evidence plan kind must match.") + ) + end + + defp count_results(nil, _status), do: 0 + + defp count_results(report, status) do + report + |> Map.get("provider_preflight_results", []) + |> Enum.count(&(Map.get(&1, "status") == status)) + end + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "production_evidence_bundle_error", "message" => "Evidence bundle projection failed."} + + defp invalid(errors) do + %{ + code: "coding_pr_delivery_evidence_bundle_invalid", + message: "Coding PR Delivery production evidence bundle is invalid.", + errors: errors + } + end + + defp issue(code, path, message, meta \\ %{}) do + %{code: code, path: path, message: message, meta: meta} + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + if is_map(current) and Map.has_key?(current, key) do + {:cont, Map.get(current, key)} + else + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_handoff.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_handoff.ex new file mode 100644 index 0000000..2fd4554 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_handoff.ex @@ -0,0 +1,217 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceHandoff do + @moduledoc """ + Builds a bounded Phase 2 provider-evidence handoff package. + + The package gives provider owners one metadata view over the Phase 2 evidence + plan, provider-owner request, production status, and evidence bundle readiness. + It does not read referenced evidence files, call providers, mutate workflow + state, approve production, or enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + EvidenceBundle, + EvidenceRequest, + Phase2EvidencePlan, + StatusReport + } + + @schema "coding_pr_delivery.production_evidence_handoff.v1" + @phase2_schema "coding_pr_delivery.phase2_evidence_plan.v1" + + @type input :: Phase2EvidencePlan.plan() | String.t() | map() + @type result :: {:ok, map()} | {:error, map()} + + @spec build(input(), keyword()) :: result() + def build(input, opts \\ []) + + def build(input, opts) when is_list(opts) do + with {:ok, phase2_plan} <- phase2_plan(input, opts), + {:ok, request} <- EvidenceRequest.build(phase2_plan, plan_opts(opts)), + {:ok, status} <- StatusReport.build(phase2_plan, status_opts(opts)), + {:ok, bundle} <- EvidenceBundle.build(phase2_plan, bundle_opts(opts)) do + {:ok, handoff(phase2_plan, request, status, bundle)} + end + end + + def build(_input, _opts) do + {:error, invalid([issue("invalid_options", [], "Evidence handoff options must be a keyword list.")])} + end + + defp phase2_plan(%{"schema" => @phase2_schema} = plan, _opts), do: {:ok, plan} + defp phase2_plan(plan, opts), do: Phase2EvidencePlan.build(plan, plan_opts(opts)) + + defp plan_opts(opts) do + Keyword.take(opts, [:plan_id, :tapd_cnb_shadow_run_id, :linear_cnb_shadow_run_id]) + end + + defp status_opts(opts) do + opts + |> plan_opts() + |> maybe_put(:preflight_report, Keyword.get(opts, :preflight_report)) + end + + defp bundle_opts(opts) do + opts + |> Keyword.take([ + :plan_id, + :tapd_cnb_shadow_run_id, + :linear_cnb_shadow_run_id, + :evidence_request, + :preflight_report, + :evidence_packet, + :evidence_packets + ]) + end + + defp handoff(phase2_plan, request, status, bundle) do + %{ + "schema" => @schema, + "status" => handoff_status(bundle), + "phase4_ready" => Map.get(bundle, "phase4_ready", false), + "phase2_plan_id" => Map.get(phase2_plan, "plan_id"), + "phase2_plan_kind" => Map.get(phase2_plan, "plan_kind"), + "provider_plan_count" => length(Map.get(phase2_plan, "provider_plans", [])), + "provider_handoffs" => provider_handoffs(request, bundle), + "external_input_summary" => Map.get(request, "external_input_summary", %{}), + "preflight" => Map.get(status, "preflight", %{}), + "status_blocker_count" => length(Map.get(status, "blockers", [])), + "evidence_bundle" => bundle_summary(bundle), + "operator_commands" => operator_commands(Map.get(phase2_plan, "plan_kind")), + "blockers" => Map.get(bundle, "blockers", []), + "required_next_step" => required_next_step(bundle), + "does_not_collect_live_evidence" => true, + "does_not_read_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true, + "raw_input_included" => false, + "normalized_artifacts_included" => false + } + end + + defp handoff_status(%{"phase4_ready" => true}), do: "ready_for_phase4_review" + defp handoff_status(_bundle), do: "blocked_pending_external_evidence" + + defp required_next_step(%{"phase4_ready" => true}) do + "build and validate the Phase 4 review packet with owner sign-off" + end + + defp required_next_step(_bundle) do + "supply provider credentials and concrete targets, run read-only preflight, collect provider evidence, then rebuild this handoff package" + end + + defp provider_handoffs(request, bundle) do + bundle_statuses = Map.get(bundle, "provider_bundle_statuses", []) + + request + |> Map.get("provider_requests", []) + |> Enum.map(fn provider_request -> + bundle_status = matching_bundle_status(bundle_statuses, provider_request) + + %{ + "template" => Map.get(provider_request, "template"), + "tier" => Map.get(provider_request, "tier"), + "provider_matrix_entry_ids" => Map.get(provider_request, "provider_matrix_entry_ids", []), + "tracker_kinds" => Map.get(provider_request, "tracker_kinds", []), + "repo_provider_kinds" => Map.get(provider_request, "repo_provider_kinds", []), + "side_effect_modes" => Map.get(provider_request, "side_effect_modes", []), + "live_evidence_status" => Map.get(provider_request, "live_evidence_status"), + "required_access" => Map.get(provider_request, "required_access", %{}), + "preflight_command_count" => length(Map.get(provider_request, "read_only_preflight_commands", [])), + "evidence_requirement_count" => length(Map.get(provider_request, "evidence_requirements", [])), + "evidence_destinations" => evidence_destinations(provider_request), + "owner_actions" => Map.get(provider_request, "owner_actions", []), + "evidence_packet_status" => Map.get(bundle_status, "evidence_packet_status", "missing"), + "matching_evidence_packet_count" => Map.get(bundle_status, "matching_evidence_packet_count", 0), + "review_packet_template_ready" => Map.get(bundle_status, "review_packet_template_ready", false) + } + end) + end + + defp matching_bundle_status(statuses, provider_request) do + template = Map.get(provider_request, "template") + + Enum.find(statuses, %{}, &(Map.get(&1, "template") == template)) + end + + defp evidence_destinations(provider_request) do + provider_request + |> Map.get("evidence_requirements", []) + |> Enum.flat_map(&Map.get(&1, "evidence_files", [])) + |> Enum.filter(&non_empty_string?/1) + |> Enum.uniq() + |> Enum.sort() + end + + defp bundle_summary(bundle) do + %{ + "status" => Map.get(bundle, "status"), + "phase4_ready" => Map.get(bundle, "phase4_ready"), + "evidence_request" => Map.get(bundle, "evidence_request", %{}), + "preflight" => Map.get(bundle, "preflight", %{}), + "evidence_packets" => Map.get(bundle, "evidence_packets", %{}), + "blocker_count" => length(Map.get(bundle, "blockers", [])) + } + end + + defp operator_commands(plan_kind) do + [ + %{ + "name" => "collect_read_only_preflight", + "command_id" => "symphony.workflow.extension.coding_pr_delivery.production_profile_preflight_collect", + "args" => ["--plan", plan_kind, "--repo", "", "--pr", "", "--json"], + "requires_external_access" => true, + "does_not_authorize_writes" => true + }, + %{ + "name" => "export_provider_evidence_request", + "command_id" => "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_request", + "args" => ["--plan", plan_kind, "--json"], + "requires_external_access" => false, + "does_not_authorize_writes" => true + }, + %{ + "name" => "validate_completed_evidence_packet", + "command_id" => "symphony.workflow.extension.coding_pr_delivery.production_profile_validate", + "args" => ["--kind", "evidence_packet", "--file", "", "--json"], + "requires_external_access" => false, + "does_not_authorize_writes" => true + }, + %{ + "name" => "rebuild_evidence_handoff", + "command_id" => "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_handoff", + "args" => [ + "--phase2-plan-file", + "", + "--evidence-request-file", + "", + "--preflight-report-file", + "", + "--evidence-packet-file", + "", + "--json" + ], + "requires_external_access" => false, + "does_not_authorize_writes" => true + } + ] + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) + + defp invalid(errors) do + %{ + code: "coding_pr_delivery_evidence_handoff_invalid", + message: "Coding PR Delivery production evidence handoff is invalid.", + errors: errors + } + end + + defp issue(code, path, message, meta \\ %{}) do + %{code: code, path: path, message: message, meta: meta} + end + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet.ex new file mode 100644 index 0000000..9f538be --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet.ex @@ -0,0 +1,530 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacket do + @moduledoc """ + Admission checks for completed Phase 2 provider evidence packets. + + The validator proves that a packet covers the deterministic evidence runbook + generated from a valid production claim. It validates evidence metadata only; + it does not fetch provider state, read evidence files, or enable production + gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + Claim, + EvidenceRunbook + } + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + @schema "coding_pr_delivery.production_evidence_packet.v1" + @error_code "coding_pr_delivery_evidence_packet_invalid" + @shadow_mode OneShotContract.shadow_mode() + @raw_evidence_fields [ + "stdout", + "stderr", + "raw_output", + "raw_payload", + "raw_provider_payload", + "raw_evidence_payload", + "response_body", + "environment", + "env", + "token" + ] + @placeholder_tokens ["fill-", "TODO", "REPLACE", "<", ">"] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate(map()) :: validation_result() + def validate(packet) when is_map(packet) do + claim_result = packet |> value_at(["production_claim"]) |> Claim.validate() + runbook_result = runbook_result(claim_result) + entries = runbook_entries(runbook_result) + + errors = + [] + |> collect_nested_errors(claim_result, ["production_claim"]) + |> collect_scenario_evidence(packet, entries) + |> collect_non_claim_acknowledgements(packet, entries) + + if errors == [] do + {:ok, normalize(packet, claim_result, runbook_result)} + else + {:error, invalid(errors)} + end + end + + def validate(_packet) do + {:error, invalid([issue("invalid_type", [], "Evidence packet must be an object.")])} + end + + defp runbook_result({:ok, claim}), do: EvidenceRunbook.build(claim) + defp runbook_result(_claim_result), do: {:ok, %{"entries" => []}} + + defp runbook_entries({:ok, %{"entries" => entries}}) when is_list(entries), do: entries + defp runbook_entries(_runbook_result), do: [] + + defp collect_scenario_evidence(errors, packet, entries) do + records = value_at(packet, ["scenario_evidence"]) + + cond do + not is_list(records) or records == [] -> + errors ++ [issue("required_field_missing", ["scenario_evidence"], "Scenario evidence must be a non-empty array.")] + + true -> + expected_pairs = expected_scenario_pairs(entries) + expected_set = MapSet.new(expected_pairs) + entry_by_id = entries_by_id(entries) + + errors ++ + scenario_record_errors(records, entry_by_id, expected_set) ++ + scenario_coverage_errors(records, expected_pairs, expected_set) + end + end + + defp scenario_record_errors(records, entry_by_id, expected_set) do + records + |> Enum.with_index() + |> Enum.flat_map(fn {record, index} -> evidence_record_errors(record, index, entry_by_id, expected_set) end) + end + + defp evidence_record_errors(record, index, entry_by_id, expected_set) when is_map(record) do + path = ["scenario_evidence", index] + entry_id = value_at(record, ["provider_matrix_entry_id"]) + scenario_id = value_at(record, ["scenario_id"]) + entry = Map.get(entry_by_id, entry_id) + pair = {entry_id, scenario_id} + + [] + |> collect_required_string(record, path ++ ["provider_matrix_entry_id"]) + |> collect_required_string(record, path ++ ["scenario_id"]) + |> maybe_add( + non_empty_string?(entry_id) and non_empty_string?(scenario_id) and not MapSet.member?(expected_set, pair), + issue("unknown_scenario_evidence", path, "Scenario evidence must match a provider entry and runbook scenario.", %{ + provider_matrix_entry_id: entry_id, + scenario_id: scenario_id + }) + ) + |> collect_required_string(record, path ++ ["collector"]) + |> collect_collected_at(record, path) + |> collect_status(record, path) + |> collect_evidence_files(record, path ++ ["evidence_files"]) + |> collect_raw_evidence_fields(record, path) + |> collect_evidence_kind(record, entry, path) + |> collect_shadow_boundary(record, entry, path) + end + + defp evidence_record_errors(_record, index, _entry_by_id, _expected_set) do + [issue("invalid_type", ["scenario_evidence", index], "Scenario evidence record must be an object.")] + end + + defp collect_collected_at(errors, record, path) do + collected_at = value_at(record, ["collected_at"]) + + errors + |> collect_required_string(record, path ++ ["collected_at"]) + |> maybe_add( + non_empty_string?(collected_at) and not valid_datetime?(collected_at), + issue("invalid_timestamp", path ++ ["collected_at"], "Collected-at timestamp must be ISO8601.") + ) + end + + defp collect_status(errors, record, path) do + status = value_at(record, ["status"]) + + errors + |> collect_required_string(record, path ++ ["status"]) + |> maybe_add( + non_empty_string?(status) and status != "passed", + issue("scenario_not_passed", path ++ ["status"], "Production evidence scenarios must be passed.") + ) + end + + defp collect_evidence_files(errors, record, path) do + evidence_files = value_at(record, [List.last(path)]) + + errors = + collect_string_list(errors, record, path, "Evidence files must be a non-empty string array.") + + if is_list(evidence_files) do + evidence_files + |> Enum.with_index() + |> Enum.flat_map(fn {evidence_ref, index} -> evidence_ref_errors(evidence_ref, path ++ [index]) end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp evidence_ref_errors(evidence_ref, path) do + [] + |> maybe_add( + non_empty_string?(evidence_ref) and not allowed_evidence_ref?(evidence_ref), + issue("invalid_evidence_ref", path, "Evidence references must be repository evidence paths or HTTP(S) links.") + ) + |> maybe_add( + non_empty_string?(evidence_ref) and placeholder_evidence_ref?(evidence_ref), + issue("placeholder_evidence_ref", path, "Evidence references must not contain placeholders.") + ) + end + + defp allowed_evidence_ref?(evidence_ref) do + String.starts_with?(evidence_ref, "evidence/") or + String.starts_with?(evidence_ref, "https://") or + String.starts_with?(evidence_ref, "http://") + end + + defp placeholder_evidence_ref?(evidence_ref) do + downcased = String.downcase(evidence_ref) + + String.starts_with?(downcased, "/tmp/") or + String.starts_with?(downcased, "tmp/") or + String.starts_with?(downcased, "/var/") or + String.starts_with?(downcased, "file://") or + Enum.any?(@placeholder_tokens, &String.contains?(downcased, String.downcase(&1))) + end + + defp collect_raw_evidence_fields(errors, record, path) do + @raw_evidence_fields + |> Enum.filter(&Map.has_key?(record, &1)) + |> Enum.map(fn field -> + issue("raw_evidence_payload_forbidden", path ++ [field], "Evidence packets must not store raw provider output, environment, or token material.") + end) + |> then(&(errors ++ &1)) + end + + defp collect_evidence_kind(errors, record, %{"side_effect_mode" => @shadow_mode}, path) do + evidence_kind = value_at(record, ["evidence_kind"]) + + errors + |> collect_required_string(record, path ++ ["evidence_kind"]) + |> maybe_add( + non_empty_string?(evidence_kind) and evidence_kind != "shadow_integration", + issue("invalid_evidence_kind", path ++ ["evidence_kind"], "Shadow provider entries must use shadow_integration evidence.") + ) + end + + defp collect_evidence_kind(errors, record, entry, path) when is_map(entry) do + evidence_kind = value_at(record, ["evidence_kind"]) + + errors + |> collect_required_string(record, path ++ ["evidence_kind"]) + |> maybe_add( + non_empty_string?(evidence_kind) and evidence_kind != "real_integration", + issue("invalid_evidence_kind", path ++ ["evidence_kind"], "Non-shadow provider entries must use real_integration evidence.") + ) + end + + defp collect_evidence_kind(errors, _record, _entry, _path), do: errors + + defp collect_shadow_boundary(errors, record, %{"side_effect_mode" => @shadow_mode}, path) do + shadow = value_at(record, ["shadow"]) + + errors + |> collect_required_map(record, path ++ ["shadow"]) + |> collect_required_string(shadow, path ++ ["shadow", "run_id"]) + |> maybe_add( + value_at(shadow, ["prefix"]) != OneShotContract.shadow_prefix(), + issue("invalid_shadow_prefix", path ++ ["shadow", "prefix"], "Shadow evidence must use the production no-write prefix.") + ) + |> maybe_add( + value_at(shadow, ["authority"]) != OneShotContract.shadow_authority(), + issue("invalid_shadow_authority", path ++ ["shadow", "authority"], "Shadow evidence must use diagnostic-only authority.") + ) + |> maybe_add( + value_at(shadow, ["canonical_authority"]) != false, + issue("shadow_canonical_authority", path ++ ["shadow", "canonical_authority"], "Shadow evidence must not claim canonical authority.") + ) + |> maybe_add( + value_at(record, ["production_write_performed"]) != false, + issue("shadow_production_write", path ++ ["production_write_performed"], "Shadow evidence must not perform production writes.") + ) + |> maybe_add( + value_at(record, ["canonical_surface_mutated"]) != false, + issue("shadow_canonical_surface_mutated", path ++ ["canonical_surface_mutated"], "Shadow evidence must not mutate canonical surfaces.") + ) + |> collect_allowed_destinations(shadow, path ++ ["shadow", "allowed_destinations"]) + end + + defp collect_shadow_boundary(errors, _record, _entry, _path), do: errors + + defp collect_allowed_destinations(errors, shadow, path) do + destinations = value_at(shadow, ["allowed_destinations"]) + allowed = OneShotContract.shadow_allowed_destinations() + + errors = + collect_string_list(errors, shadow, path, "Shadow destinations must be a non-empty string array.") + + if is_list(destinations) do + destinations + |> Enum.reject(&(&1 in allowed)) + |> Enum.map(fn destination -> + issue("invalid_shadow_destination", path, "Shadow evidence destination is not diagnostic-only.", %{destination: destination}) + end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp scenario_coverage_errors(records, expected_pairs, expected_set) do + observed_pairs = + records + |> Enum.filter(&is_map/1) + |> Enum.map(&{value_at(&1, ["provider_matrix_entry_id"]), value_at(&1, ["scenario_id"])}) + |> Enum.filter(fn {entry_id, scenario_id} -> non_empty_string?(entry_id) and non_empty_string?(scenario_id) end) + + observed_counts = Enum.frequencies(observed_pairs) + observed_set = MapSet.new(observed_pairs) + + missing = + expected_pairs + |> Enum.reject(&MapSet.member?(observed_set, &1)) + |> Enum.map(fn {entry_id, scenario_id} -> + issue("missing_scenario_evidence", ["scenario_evidence"], "Every runbook scenario must have one evidence record.", %{ + provider_matrix_entry_id: entry_id, + scenario_id: scenario_id + }) + end) + + duplicate = + observed_counts + |> Enum.filter(fn {_pair, count} -> count > 1 end) + |> Enum.map(fn {{entry_id, scenario_id}, _count} -> + issue("duplicate_scenario_evidence", ["scenario_evidence"], "A runbook scenario must not have duplicate evidence records.", %{ + provider_matrix_entry_id: entry_id, + scenario_id: scenario_id + }) + end) + + unknown = + observed_pairs + |> Enum.reject(&MapSet.member?(expected_set, &1)) + |> Enum.uniq() + |> Enum.map(fn {entry_id, scenario_id} -> + issue("unknown_scenario_evidence", ["scenario_evidence"], "Scenario evidence must match a runbook scenario.", %{ + provider_matrix_entry_id: entry_id, + scenario_id: scenario_id + }) + end) + + missing ++ duplicate ++ unknown + end + + defp collect_non_claim_acknowledgements(errors, packet, entries) do + acknowledgements = value_at(packet, ["non_claim_acknowledgements"]) + + cond do + not is_list(acknowledgements) or acknowledgements == [] -> + errors ++ [issue("required_field_missing", ["non_claim_acknowledgements"], "Non-claims must be acknowledged per provider entry.")] + + true -> + expected_non_claims = expected_non_claims_by_entry(entries) + + errors ++ + non_claim_acknowledgement_errors(acknowledgements, expected_non_claims) ++ + non_claim_acknowledgement_coverage_errors(acknowledgements, expected_non_claims) + end + end + + defp non_claim_acknowledgement_errors(acknowledgements, expected_non_claims) do + acknowledgements + |> Enum.with_index() + |> Enum.flat_map(fn {acknowledgement, index} -> non_claim_acknowledgement_errors(acknowledgement, index, expected_non_claims) end) + end + + defp non_claim_acknowledgement_errors(acknowledgement, index, expected_non_claims) when is_map(acknowledgement) do + path = ["non_claim_acknowledgements", index] + entry_id = value_at(acknowledgement, ["provider_matrix_entry_id"]) + non_claims = value_at(acknowledgement, ["non_claims"]) + expected = Map.get(expected_non_claims, entry_id, []) + + [] + |> collect_required_string(acknowledgement, path ++ ["provider_matrix_entry_id"]) + |> maybe_add( + non_empty_string?(entry_id) and not Map.has_key?(expected_non_claims, entry_id), + issue("unknown_provider_matrix_entry", path ++ ["provider_matrix_entry_id"], "Non-claim acknowledgement must match a provider entry.") + ) + |> collect_required_string(acknowledgement, path ++ ["owner"]) + |> collect_acknowledged_at(acknowledgement, path) + |> collect_string_list(acknowledgement, path ++ ["non_claims"], "Non-claims must be a non-empty string array.") + |> collect_missing_non_claims(non_claims, expected, path) + end + + defp non_claim_acknowledgement_errors(_acknowledgement, index, _expected_non_claims) do + [issue("invalid_type", ["non_claim_acknowledgements", index], "Non-claim acknowledgement must be an object.")] + end + + defp collect_acknowledged_at(errors, acknowledgement, path) do + acknowledged_at = value_at(acknowledgement, ["acknowledged_at"]) + + errors + |> collect_required_string(acknowledgement, path ++ ["acknowledged_at"]) + |> maybe_add( + non_empty_string?(acknowledged_at) and not valid_datetime?(acknowledged_at), + issue("invalid_timestamp", path ++ ["acknowledged_at"], "Acknowledged-at timestamp must be ISO8601.") + ) + end + + defp collect_missing_non_claims(errors, non_claims, expected, path) when is_list(non_claims) do + expected + |> Enum.reject(&(&1 in non_claims)) + |> Enum.map(fn non_claim -> + issue("missing_non_claim_acknowledgement", path ++ ["non_claims"], "Expected non-claim is not acknowledged.", %{non_claim: non_claim}) + end) + |> then(&(errors ++ &1)) + end + + defp collect_missing_non_claims(errors, _non_claims, _expected, _path), do: errors + + defp non_claim_acknowledgement_coverage_errors(acknowledgements, expected_non_claims) do + observed_ids = + acknowledgements + |> Enum.filter(&is_map/1) + |> Enum.map(&value_at(&1, ["provider_matrix_entry_id"])) + |> Enum.filter(&non_empty_string?/1) + + observed_counts = Enum.frequencies(observed_ids) + + missing = + expected_non_claims + |> Map.keys() + |> Enum.reject(&(&1 in observed_ids)) + |> Enum.map(fn entry_id -> + issue("missing_non_claim_acknowledgement", ["non_claim_acknowledgements"], "Each provider entry must acknowledge explicit non-claims.", %{ + provider_matrix_entry_id: entry_id + }) + end) + + duplicate = + observed_counts + |> Enum.filter(fn {_entry_id, count} -> count > 1 end) + |> Enum.map(fn {entry_id, _count} -> + issue("duplicate_non_claim_acknowledgement", ["non_claim_acknowledgements"], "A provider entry must not have duplicate non-claim acknowledgements.", %{ + provider_matrix_entry_id: entry_id + }) + end) + + missing ++ duplicate + end + + defp expected_scenario_pairs(entries) do + Enum.flat_map(entries, fn entry -> + entry_id = Map.get(entry, "entry_id") + + entry + |> Map.get("scenario_checklist", []) + |> Enum.map(&{entry_id, Map.get(&1, "id")}) + |> Enum.filter(fn {entry_id, scenario_id} -> non_empty_string?(entry_id) and non_empty_string?(scenario_id) end) + end) + end + + defp entries_by_id(entries) do + entries + |> Enum.map(&{Map.get(&1, "entry_id"), &1}) + |> Enum.filter(fn {entry_id, _entry} -> non_empty_string?(entry_id) end) + |> Map.new() + end + + defp expected_non_claims_by_entry(entries) do + entries + |> Enum.map(&{Map.get(&1, "entry_id"), Map.get(&1, "non_claims", [])}) + |> Enum.filter(fn {entry_id, non_claims} -> non_empty_string?(entry_id) and is_list(non_claims) end) + |> Map.new() + end + + defp normalize(packet, {:ok, claim}, {:ok, runbook}) do + %{ + "schema" => @schema, + "profile_instance_id" => Map.get(claim, "profile_instance_id"), + "production_claim" => claim, + "runbook" => runbook, + "scenario_evidence" => value_at(packet, ["scenario_evidence"]), + "non_claim_acknowledgements" => value_at(packet, ["non_claim_acknowledgements"]) + } + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery evidence packet is invalid.", + errors: errors + } + end + + defp collect_nested_errors(errors, {:ok, _value}, _path), do: errors + + defp collect_nested_errors(errors, {:error, %{errors: nested_errors}}, path) when is_list(nested_errors) do + errors ++ Enum.map(nested_errors, &prefix_error(&1, path)) + end + + defp prefix_error(error, path) do + %{ + code: error_code(error), + path: path ++ error_path(error), + message: error_message(error) + } + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, local_path(path))), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, local_path(path))), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, local_path(path)) + + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp local_path(path), do: [List.last(path)] + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message, extra \\ %{}) do + Map.merge( + %{ + code: code, + path: path, + message: message + }, + extra + ) + end + + defp valid_datetime?(value) when is_binary(value) do + match?({:ok, _datetime, _offset}, DateTime.from_iso8601(value)) + end + + defp valid_datetime?(_value), do: false + + defp string_list?(value) when is_list(value), do: Enum.all?(value, &non_empty_string?/1) + defp string_list?(_value), do: false + + defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != "" + defp non_empty_string?(_value), do: false + + defp error_code(error), do: Map.get(error, :code) || Map.get(error, "code") || "invalid" + defp error_path(error), do: Map.get(error, :path) || Map.get(error, "path") || [] + defp error_message(error), do: Map.get(error, :message) || Map.get(error, "message") || "Invalid evidence packet." + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_template.ex new file mode 100644 index 0000000..6f0b630 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_template.ex @@ -0,0 +1,110 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacketTemplate do + @moduledoc """ + Builds Phase 2 evidence-packet fill templates from accepted production claims. + + The template enumerates the completed evidence packet shape required after + live provider or shadow runs. It does not mark evidence as passed, read + evidence files, call providers, mutate workflow state, or enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + Claim, + EvidenceRunbook + } + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + @schema "coding_pr_delivery.production_evidence_packet_template.v1" + @completed_packet_schema "coding_pr_delivery.production_evidence_packet.v1" + @shadow_mode OneShotContract.shadow_mode() + + @type result :: {:ok, map()} | {:error, map()} + + @spec build(map()) :: result() + def build(claim) when is_map(claim) do + with {:ok, normalized_claim} <- Claim.validate(claim), + {:ok, runbook} <- EvidenceRunbook.build(normalized_claim) do + {:ok, template(normalized_claim, runbook)} + end + end + + def build(claim), do: Claim.validate(claim) + + defp template(claim, runbook) do + entries = Map.get(runbook, "entries", []) + + %{ + "schema" => @schema, + "completed_packet_schema" => @completed_packet_schema, + "profile_instance_id" => Map.get(claim, "profile_instance_id"), + "template_authority" => "evidence_packet_shape_only", + "does_not_collect_live_evidence" => true, + "production_claim" => claim, + "runbook" => runbook, + "scenario_evidence_requirements" => scenario_requirements(entries), + "non_claim_acknowledgement_requirements" => non_claim_acknowledgement_requirements(entries) + } + end + + defp scenario_requirements(entries) do + Enum.flat_map(entries, fn entry -> + entry + |> Map.get("scenario_checklist", []) + |> Enum.map(&scenario_requirement(entry, &1)) + end) + end + + defp scenario_requirement(entry, scenario) do + entry_id = Map.get(entry, "entry_id") + + %{ + "provider_matrix_entry_id" => entry_id, + "scenario_id" => Map.get(scenario, "id"), + "scenario_title" => Map.get(scenario, "title"), + "required_status" => "passed", + "required_evidence_kind" => evidence_kind(entry), + "evidence_files" => ["evidence/live/#{entry_id}/#{Map.get(scenario, "id")}.md"], + "allowed_evidence_ref_prefixes" => ["evidence/", "https://", "http://"], + "raw_provider_output_allowed" => false, + "fields_to_complete" => ["collector", "collected_at", "evidence_files"], + "shadow" => shadow_requirement(entry), + "no_write_flags" => no_write_flags(entry) + } + end + + defp evidence_kind(%{"side_effect_mode" => @shadow_mode}), do: "shadow_integration" + defp evidence_kind(_entry), do: "real_integration" + + defp shadow_requirement(%{"side_effect_mode" => @shadow_mode} = entry) do + runbook_shadow = Map.get(entry, "shadow_requirements") || %{} + + %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => Map.get(runbook_shadow, "run_id") || "fill-from-shadow-run", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + end + + defp shadow_requirement(_entry), do: nil + + defp no_write_flags(%{"side_effect_mode" => @shadow_mode}) do + %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + end + + defp no_write_flags(_entry), do: nil + + defp non_claim_acknowledgement_requirements(entries) do + Enum.map(entries, fn entry -> + %{ + "provider_matrix_entry_id" => Map.get(entry, "entry_id"), + "non_claims" => Map.get(entry, "non_claims", []), + "fields_to_complete" => ["owner", "acknowledged_at"] + } + end) + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_request.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_request.ex new file mode 100644 index 0000000..aab6504 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_request.ex @@ -0,0 +1,253 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRequest do + @moduledoc """ + Builds bounded provider-evidence request packets from Phase 2 evidence plans. + + The request is an operator/provider-owner handoff artifact. It names the + credentials, auth checks, concrete targets, read-only preflight probes, and + evidence files needed before Phase 4 can be reviewed. It does not read + evidence files, call providers, mutate workflow state, approve production, or + enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + + @schema "coding_pr_delivery.production_evidence_request.v1" + @phase2_schema "coding_pr_delivery.phase2_evidence_plan.v1" + + @type input :: Phase2EvidencePlan.plan() | String.t() | map() + @type result :: {:ok, map()} | {:error, map()} + + @spec build(input(), keyword()) :: result() + def build(input, opts \\ []) + + def build(input, opts) when is_list(opts) do + with {:ok, phase2_plan} <- phase2_plan(input, opts) do + {:ok, evidence_request(phase2_plan)} + end + end + + def build(_input, _opts) do + {:error, invalid([issue("invalid_options", [], "Evidence request options must be a keyword list.")])} + end + + defp phase2_plan(%{"schema" => @phase2_schema} = plan, _opts), do: {:ok, plan} + + defp phase2_plan(plan, opts) do + opts = + opts + |> Keyword.take([:plan_id, :tapd_cnb_shadow_run_id, :linear_cnb_shadow_run_id]) + + Phase2EvidencePlan.build(plan, opts) + end + + defp evidence_request(phase2_plan) do + provider_requests = provider_requests(phase2_plan) + + %{ + "schema" => @schema, + "status" => "blocked_pending_external_evidence", + "phase2_plan_id" => Map.get(phase2_plan, "plan_id"), + "phase2_plan_kind" => Map.get(phase2_plan, "plan_kind"), + "request_authority" => "phase2_provider_evidence_request_only", + "provider_request_count" => length(provider_requests), + "provider_requests" => provider_requests, + "external_input_summary" => external_input_summary(provider_requests), + "required_next_step" => "supply provider credentials and concrete targets, run read-only preflight, then collect and validate provider evidence", + "does_not_collect_live_evidence" => true, + "does_not_read_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true, + "raw_input_included" => false, + "normalized_plan_included" => false + } + end + + defp provider_requests(phase2_plan) do + phase2_plan + |> Map.get("provider_plans", []) + |> Enum.map(&provider_request/1) + end + + defp provider_request(provider_plan) do + preflight_commands = preflight_commands(provider_plan) + evidence_requirements = evidence_requirements(provider_plan) + + %{ + "template" => Map.get(provider_plan, "template"), + "tier" => Map.get(provider_plan, "tier"), + "provider_matrix_entry_ids" => Map.get(provider_plan, "provider_matrix_entry_ids", []), + "tracker_kinds" => Map.get(provider_plan, "tracker_kinds", []), + "repo_provider_kinds" => Map.get(provider_plan, "repo_provider_kinds", []), + "side_effect_modes" => Map.get(provider_plan, "side_effect_modes", []), + "live_evidence_status" => Map.get(provider_plan, "live_evidence_status"), + "required_access" => required_access(preflight_commands), + "read_only_preflight_commands" => preflight_commands, + "evidence_requirements" => evidence_requirements, + "non_claim_acknowledgements" => non_claim_acknowledgements(provider_plan), + "owner_actions" => owner_actions(provider_plan), + "does_not_authorize_production_writes" => true + } + end + + defp preflight_commands(provider_plan) do + provider_plan + |> value_at(["read_only_preflight", "commands"]) + |> case do + commands when is_list(commands) -> + Enum.map(commands, &preflight_command/1) + + _missing -> + [] + end + end + + defp preflight_command(command) do + %{ + "id" => Map.get(command, "id"), + "target" => Map.get(command, "target"), + "provider_kind" => Map.get(command, "provider_kind"), + "command" => Map.get(command, "command"), + "side_effect_mode" => Map.get(command, "side_effect_mode"), + "does_not_write" => Map.get(command, "does_not_write"), + "required_env" => Map.get(command, "required_env", []), + "required_auth" => Map.get(command, "required_auth", []), + "required_targets" => Map.get(command, "required_targets", []), + "required_runtime" => Map.get(command, "required_runtime", []) + } + end + + defp required_access(preflight_commands) do + %{ + "required_env" => unique_flat(preflight_commands, "required_env"), + "required_auth" => unique_flat(preflight_commands, "required_auth"), + "required_targets" => unique_flat(preflight_commands, "required_targets"), + "required_runtime" => unique_flat(preflight_commands, "required_runtime") + } + end + + defp evidence_requirements(provider_plan) do + provider_plan + |> value_at(["evidence_packet_template", "scenario_evidence_requirements"]) + |> case do + requirements when is_list(requirements) -> + Enum.map(requirements, &evidence_requirement/1) + + _missing -> + [] + end + end + + defp evidence_requirement(requirement) do + %{ + "scenario_id" => Map.get(requirement, "scenario_id"), + "scenario_title" => Map.get(requirement, "scenario_title"), + "provider_matrix_entry_id" => Map.get(requirement, "provider_matrix_entry_id"), + "required_evidence_kind" => Map.get(requirement, "required_evidence_kind"), + "required_status" => Map.get(requirement, "required_status"), + "evidence_files" => Map.get(requirement, "evidence_files", []), + "allowed_evidence_ref_prefixes" => Map.get(requirement, "allowed_evidence_ref_prefixes", []), + "raw_provider_output_allowed" => Map.get(requirement, "raw_provider_output_allowed"), + "no_write_flags" => Map.get(requirement, "no_write_flags"), + "shadow" => shadow_summary(Map.get(requirement, "shadow")) + } + end + + defp shadow_summary(shadow) when is_map(shadow) do + %{ + "prefix" => Map.get(shadow, "prefix"), + "run_id" => Map.get(shadow, "run_id"), + "authority" => Map.get(shadow, "authority"), + "canonical_authority" => Map.get(shadow, "canonical_authority"), + "allowed_destinations" => Map.get(shadow, "allowed_destinations", []) + } + end + + defp shadow_summary(_shadow), do: nil + + defp non_claim_acknowledgements(provider_plan) do + provider_plan + |> value_at(["evidence_packet_template", "non_claim_acknowledgement_requirements"]) + |> case do + requirements when is_list(requirements) -> + Enum.map(requirements, fn requirement -> + %{ + "provider_matrix_entry_id" => Map.get(requirement, "provider_matrix_entry_id"), + "non_claims" => Map.get(requirement, "non_claims", []), + "fields_to_complete" => Map.get(requirement, "fields_to_complete", []) + } + end) + + _missing -> + [] + end + end + + defp owner_actions(provider_plan) do + [ + "provide_required_access", + "provide_concrete_targets", + "run_read_only_preflight", + evidence_action(provider_plan), + "validate_completed_evidence_packet" + ] + end + + defp evidence_action(provider_plan) do + if "shadow_no_write" in Map.get(provider_plan, "side_effect_modes", []) do + "collect_shadow_no_write_evidence" + else + "collect_ready_to_land_evidence" + end + end + + defp external_input_summary(provider_requests) do + required_access = + provider_requests + |> Enum.map(&Map.get(&1, "required_access", %{})) + + %{ + "required_env" => unique_flat(required_access, "required_env"), + "required_auth" => unique_flat(required_access, "required_auth"), + "required_targets" => unique_flat(required_access, "required_targets"), + "required_runtime" => unique_flat(required_access, "required_runtime") + } + end + + defp unique_flat(values, field) do + values + |> Enum.flat_map(fn value -> + case Map.get(value, field) do + items when is_list(items) -> items + _missing -> [] + end + end) + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + if is_map(current) and Map.has_key?(current, key) do + {:cont, Map.get(current, key)} + else + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp invalid(errors) do + %{ + code: "coding_pr_delivery_evidence_request_invalid", + message: "Coding PR Delivery production evidence request is invalid.", + errors: errors + } + end + + defp issue(code, path, message, meta \\ %{}) do + %{code: code, path: path, message: message, meta: meta} + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_runbook.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_runbook.ex new file mode 100644 index 0000000..501d76a --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_runbook.ex @@ -0,0 +1,240 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRunbook do + @moduledoc """ + Builds bounded Phase 2 evidence collection runbooks for production claims. + + The runbook is a deterministic projection over an already valid production + claim. It names scenarios and evidence destinations; it does not call tracker, + repo-provider, agent-provider, or storage backends. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Claim + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @schema "coding_pr_delivery.production_evidence_runbook.v1" + @base_scenarios [ + {"review_handoff_success", "Successful review handoff"}, + {"review_handoff_missing_validation_block", "Blocked handoff for missing validation"}, + {"review_handoff_missing_change_proposal_block", "Blocked handoff for missing PR/MR linkage"}, + {"review_handoff_recovered_after_evidence", "Blocked handoff recovered after evidence is recorded"}, + {"stale_head_or_workpad_block_and_recovery", "Stale head or stale Workpad block and recovery"}, + {"provider_outage_block_and_recovery", "Tracker or repo-provider outage block and recovery"}, + {"duplicate_callback_replay_rejection", "Duplicate typed-tool callback or replay rejection"} + ] + @reconciliation_scenarios [ + {"reconciliation_pending_checks", "Pending checks do not move ready-to-land"}, + {"reconciliation_failing_checks", "Failing checks do not move ready-to-land"}, + {"reconciliation_provider_retry", "Provider retry or downgrade is bounded"}, + {"reconciliation_noop_repeat", "Repeated reconciliation is a no-op"}, + {"reconciliation_state_precondition_conflict", "State precondition conflict fails closed"}, + {"reconciliation_queue_drop_behavior", "Queue/drop behavior is observable"}, + {"operator_one_shot_recovery", "Operator one-shot recovery path is recorded"} + ] + + @type result :: {:ok, map()} | {:error, map()} + + @spec build(map()) :: result() + def build(claim) when is_map(claim) do + with {:ok, normalized_claim} <- Claim.validate(claim) do + {:ok, runbook(normalized_claim)} + end + end + + def build(_claim), do: Claim.validate(:invalid) + + defp runbook(claim) do + entries = Map.get(claim, "provider_matrix", []) + governance_packets = Map.get(claim, "production_governance", []) + exceptions = Map.get(claim, "typed_tool_exceptions", []) + + %{ + "schema" => @schema, + "profile_instance_id" => Map.get(claim, "profile_instance_id"), + "claim_authority" => "diagnostic_runbook_only", + "does_not_execute_providers" => true, + "entries" => Enum.map(entries, &entry_runbook(&1, governance_packets, exceptions)) + } + end + + defp entry_runbook(entry, governance_packets, exceptions) do + entry_id = Map.get(entry, "id") + governance = Enum.find(governance_packets, &(value_at(&1, ["provider_matrix_entry_id"]) == entry_id)) || %{} + matching_exceptions = Enum.filter(exceptions, &exception_matches_entry?(&1, entry)) + side_effect_mode = Map.get(entry, "side_effect_mode") + + %{ + "entry_id" => entry_id, + "workflow_profile" => Map.get(entry, "workflow_profile"), + "tracker" => Map.get(entry, "tracker"), + "repo_provider" => Map.get(entry, "repo_provider"), + "agent_provider" => Map.get(entry, "agent_provider"), + "repository_class" => Map.get(entry, "repository_class"), + "side_effect_mode" => side_effect_mode, + "evidence_files" => evidence_files(entry, governance, matching_exceptions), + "typed_tool_inventory" => Map.get(entry, "typed_tool_inventory"), + "topology" => Map.get(entry, "deployment_topology"), + "rollback" => rollback(entry, governance), + "scenario_checklist" => scenarios_for(entry), + "collection_steps" => collection_steps(entry, matching_exceptions), + "shadow_requirements" => shadow_requirements(entry), + "ready_to_land_requirements" => ready_to_land_requirements(entry), + "non_claims" => non_claims(entry) + } + end + + defp evidence_files(entry, governance, exceptions) do + %{ + "provider_matrix" => Map.get(entry, "evidence_files", []), + "production_governance" => Map.get(governance, "evidence_files", []), + "typed_tool_exceptions" => exceptions |> Enum.flat_map(&Map.get(&1, "real_integration_evidence", [])) |> Enum.uniq() + } + end + + defp rollback(entry, governance) do + %{ + "provider_matrix" => Map.get(entry, "rollback"), + "production_governance" => Map.get(governance, "rollback") + } + end + + defp scenarios_for(entry) do + @base_scenarios + |> Kernel.++(mode_scenarios(Map.get(entry, "side_effect_mode"))) + |> Kernel.++(@reconciliation_scenarios) + |> Enum.uniq_by(fn {id, _title} -> id end) + |> Enum.map(fn {id, title} -> + %{ + "id" => id, + "title" => title, + "required" => true, + "evidence_status" => "pending_live_evidence" + } + end) + end + + defp mode_scenarios("ready_to_land_write") do + [ + {"reconciliation_ready_to_land_write", "Ready-to-land write under approved transition readiness gates"} + ] + end + + defp mode_scenarios("shadow_no_write") do + [ + {"shadow_decision_chain", "Shadow/no-write decision chain emits diagnostic-only evidence"}, + {"shadow_isolation", "Shadow output is isolated from canonical authority surfaces"} + ] + end + + defp mode_scenarios("review_handoff_write") do + [ + {"review_handoff_write_boundary", "Review handoff write stays inside approved tracker/repo-provider surfaces"} + ] + end + + defp mode_scenarios(_mode), do: [] + + defp collection_steps(entry, exceptions) do + [ + step("validate_production_claim", "Run ProductionProfile.Claim.validate/1 for this packet."), + step("capture_typed_tool_inventory", "Record exact tracker, repo-core, and repo-provider typed-tool inventory."), + step("capture_governance_packet", "Attach structured-plan durability, retention, tombstone, scrubbing, and rollback evidence."), + step("run_provider_scenarios", "Execute and attach live evidence for every scenario in scenario_checklist."), + step("verify_rollback", "Record rollback owner, gates, and non-destructive rollback behavior."), + step("attach_exceptions", exception_step_text(exceptions)) + ] + |> maybe_append_shadow_step(Map.get(entry, "side_effect_mode")) + |> maybe_append_ready_to_land_step(entry) + end + + defp step(id, title), do: %{"id" => id, "title" => title, "status" => "pending_live_evidence"} + + defp exception_step_text([]), do: "No non-typed tool exception evidence is linked for this provider entry." + + defp exception_step_text(_exceptions) do + "Validate scoped non-typed tool exception evidence and confirm it remains outside raw provider passthrough." + end + + defp maybe_append_shadow_step(steps, "shadow_no_write") do + steps ++ [step("verify_shadow_isolation", "Confirm shadow evidence uses the no-production-write prefix, run id, and diagnostic-only destinations.")] + end + + defp maybe_append_shadow_step(steps, _mode), do: steps + + defp maybe_append_ready_to_land_step(steps, entry) do + if Map.get(entry, "side_effect_mode") == "ready_to_land_write" do + steps ++ [step("verify_transition_readiness_gate", "Confirm transition readiness is required before any ready-to-land write.")] + else + steps + end + end + + defp shadow_requirements(%{"side_effect_mode" => "shadow_no_write"} = entry) do + shadow = Map.get(entry, "shadow", %{}) + + %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => Map.get(shadow, "run_id"), + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + end + + defp shadow_requirements(_entry), do: nil + + defp ready_to_land_requirements(%{"side_effect_mode" => "ready_to_land_write"} = entry) do + gates = Map.get(entry, "structured_plan_gates", %{}) + + %{ + "transition_readiness_required" => Map.get(gates, Gates.transition_readiness_required_gate_key()) == true, + "transition_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + end + + defp ready_to_land_requirements(_entry), do: nil + + defp non_claims(entry) do + topology = Map.get(entry, "deployment_topology", %{}) + + case Map.get(topology, "mode") do + "singleton" -> + ["multi_node_ownership", "automatic_durable_replay", "automatic_cold_start_provider_rebuild"] + + "distributed_lock" -> + ["automatic_cold_start_provider_rebuild_unless_evidenced"] + + "external_queue" -> + ["automatic_cold_start_provider_rebuild_unless_evidenced"] + + _mode -> + ["unvalidated_topology_claims"] + end + end + + defp exception_matches_entry?(exception, entry) when is_map(exception) and is_map(entry) do + value_at(exception, ["workflow_profile"]) == Map.get(entry, "workflow_profile") and + value_at(exception, ["tracker", "kind"]) == value_at(entry, ["tracker", "kind"]) and + value_at(exception, ["repo_provider", "kind"]) == value_at(entry, ["repo_provider", "kind"]) and + value_at(exception, ["agent_provider", "kind"]) == value_at(entry, ["agent_provider", "kind"]) and + value_at(exception, ["repository_class"]) == Map.get(entry, "repository_class") + end + + defp exception_matches_entry?(_exception, _entry), do: false + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_decision.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_decision.ex new file mode 100644 index 0000000..2d5ed39 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_decision.ex @@ -0,0 +1,137 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationDecision do + @moduledoc """ + Builds bounded reviewer-facing decisions from observation status records. + + The decision summarizes whether post-apply observation is passed, failed, in + progress, or blocked. It intentionally omits raw evidence payloads and does + not inspect providers, mutate workflow state, apply settings, or enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatus + + @schema "coding_pr_delivery.production_observation_decision.v1" + + @spec build(map()) :: {:ok, map()} + def build(status) when is_map(status) do + case ObservationStatus.validate(status) do + {:ok, observation_status} -> + {:ok, decision(observation_status)} + + {:error, reason} -> + {:ok, blocked_decision(status, reason)} + end + end + + def build(status) do + {:ok, + %{ + "schema" => @schema, + "status" => "blocked", + "observation_status_id" => nil, + "profile_instance_id" => nil, + "review_packet_id" => nil, + "enablement_request_id" => nil, + "apply_record_id" => nil, + "criteria_summary" => %{"passed" => 0, "failed" => 0, "in_progress" => 0, "total" => 0}, + "no_write_observation" => nil, + "blockers" => [blocker(%{code: "invalid_type", path: [], message: "Observation status must be an object."})], + "records_observation_only" => true, + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false, + "input_type" => inspect(status) + }} + end + + defp decision(%{"status" => status} = observation_status) do + %{ + "schema" => @schema, + "status" => decision_status(status), + "observation_status_id" => Map.get(observation_status, "observation_status_id"), + "profile_instance_id" => Map.get(observation_status, "profile_instance_id"), + "review_packet_id" => Map.get(observation_status, "review_packet_id"), + "enablement_request_id" => Map.get(observation_status, "enablement_request_id"), + "apply_record_id" => Map.get(observation_status, "apply_record_id"), + "observed_by" => Map.get(observation_status, "observed_by"), + "observed_at" => Map.get(observation_status, "observed_at"), + "criteria_summary" => criteria_summary(observation_status), + "no_write_observation" => Map.get(observation_status, "no_write_observation"), + "blockers" => [], + "records_observation_only" => true, + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false + } + end + + defp blocked_decision(status, reason) do + %{ + "schema" => @schema, + "status" => "blocked", + "observation_status_id" => value_at(status, ["observation_status_id"]), + "profile_instance_id" => value_at(status, ["operator_apply_record", "operator_apply_plan", "profile_instance_id"]), + "review_packet_id" => value_at(status, ["operator_apply_record", "operator_apply_plan", "review_packet_id"]), + "enablement_request_id" => value_at(status, ["operator_apply_record", "operator_apply_plan", "enablement_request_id"]), + "apply_record_id" => value_at(status, ["operator_apply_record", "apply_record_id"]), + "criteria_summary" => criteria_summary(status), + "no_write_observation" => value_at(status, ["no_write_observation"]), + "blockers" => blockers(reason), + "records_observation_only" => true, + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false + } + end + + defp decision_status("passed"), do: "observation_passed" + defp decision_status("failed"), do: "observation_failed" + defp decision_status("in_progress"), do: "observation_in_progress" + defp decision_status(_status), do: "blocked" + + defp criteria_summary(status) do + statuses = + status + |> value_at(["criteria_results"]) + |> case do + results when is_list(results) -> + results + |> Enum.filter(&is_map/1) + |> Enum.map(&value_at(&1, ["status"])) + |> Enum.filter(&is_binary/1) + + _missing -> + [] + end + + %{ + "passed" => Enum.count(statuses, &(&1 == "passed")), + "failed" => Enum.count(statuses, &(&1 == "failed")), + "in_progress" => Enum.count(statuses, &(&1 == "in_progress")), + "total" => length(statuses) + } + end + + defp blockers(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &blocker/1) + + defp blocker(error) when is_map(error) do + %{ + "code" => Map.get(error, :code) || Map.get(error, "code") || "invalid", + "path" => Map.get(error, :path) || Map.get(error, "path") || [], + "message" => Map.get(error, :message) || Map.get(error, "message") || "Observation status is invalid." + } + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status.ex new file mode 100644 index 0000000..49f3592 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status.ex @@ -0,0 +1,381 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatus do + @moduledoc """ + Admission checks for post-apply observation-window status records. + + The status record proves that the approved observation window is being + tracked after an external operator apply. It validates bounded observation + metadata only; it does not inspect providers, mutate workflow state, apply + settings, or enable production gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecord + + @schema "coding_pr_delivery.production_observation_status.v1" + @error_code "coding_pr_delivery_observation_status_invalid" + @statuses ["in_progress", "passed", "failed"] + @criterion_statuses ["in_progress", "passed", "failed"] + @placeholder_tokens ["fill-", "TODO", "REPLACE", "<", ">"] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate(map()) :: validation_result() + def validate(status) when is_map(status) do + record_result = status |> value_at(["operator_apply_record"]) |> OperatorApplyRecord.validate() + apply_record = normalized_apply_record(record_result) + + errors = + [] + |> collect_required_string(status, ["observation_status_id"]) + |> collect_required_string(status, ["observed_by"]) + |> collect_observed_at(status) + |> collect_status(status) + |> collect_nested_errors(record_result, ["operator_apply_record"]) + |> collect_observation_window(status, apply_record) + |> collect_criteria(status, apply_record) + |> collect_no_write_observation(status, apply_record) + |> collect_status_consistency(status) + + if errors == [] do + {:ok, normalize(status, apply_record)} + else + {:error, invalid(errors)} + end + end + + def validate(_status) do + {:error, invalid([issue("invalid_type", [], "Observation status must be an object.")])} + end + + defp normalized_apply_record({:ok, record}), do: record + defp normalized_apply_record(_result), do: %{} + + defp collect_observed_at(errors, status) do + observed_at = value_at(status, ["observed_at"]) + + errors + |> collect_required_string(status, ["observed_at"]) + |> maybe_add( + non_empty_string?(observed_at) and not valid_datetime?(observed_at), + issue("invalid_timestamp", ["observed_at"], "Observed-at timestamp must be ISO8601.") + ) + end + + defp collect_status(errors, status) do + observed_status = value_at(status, ["status"]) + + errors + |> collect_required_string(status, ["status"]) + |> maybe_add( + non_empty_string?(observed_status) and observed_status not in @statuses, + issue("invalid_observation_status", ["status"], "Observation status is unsupported.", %{allowed_values: @statuses}) + ) + end + + defp collect_observation_window(errors, status, apply_record) do + window = value_at(status, ["observation_window"]) + expected_window = value_at(apply_record, ["observation_start", "observation_window"]) + + errors + |> collect_required_map(status, ["observation_window"]) + |> maybe_add( + is_map(window) and expected_window != nil and window != expected_window, + issue("observation_window_mismatch", ["observation_window"], "Observation status window must match the approved apply record.") + ) + end + + defp collect_criteria(errors, status, apply_record) do + results = value_at(status, ["criteria_results"]) + expected = value_at(apply_record, ["observation_start", "observation_window", "success_criteria"]) + + cond do + not is_list(results) or results == [] -> + errors ++ [issue("required_field_missing", ["criteria_results"], "Criteria results must be a non-empty array.")] + + true -> + errors ++ + criteria_result_errors(results) ++ + criteria_coverage_errors(results, expected) + end + end + + defp criteria_result_errors(results) do + results + |> Enum.with_index() + |> Enum.flat_map(fn {result, index} -> criteria_result_errors(result, index) end) + end + + defp criteria_result_errors(result, index) when is_map(result) do + path = ["criteria_results", index] + observed_at = value_at(result, ["observed_at"]) + status = value_at(result, ["status"]) + + [] + |> collect_required_string(result, path ++ ["criterion"]) + |> collect_required_string(result, path ++ ["status"]) + |> maybe_add( + non_empty_string?(status) and status not in @criterion_statuses, + issue("invalid_criterion_status", path ++ ["status"], "Criterion status is unsupported.", %{allowed_values: @criterion_statuses}) + ) + |> collect_required_string(result, path ++ ["observed_at"]) + |> maybe_add( + non_empty_string?(observed_at) and not valid_datetime?(observed_at), + issue("invalid_timestamp", path ++ ["observed_at"], "Criterion observed-at timestamp must be ISO8601.") + ) + |> collect_string_list(result, path ++ ["evidence_files"], "Criterion evidence files must be a non-empty string array.") + |> collect_evidence_files(result, path ++ ["evidence_files"]) + end + + defp criteria_result_errors(_result, index) do + [issue("invalid_type", ["criteria_results", index], "Criteria result must be an object.")] + end + + defp criteria_coverage_errors(results, expected) when is_list(expected) do + observed = + results + |> Enum.filter(&is_map/1) + |> Enum.map(&value_at(&1, ["criterion"])) + |> Enum.filter(&non_empty_string?/1) + + observed_counts = Enum.frequencies(observed) + + missing = + expected + |> Enum.reject(&(&1 in observed)) + |> Enum.map(fn criterion -> + issue("missing_criterion_result", ["criteria_results"], "Every observation success criterion must have one result.", %{criterion: criterion}) + end) + + duplicate = + observed_counts + |> Enum.filter(fn {_criterion, count} -> count > 1 end) + |> Enum.map(fn {criterion, _count} -> + issue("duplicate_criterion_result", ["criteria_results"], "Observation criterion must not be recorded more than once.", %{criterion: criterion}) + end) + + unexpected = + observed + |> Enum.reject(&(&1 in expected)) + |> Enum.uniq() + |> Enum.map(fn criterion -> + issue("unexpected_criterion_result", ["criteria_results"], "Observation criterion is outside the approved observation window.", %{criterion: criterion}) + end) + + missing ++ duplicate ++ unexpected + end + + defp criteria_coverage_errors(_results, _expected), do: [] + + defp collect_no_write_observation(errors, status, apply_record) do + case value_at(apply_record, ["applied_scope", "side_effect_mode"]) do + "shadow_no_write" -> + no_write = value_at(status, ["no_write_observation"]) + + errors + |> collect_required_map(status, ["no_write_observation"]) + |> maybe_add( + value_at(no_write, ["production_write_performed"]) != false, + issue("shadow_production_write", ["no_write_observation", "production_write_performed"], "Shadow observation must not record production writes.") + ) + |> maybe_add( + value_at(no_write, ["canonical_surface_mutated"]) != false, + issue("shadow_canonical_surface_mutated", ["no_write_observation", "canonical_surface_mutated"], "Shadow observation must not mutate canonical surfaces.") + ) + + _mode -> + errors + end + end + + defp collect_status_consistency(errors, status) do + observed_status = value_at(status, ["status"]) + criterion_statuses = criterion_statuses(status) + + cond do + observed_status not in @statuses -> + errors + + criterion_statuses == [] -> + errors + + not Enum.all?(criterion_statuses, &(&1 in @criterion_statuses)) -> + errors + + derived_status(criterion_statuses) != observed_status -> + errors ++ + [ + issue("observation_status_mismatch", ["status"], "Observation status must match criterion results.", %{ + expected_status: derived_status(criterion_statuses) + }) + ] + + true -> + errors + end + end + + defp criterion_statuses(status) do + status + |> value_at(["criteria_results"]) + |> case do + results when is_list(results) -> + results + |> Enum.filter(&is_map/1) + |> Enum.map(&value_at(&1, ["status"])) + |> Enum.filter(&non_empty_string?/1) + + _missing -> + [] + end + end + + defp derived_status(statuses) do + cond do + Enum.any?(statuses, &(&1 == "failed")) -> "failed" + Enum.all?(statuses, &(&1 == "passed")) -> "passed" + true -> "in_progress" + end + end + + defp normalize(status, apply_record) do + %{ + "schema" => @schema, + "observation_status_id" => value_at(status, ["observation_status_id"]), + "status" => value_at(status, ["status"]), + "profile_instance_id" => value_at(apply_record, ["profile_instance_id"]), + "review_packet_id" => value_at(apply_record, ["review_packet_id"]), + "enablement_request_id" => value_at(apply_record, ["enablement_request_id"]), + "apply_record_id" => value_at(apply_record, ["apply_record_id"]), + "observed_by" => value_at(status, ["observed_by"]), + "observed_at" => value_at(status, ["observed_at"]), + "observation_window" => value_at(status, ["observation_window"]), + "criteria_results" => value_at(status, ["criteria_results"]), + "no_write_observation" => value_at(status, ["no_write_observation"]), + "records_observation_only" => true, + "does_not_enable_production" => true + } + end + + defp collect_nested_errors(errors, {:ok, _value}, _path), do: errors + + defp collect_nested_errors(errors, {:error, %{errors: nested_errors}}, path) when is_list(nested_errors) do + errors ++ Enum.map(nested_errors, &prefix_error(&1, path)) + end + + defp prefix_error(error, path) do + %{ + code: error_code(error), + path: path ++ error_path(error), + message: error_message(error) + } + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery observation status is invalid.", + errors: errors + } + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, [List.last(path)]) + + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp collect_evidence_files(errors, map, path) do + evidence_files = value_at(map, [List.last(path)]) + + if is_list(evidence_files) do + evidence_files + |> Enum.with_index() + |> Enum.flat_map(fn {evidence_ref, index} -> evidence_ref_errors(evidence_ref, path ++ [index]) end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp evidence_ref_errors(evidence_ref, path) do + [] + |> maybe_add( + non_empty_string?(evidence_ref) and not allowed_evidence_ref?(evidence_ref), + issue("invalid_evidence_ref", path, "Observation evidence references must be repository evidence paths or HTTP(S) links.") + ) + |> maybe_add( + non_empty_string?(evidence_ref) and placeholder_evidence_ref?(evidence_ref), + issue("placeholder_evidence_ref", path, "Observation evidence references must not contain placeholders.") + ) + end + + defp allowed_evidence_ref?(evidence_ref) do + String.starts_with?(evidence_ref, "evidence/") or + String.starts_with?(evidence_ref, "https://") or + String.starts_with?(evidence_ref, "http://") + end + + defp placeholder_evidence_ref?(evidence_ref) do + downcased = String.downcase(evidence_ref) + + String.starts_with?(downcased, "/tmp/") or + String.starts_with?(downcased, "tmp/") or + String.starts_with?(downcased, "/var/") or + String.starts_with?(downcased, "file://") or + Enum.any?(@placeholder_tokens, &String.contains?(downcased, String.downcase(&1))) + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message, extra \\ %{}) do + Map.merge( + %{ + code: code, + path: path, + message: message + }, + extra + ) + end + + defp error_code(error), do: Map.get(error, :code) || Map.get(error, "code") || "invalid" + defp error_path(error), do: Map.get(error, :path) || Map.get(error, "path") || [] + defp error_message(error), do: Map.get(error, :message) || Map.get(error, "message") || "Observation status is invalid." + + defp valid_datetime?(value) when is_binary(value) do + match?({:ok, _datetime, _offset}, DateTime.from_iso8601(value)) + end + + defp valid_datetime?(_value), do: false + + defp string_list?(value) when is_list(value), do: Enum.all?(value, &non_empty_string?/1) + defp string_list?(_value), do: false + + defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != "" + defp non_empty_string?(_value), do: false + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_template.ex new file mode 100644 index 0000000..c29b673 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_template.ex @@ -0,0 +1,136 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatusTemplate do + @moduledoc """ + Builds observation-status fill templates from accepted operator apply records. + + The template is a shape-only handoff for post-apply observation tracking. It + validates the operator apply record first, then projects the status fields + operators must complete during the approved observation window. It does not + inspect providers, mutate workflow state, apply settings, or enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecord + + @schema "coding_pr_delivery.production_observation_status_template.v1" + @completed_packet_schema "coding_pr_delivery.production_observation_status.v1" + @status_options ["in_progress", "passed", "failed"] + @allowed_evidence_ref_prefixes ["evidence/", "https://", "http://"] + + @type result :: {:ok, map()} | {:error, map()} + + @spec build(map()) :: result() + def build(apply_record) when is_map(apply_record) do + with {:ok, normalized_record} <- OperatorApplyRecord.validate(apply_record) do + {:ok, template(apply_record, normalized_record)} + end + end + + def build(apply_record), do: OperatorApplyRecord.validate(apply_record) + + defp template(apply_record, normalized_record) do + observation_window = value_at(normalized_record, ["observation_start", "observation_window"]) + + %{ + "schema" => @schema, + "completed_packet_schema" => @completed_packet_schema, + "profile_instance_id" => value_at(normalized_record, ["profile_instance_id"]), + "review_packet_id" => value_at(normalized_record, ["review_packet_id"]), + "enablement_request_id" => value_at(normalized_record, ["enablement_request_id"]), + "apply_record_id" => value_at(normalized_record, ["apply_record_id"]), + "template_authority" => "observation_status_shape_only", + "records_observation_only" => true, + "does_not_enable_production" => true, + "status_options" => @status_options, + "allowed_evidence_ref_prefixes" => @allowed_evidence_ref_prefixes, + "observation_status_field_template" => %{ + "observation_status_id" => "fill-observation-status-id", + "operator_apply_record" => apply_record, + "observed_by" => "fill-observer", + "observed_at" => "fill-observed-at", + "status" => "in_progress", + "observation_window" => observation_window, + "criteria_results" => criteria_results(observation_window), + "no_write_observation" => no_write_observation(normalized_record) + }, + "fields_to_complete" => fields_to_complete(normalized_record) + } + end + + defp criteria_results(%{"success_criteria" => criteria}) when is_list(criteria) do + Enum.map(criteria, fn criterion -> + %{ + "criterion" => criterion, + "status" => "in_progress", + "observed_at" => "fill-observed-at", + "allowed_evidence_ref_prefixes" => @allowed_evidence_ref_prefixes, + "evidence_files" => [evidence_file(criterion)] + } + end) + end + + defp criteria_results(_window), do: [] + + defp evidence_file(criterion) do + "evidence/observation/#{criterion_slug(criterion)}.md" + end + + defp criterion_slug(criterion) when is_binary(criterion) do + criterion + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/, "-") + |> String.trim("-") + |> case do + "" -> "criterion" + slug -> slug + end + end + + defp criterion_slug(_criterion), do: "criterion" + + defp no_write_observation(%{"applied_scope" => %{"side_effect_mode" => "shadow_no_write"}}) do + %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + end + + defp no_write_observation(_normalized_record), do: nil + + defp fields_to_complete(normalized_record) do + base = [ + "observation_status_id", + "observed_by", + "observed_at", + "status", + "criteria_results[].status", + "criteria_results[].observed_at", + "criteria_results[].evidence_files" + ] + + if value_at(normalized_record, ["applied_scope", "side_effect_mode"]) == "shadow_no_write" do + base ++ + [ + "no_write_observation.production_write_performed", + "no_write_observation.canonical_surface_mutated" + ] + else + base + end + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_plan.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_plan.ex new file mode 100644 index 0000000..c3cf49e --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_plan.ex @@ -0,0 +1,162 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyPlan do + @moduledoc """ + Builds a bounded operator apply plan for accepted enablement requests. + + The plan is an instruction projection for human operators. It names the + reviewed scope, gate values, observation window, and rollback checks that must + be applied outside this validator. It never writes configuration, calls + providers, mutates workflow state, or enables production gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequest + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @schema "coding_pr_delivery.production_operator_apply_plan.v1" + + @spec build(map()) :: {:ok, map()} + def build(request) when is_map(request) do + case EnablementRequest.validate(request) do + {:ok, normalized_request} -> + {:ok, ready_plan(normalized_request)} + + {:error, reason} -> + {:ok, blocked_plan(request, reason)} + end + end + + def build(request) do + {:ok, + %{ + "schema" => @schema, + "status" => "blocked", + "enablement_request_id" => nil, + "profile_instance_id" => nil, + "review_packet_id" => nil, + "operator_steps" => [], + "rollback_steps" => [], + "blockers" => [blocker(%{code: "invalid_type", path: [], message: "Enablement request must be an object."})], + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false, + "input_type" => inspect(request) + }} + end + + defp ready_plan(request) do + %{ + "schema" => @schema, + "status" => "ready_for_operator_apply", + "enablement_request_id" => Map.get(request, "enablement_request_id"), + "profile_instance_id" => Map.get(request, "profile_instance_id"), + "review_packet_id" => Map.get(request, "review_packet_id"), + "scope" => Map.get(request, "scope"), + "gate_values" => Map.get(request, "gate_values"), + "observation_window" => Map.get(request, "observation_window"), + "activation_control" => Map.get(request, "activation_control"), + "operator_steps" => operator_steps(request), + "rollback_steps" => rollback_steps(request), + "blockers" => [], + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false + } + end + + defp blocked_plan(request, reason) do + %{ + "schema" => @schema, + "status" => "blocked", + "enablement_request_id" => value_at(request, ["enablement_request_id"]), + "profile_instance_id" => value_at(request, ["review_decision", "profile_instance_id"]), + "review_packet_id" => value_at(request, ["review_decision", "review_packet_id"]), + "operator_steps" => [], + "rollback_steps" => [], + "blockers" => blockers(reason), + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false + } + end + + defp operator_steps(request) do + [ + %{ + "id" => "confirm_change_ticket", + "title" => "Confirm change ticket and operator ownership.", + "status" => "pending_operator_apply", + "change_ticket" => value_at(request, ["activation_control", "change_ticket"]) + }, + %{ + "id" => "verify_scope", + "title" => "Verify provider entries, repositories, environment, and side-effect mode.", + "status" => "pending_operator_apply", + "scope" => Map.get(request, "scope") + }, + %{ + "id" => "apply_gate_values", + "title" => "Apply reviewed gate values through the production configuration path.", + "status" => "pending_operator_apply", + "gate_values" => Map.get(request, "gate_values") + }, + %{ + "id" => "start_observation_window", + "title" => "Start the approved observation window and success criteria tracking.", + "status" => "pending_operator_apply", + "observation_window" => Map.get(request, "observation_window") + }, + %{ + "id" => "record_operator_apply", + "title" => "Record operator confirmation after the external apply completes.", + "status" => "pending_operator_apply", + "requires_operator_confirmation" => true + } + ] + end + + defp rollback_steps(request) do + rollback = Map.get(request, "rollback", %{}) + + [ + %{ + "id" => "disable_transition_readiness", + "title" => "Disable the external transition readiness gate if rollback is needed.", + "status" => "available_before_apply", + "gate" => Gates.transition_readiness_required_gate_key(), + "owner" => Map.get(rollback, "owner") + }, + %{ + "id" => "disable_configured_gates", + "title" => "Disable every reviewed rollback gate.", + "status" => "available_before_apply", + "disable_gates" => Map.get(rollback, "disable_gates", []) + } + ] + end + + defp blockers(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &blocker/1) + + defp blocker(error) when is_map(error) do + %{ + "code" => Map.get(error, :code) || Map.get(error, "code") || "invalid", + "path" => Map.get(error, :path) || Map.get(error, "path") || [], + "message" => Map.get(error, :message) || Map.get(error, "message") || "Enablement request is invalid." + } + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record.ex new file mode 100644 index 0000000..a258043 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record.ex @@ -0,0 +1,373 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecord do + @moduledoc """ + Admission checks for external operator apply records. + + The record proves that a human operator applied the previously reviewed apply + plan through an external configuration path. This module validates the record + metadata only; it does not apply settings, call providers, mutate workflow + state, or enable production gates. + """ + + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @schema "coding_pr_delivery.production_operator_apply_record.v1" + @error_code "coding_pr_delivery_operator_apply_record_invalid" + @plan_schema "coding_pr_delivery.production_operator_apply_plan.v1" + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate(map()) :: validation_result() + def validate(record) when is_map(record) do + plan = value_at(record, ["operator_apply_plan"]) + metadata = value_at(record, ["apply_metadata"]) + + errors = + [] + |> collect_required_string(record, ["apply_record_id"]) + |> collect_plan(plan) + |> collect_apply_metadata(metadata, plan) + |> collect_applied_scope(record, plan) + |> collect_applied_gate_values(record, plan) + |> collect_completed_steps(record, plan) + |> collect_rollback_readiness(record, plan) + |> collect_observation_start(record, plan) + + if errors == [] do + {:ok, normalize(record, plan)} + else + {:error, invalid(errors)} + end + end + + def validate(_record) do + {:error, invalid([issue("invalid_type", [], "Operator apply record must be an object.")])} + end + + defp collect_plan(errors, plan) when is_map(plan) do + blockers = value_at(plan, ["blockers"]) + + errors + |> maybe_add( + value_at(plan, ["schema"]) != @plan_schema, + issue("invalid_operator_apply_plan_schema", ["operator_apply_plan", "schema"], "Operator apply plan schema is invalid.") + ) + |> maybe_add( + value_at(plan, ["status"]) != "ready_for_operator_apply", + issue("operator_apply_plan_not_ready", ["operator_apply_plan", "status"], "Operator apply record requires a ready apply plan.") + ) + |> maybe_add( + blockers != [], + issue("operator_apply_plan_blocked", ["operator_apply_plan", "blockers"], "Operator apply record requires an apply plan with no blockers.") + ) + |> maybe_add( + value_at(plan, ["does_not_apply_settings"]) != true, + issue("operator_apply_plan_side_effect_boundary", ["operator_apply_plan", "does_not_apply_settings"], "Apply plan must be non-mutating.") + ) + |> maybe_add( + value_at(plan, ["requires_operator_confirmation"]) != true, + issue("operator_confirmation_not_required", ["operator_apply_plan", "requires_operator_confirmation"], "Apply plan must require operator confirmation.") + ) + |> maybe_add( + value_at(plan, ["can_apply_automatically"]) != false, + issue("automatic_apply_allowed", ["operator_apply_plan", "can_apply_automatically"], "Apply plan must not allow automatic apply.") + ) + end + + defp collect_plan(errors, _plan) do + errors ++ [issue("required_field_missing", ["operator_apply_plan"], "Operator apply plan must be an object.")] + end + + defp collect_apply_metadata(errors, metadata, plan) when is_map(metadata) do + applied_at = value_at(metadata, ["applied_at"]) + expected_ticket = value_at(plan, ["activation_control", "change_ticket"]) + + errors + |> collect_required_string(metadata, ["apply_metadata", "applied_by"]) + |> collect_required_string(metadata, ["apply_metadata", "applied_at"]) + |> collect_required_string(metadata, ["apply_metadata", "change_ticket"]) + |> maybe_add( + non_empty_string?(applied_at) and not valid_datetime?(applied_at), + issue("invalid_timestamp", ["apply_metadata", "applied_at"], "Applied-at timestamp must be ISO8601.") + ) + |> maybe_add( + value_at(metadata, ["change_ticket"]) != expected_ticket, + issue("change_ticket_mismatch", ["apply_metadata", "change_ticket"], "Apply record change ticket must match the apply plan.") + ) + |> maybe_add( + value_at(metadata, ["operator_confirmation"]) != true, + issue("missing_operator_confirmation", ["apply_metadata", "operator_confirmation"], "Operator apply record must include explicit confirmation.") + ) + |> maybe_add( + value_at(metadata, ["automatic_apply"]) != false, + issue("automatic_apply", ["apply_metadata", "automatic_apply"], "Operator apply record must not be automatic.") + ) + end + + defp collect_apply_metadata(errors, _metadata, _plan) do + errors ++ [issue("required_field_missing", ["apply_metadata"], "Apply metadata must be an object.")] + end + + defp collect_applied_scope(errors, record, plan) do + applied_scope = value_at(record, ["applied_scope"]) + expected_scope = value_at(plan, ["scope"]) + + errors + |> collect_required_map(record, ["applied_scope"]) + |> maybe_add( + is_map(applied_scope) and applied_scope != expected_scope, + issue("applied_scope_mismatch", ["applied_scope"], "Applied scope must match the reviewed apply plan scope.") + ) + end + + defp collect_applied_gate_values(errors, record, plan) do + gate_values = value_at(record, ["applied_gate_values"]) + expected_gate_values = value_at(plan, ["gate_values"]) + transition_gate = Gates.transition_readiness_required_gate_key() + + errors + |> collect_required_map(record, ["applied_gate_values"]) + |> maybe_add( + is_map(gate_values) and Map.has_key?(gate_values, "review_handoff_required"), + issue("legacy_gate_name", ["applied_gate_values", "review_handoff_required"], "Legacy review_handoff_required gate must not be applied.") + ) + |> maybe_add( + is_map(gate_values) and not Map.has_key?(gate_values, transition_gate), + issue("missing_transition_readiness_gate", ["applied_gate_values"], "Applied gate values must include the external transition readiness gate.") + ) + |> maybe_add( + is_map(gate_values) and gate_values != expected_gate_values, + issue("applied_gate_values_mismatch", ["applied_gate_values"], "Applied gate values must match the reviewed apply plan.") + ) + end + + defp collect_completed_steps(errors, record, plan) do + steps = value_at(record, ["completed_operator_steps"]) + + cond do + not is_list(steps) or steps == [] -> + errors ++ [issue("required_field_missing", ["completed_operator_steps"], "Completed operator steps must be a non-empty array.")] + + true -> + expected_ids = plan |> value_at(["operator_steps"]) |> expected_step_ids() + + errors ++ + completed_step_errors(steps) ++ + completed_step_coverage_errors(steps, expected_ids) + end + end + + defp completed_step_errors(steps) do + steps + |> Enum.with_index() + |> Enum.flat_map(fn {step, index} -> completed_step_errors(step, index) end) + end + + defp completed_step_errors(step, index) when is_map(step) do + path = ["completed_operator_steps", index] + completed_at = value_at(step, ["completed_at"]) + + [] + |> collect_required_string(step, path ++ ["id"]) + |> collect_required_string(step, path ++ ["completed_by"]) + |> collect_required_string(step, path ++ ["completed_at"]) + |> maybe_add( + non_empty_string?(completed_at) and not valid_datetime?(completed_at), + issue("invalid_timestamp", path ++ ["completed_at"], "Completed-at timestamp must be ISO8601.") + ) + |> maybe_add( + value_at(step, ["status"]) != "completed", + issue("operator_step_not_completed", path ++ ["status"], "Operator step status must be completed.") + ) + end + + defp completed_step_errors(_step, index) do + [issue("invalid_type", ["completed_operator_steps", index], "Completed operator step must be an object.")] + end + + defp completed_step_coverage_errors(steps, expected_ids) do + observed_ids = + steps + |> Enum.filter(&is_map/1) + |> Enum.map(&value_at(&1, ["id"])) + |> Enum.filter(&non_empty_string?/1) + + observed_counts = Enum.frequencies(observed_ids) + + missing = + expected_ids + |> Enum.reject(&(&1 in observed_ids)) + |> Enum.map(fn step_id -> + issue("missing_operator_step", ["completed_operator_steps"], "Every apply plan operator step must be completed.", %{step_id: step_id}) + end) + + duplicate = + observed_counts + |> Enum.filter(fn {_id, count} -> count > 1 end) + |> Enum.map(fn {step_id, _count} -> + issue("duplicate_operator_step", ["completed_operator_steps"], "Operator step must not be recorded more than once.", %{step_id: step_id}) + end) + + unexpected = + observed_ids + |> Enum.reject(&(&1 in expected_ids)) + |> Enum.map(fn step_id -> + issue("unexpected_operator_step", ["completed_operator_steps"], "Completed operator step must be present in the apply plan.", %{step_id: step_id}) + end) + + missing ++ duplicate ++ unexpected + end + + defp collect_rollback_readiness(errors, record, plan) do + readiness = value_at(record, ["rollback_readiness"]) + expected_gates = value_at(plan, ["rollback_steps"]) |> rollback_gates() + disable_gates = value_at(readiness, ["disable_gates"]) + + errors + |> collect_required_map(record, ["rollback_readiness"]) + |> collect_required_string(readiness, ["rollback_readiness", "owner"]) + |> collect_string_list(readiness, ["rollback_readiness", "disable_gates"], "Rollback readiness disable gates must be a non-empty string array.") + |> maybe_add( + value_at(readiness, ["verified"]) != true, + issue("rollback_not_ready", ["rollback_readiness", "verified"], "Rollback readiness must be verified.") + ) + |> collect_missing_rollback_gates(disable_gates, expected_gates) + end + + defp collect_missing_rollback_gates(errors, disable_gates, expected_gates) when is_list(disable_gates) do + expected_gates + |> Enum.reject(&(&1 in disable_gates)) + |> Enum.map(fn gate -> + issue("missing_rollback_gate", ["rollback_readiness", "disable_gates"], "Rollback readiness must include every apply-plan rollback gate.", %{gate: gate}) + end) + |> then(&(errors ++ &1)) + end + + defp collect_missing_rollback_gates(errors, _disable_gates, _expected_gates), do: errors + + defp collect_observation_start(errors, record, plan) do + observation = value_at(record, ["observation_start"]) + expected_window = value_at(plan, ["observation_window"]) + + errors + |> collect_required_map(record, ["observation_start"]) + |> maybe_add( + value_at(observation, ["started"]) != true, + issue("observation_not_started", ["observation_start", "started"], "Observation window must be started.") + ) + |> maybe_add( + value_at(observation, ["observation_window"]) != expected_window, + issue("observation_window_mismatch", ["observation_start", "observation_window"], "Observation window must match the apply plan.") + ) + end + + defp normalize(record, plan) do + %{ + "schema" => @schema, + "apply_record_id" => value_at(record, ["apply_record_id"]), + "enablement_request_id" => value_at(plan, ["enablement_request_id"]), + "profile_instance_id" => value_at(plan, ["profile_instance_id"]), + "review_packet_id" => value_at(plan, ["review_packet_id"]), + "applied_scope" => value_at(record, ["applied_scope"]), + "applied_gate_values" => value_at(record, ["applied_gate_values"]), + "completed_operator_step_ids" => completed_step_ids(record), + "rollback_readiness" => value_at(record, ["rollback_readiness"]), + "observation_start" => value_at(record, ["observation_start"]), + "does_not_apply_settings" => true, + "records_external_operator_apply" => true + } + end + + defp expected_step_ids(steps) when is_list(steps) do + steps + |> Enum.map(&value_at(&1, ["id"])) + |> Enum.filter(&non_empty_string?/1) + end + + defp expected_step_ids(_steps), do: [] + + defp rollback_gates(steps) when is_list(steps) do + steps + |> Enum.flat_map(fn + %{"gate" => gate} when is_binary(gate) -> [gate] + %{"disable_gates" => gates} when is_list(gates) -> gates + _step -> [] + end) + |> Enum.uniq() + end + + defp rollback_gates(_steps), do: [] + + defp completed_step_ids(record) do + record + |> value_at(["completed_operator_steps"]) + |> case do + steps when is_list(steps) -> Enum.map(steps, &value_at(&1, ["id"])) + _missing -> [] + end + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery operator apply record is invalid.", + errors: errors + } + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, [List.last(path)]) + + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message, extra \\ %{}) do + Map.merge( + %{ + code: code, + path: path, + message: message + }, + extra + ) + end + + defp valid_datetime?(value) when is_binary(value) do + match?({:ok, _datetime, _offset}, DateTime.from_iso8601(value)) + end + + defp valid_datetime?(_value), do: false + + defp string_list?(value) when is_list(value), do: Enum.all?(value, &non_empty_string?/1) + defp string_list?(_value), do: false + + defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != "" + defp non_empty_string?(_value), do: false + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_template.ex new file mode 100644 index 0000000..bd17482 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_template.ex @@ -0,0 +1,198 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecordTemplate do + @moduledoc """ + Builds operator apply-record fill templates from ready operator apply plans. + + The template is a shape-only handoff for the external human apply path. It + validates that the apply plan is ready and non-mutating, then projects the + fields that must be recorded after the operator applies settings outside this + module. It does not apply settings, call providers, mutate workflow state, or + enable production gates. + """ + + @schema "coding_pr_delivery.production_operator_apply_record_template.v1" + @completed_packet_schema "coding_pr_delivery.production_operator_apply_record.v1" + @error_code "coding_pr_delivery_operator_apply_record_template_invalid" + @plan_schema "coding_pr_delivery.production_operator_apply_plan.v1" + + @type result :: {:ok, map()} | {:error, map()} + + @spec build(map()) :: result() + def build(plan) when is_map(plan) do + errors = collect_plan([], plan) + + if errors == [] do + {:ok, template(plan)} + else + {:error, invalid(errors)} + end + end + + def build(_plan) do + {:error, invalid([issue("invalid_type", [], "Operator apply plan must be an object.")])} + end + + defp collect_plan(errors, plan) do + blockers = value_at(plan, ["blockers"]) + + errors + |> maybe_add( + value_at(plan, ["schema"]) != @plan_schema, + issue("invalid_operator_apply_plan_schema", ["operator_apply_plan", "schema"], "Operator apply plan schema is invalid.") + ) + |> maybe_add( + value_at(plan, ["status"]) != "ready_for_operator_apply", + issue("operator_apply_plan_not_ready", ["operator_apply_plan", "status"], "Operator apply record template requires a ready apply plan.") + ) + |> maybe_add( + blockers != [], + issue("operator_apply_plan_blocked", ["operator_apply_plan", "blockers"], "Operator apply record template requires an apply plan with no blockers.") + ) + |> maybe_add( + value_at(plan, ["does_not_apply_settings"]) != true, + issue("operator_apply_plan_side_effect_boundary", ["operator_apply_plan", "does_not_apply_settings"], "Apply plan must be non-mutating.") + ) + |> maybe_add( + value_at(plan, ["requires_operator_confirmation"]) != true, + issue("operator_confirmation_not_required", ["operator_apply_plan", "requires_operator_confirmation"], "Apply plan must require operator confirmation.") + ) + |> maybe_add( + value_at(plan, ["can_apply_automatically"]) != false, + issue("automatic_apply_allowed", ["operator_apply_plan", "can_apply_automatically"], "Apply plan must not allow automatic apply.") + ) + end + + defp template(plan) do + %{ + "schema" => @schema, + "completed_packet_schema" => @completed_packet_schema, + "enablement_request_id" => value_at(plan, ["enablement_request_id"]), + "profile_instance_id" => value_at(plan, ["profile_instance_id"]), + "review_packet_id" => value_at(plan, ["review_packet_id"]), + "template_authority" => "operator_apply_record_shape_only", + "does_not_apply_settings" => true, + "operator_apply_plan" => plan, + "apply_record_field_template" => %{ + "apply_record_id" => "fill-apply-record-id", + "operator_apply_plan" => plan, + "apply_metadata" => %{ + "applied_by" => "fill-operator", + "applied_at" => "fill-applied-at", + "change_ticket" => value_at(plan, ["activation_control", "change_ticket"]), + "operator_confirmation" => true, + "automatic_apply" => false + }, + "applied_scope" => value_at(plan, ["scope"]), + "applied_gate_values" => value_at(plan, ["gate_values"]), + "completed_operator_steps" => completed_operator_steps(plan), + "rollback_readiness" => %{ + "owner" => rollback_owner(plan), + "disable_gates" => rollback_gates(plan), + "verified" => false + }, + "observation_start" => %{ + "started" => false, + "observation_window" => value_at(plan, ["observation_window"]) + } + }, + "fields_to_complete" => [ + "apply_record_id", + "apply_metadata.applied_by", + "apply_metadata.applied_at", + "completed_operator_steps[].completed_by", + "completed_operator_steps[].completed_at", + "rollback_readiness.verified", + "observation_start.started" + ] + } + end + + defp completed_operator_steps(plan) do + plan + |> value_at(["operator_steps"]) + |> case do + steps when is_list(steps) -> + Enum.map(steps, fn step -> + %{ + "id" => value_at(step, ["id"]), + "status" => "completed", + "completed_by" => "fill-operator", + "completed_at" => "fill-completed-at" + } + end) + + _missing -> + [] + end + end + + defp rollback_owner(plan) do + plan + |> value_at(["rollback_steps"]) + |> case do + steps when is_list(steps) -> + steps + |> Enum.map(&value_at(&1, ["owner"])) + |> Enum.find("workflow-runtime", &non_empty_string?/1) + + _missing -> + "workflow-runtime" + end + end + + defp rollback_gates(plan) do + plan + |> value_at(["rollback_steps"]) + |> case do + steps when is_list(steps) -> + steps + |> Enum.flat_map(fn + %{"gate" => gate} when is_binary(gate) -> [gate] + %{"disable_gates" => gates} when is_list(gates) -> gates + _step -> [] + end) + |> Enum.filter(&non_empty_string?/1) + |> Enum.uniq() + + _missing -> + [] + end + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery operator apply record template is invalid.", + errors: errors + } + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message) do + %{ + code: code, + path: path, + message: message + } + end + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_claim_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_claim_template.ex new file mode 100644 index 0000000..df2d3d2 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_claim_template.ex @@ -0,0 +1,296 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2ClaimTemplate do + @moduledoc """ + Builds starter Phase 2 production-claim packets for provider evidence runs. + + Templates are bounded metadata packets for review and evidence planning. The + builder validates each generated packet through `Claim.validate/1`, but it + does not collect live evidence, call providers, mutate workflow state, or + enable production gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Claim + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Profile.Contract, as: ProfileContract + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + @error_code "coding_pr_delivery_phase2_claim_template_invalid" + @template_ids ["reference", "linear_github_ready", "tapd_cnb_shadow", "linear_cnb_shadow"] + @runtime_targeted_discovery "runtime_targeted" + @default_repository_class "single_repo_change_proposal" + @default_agent_provider "configured_agent_provider" + @default_storage_backend "workflow_extension_state_store" + @default_retention_class "workflow_audit_staging" + @default_retention_days 30 + @default_scrubbing_catalog "2026-06-25" + + @type template :: :reference | :linear_github_ready | :tapd_cnb_shadow | :linear_cnb_shadow + @type result :: {:ok, map()} | {:error, map()} + + @spec templates() :: [String.t()] + def templates, do: @template_ids + + @spec build(template() | String.t(), keyword()) :: result() + def build(template, opts \\ []) + + def build(template, opts) when is_list(opts) do + with {:ok, template_id} <- normalize_template(template), + claim = claim_for(template_id, opts), + {:ok, _normalized} <- Claim.validate(claim) do + {:ok, claim} + end + end + + def build(_template, _opts) do + {:error, invalid([issue("invalid_options", [], "Phase 2 claim template options must be a keyword list.")])} + end + + defp normalize_template(template) when is_atom(template), do: normalize_template(Atom.to_string(template)) + + defp normalize_template(template) when is_binary(template) do + template_id = String.trim(template) + + if template_id in @template_ids do + {:ok, template_id} + else + {:error, invalid([issue("unknown_template", ["template"], "Phase 2 claim template is not supported.", %{allowed_values: @template_ids})])} + end + end + + defp normalize_template(_template) do + {:error, invalid([issue("invalid_template", ["template"], "Phase 2 claim template must be a string or atom.", %{allowed_values: @template_ids})])} + end + + defp claim_for(template_id, opts) do + profile_instance_id = profile_instance_id(template_id, opts) + entries = entries_for(template_id, opts) + + %{ + "profile_instance_id" => profile_instance_id, + "provider_matrix" => entries, + "production_governance" => Enum.map(entries, &governance_packet(&1, profile_instance_id, opts)), + "typed_tool_exceptions" => typed_tool_exceptions(template_id, opts) + } + end + + defp entries_for("reference", opts), do: [linear_github_ready_entry(opts), tapd_cnb_shadow_entry(opts)] + defp entries_for("linear_github_ready", opts), do: [linear_github_ready_entry(opts)] + defp entries_for("tapd_cnb_shadow", opts), do: [tapd_cnb_shadow_entry(opts)] + defp entries_for("linear_cnb_shadow", opts), do: [linear_cnb_shadow_entry(opts)] + + defp linear_github_ready_entry(opts) do + base_entry("linear-github-ready", "linear", "github", opts) + |> Map.merge(%{ + "side_effect_mode" => "ready_to_land_write", + "structured_plan_gates" => gates(true), + "deployment_topology" => singleton_topology() + }) + end + + defp tapd_cnb_shadow_entry(opts) do + cnb_shadow_entry("tapd-cnb-shadow", "tapd", opts) + end + + defp linear_cnb_shadow_entry(opts) do + cnb_shadow_entry("linear-cnb-shadow", "linear", opts) + end + + defp cnb_shadow_entry(id, tracker, opts) do + base_entry(id, tracker, "cnb", opts) + |> Map.merge(%{ + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => gates(false), + "deployment_topology" => singleton_topology(), + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => Keyword.get(opts, :shadow_run_id, "#{id}-run-1"), + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + }) + end + + defp base_entry(id, tracker, repo_provider, opts) do + %{ + "id" => id, + "workflow_profile" => workflow_profile(), + "tracker" => %{"kind" => tracker}, + "repo_provider" => %{"kind" => repo_provider}, + "agent_provider" => %{"kind" => Keyword.get(opts, :agent_provider, @default_agent_provider)}, + "repository_class" => Keyword.get(opts, :repository_class, @default_repository_class), + "candidate_discovery" => @runtime_targeted_discovery, + "typed_tool_inventory" => typed_tool_inventory(), + "evidence_files" => ["evidence/provider-matrix/#{id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => Keyword.get(opts, :rollback_owner, "workflow-runtime"), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp typed_tool_inventory do + %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + } + end + + defp governance_packet(%{"id" => entry_id}, profile_instance_id, opts) do + retention_class = Keyword.get(opts, :retention_class, @default_retention_class) + retention_days = Keyword.get(opts, :retention_period_days, @default_retention_days) + + %{ + "provider_matrix_entry_id" => entry_id, + "profile_instance_id" => profile_instance_id, + "durability_profile" => %{ + "storage_backend" => Keyword.get(opts, :storage_backend, @default_storage_backend), + "restart_behavior" => "survives process, worker, and service restarts", + "backup_restore" => "documented backup and restore drill before production enablement", + "retention_class" => retention_class, + "retention_period_days" => retention_days, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "backend_scrubber_before_persist_or_render", + "retention_class" => retention_class, + "retention_period_days" => retention_days, + "cleanup_owner" => Keyword.get(opts, :cleanup_owner, "workflow-runtime"), + "cleanup_schedule" => Keyword.get(opts, :cleanup_schedule, "daily"), + "scrubbing_pipeline" => %{ + "owner" => Keyword.get(opts, :scrubbing_owner, "workflow-runtime-security"), + "pattern_catalog_version" => Keyword.get(opts, :scrubbing_pattern_catalog_version, @default_scrubbing_catalog), + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "coding_pr_delivery_provider_summary", + "coding_pr_delivery_review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Gates.enabled_gate_key(), + "disable_rendering_gate" => Gates.render_workpad_gate_key(), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Gates.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => ["evidence/structured-plan/governance/#{entry_id}.md"] + } + end + + defp typed_tool_exceptions(template_id, opts) when template_id in ["reference", "linear_github_ready"] do + if Keyword.get(opts, :include_linear_github_review_read_exception?, false) do + [linear_github_review_read_exception(opts)] + else + [] + end + end + + defp typed_tool_exceptions(_template_id, _opts), do: [] + + defp linear_github_review_read_exception(opts) do + %{ + "exception_id" => "typed-tool-exception-linear-github-review-read", + "workflow_profile" => workflow_profile(), + "tracker" => %{"kind" => "linear"}, + "repo_provider" => %{"kind" => "github"}, + "agent_provider" => %{"kind" => Keyword.get(opts, :agent_provider, @default_agent_provider)}, + "repository_class" => Keyword.get(opts, :repository_class, @default_repository_class), + "workspace_class" => Keyword.get(opts, :workspace_class, "staging_workspace"), + "route_set" => ["developing", "review"], + "operation_set" => ["repo_provider.review.read"], + "fallback_authority" => %{ + "owner" => Keyword.get(opts, :rollback_owner, "workflow-runtime"), + "authority_kind" => "temporary_backend_adapter", + "accepted_by_profile_owners" => true + }, + "compensating_controls" => ["read_only_operation", "bounded_schema_allowlist"], + "input_schema_allowlist" => %{ + "schema_ids" => ["repo_provider.review.read.v1"], + "rejects_unknown_fields" => true + }, + "limits" => %{"max_calls_per_run" => 3, "max_concurrency" => 1}, + "audit_logging" => %{ + "event_name" => "coding_pr_delivery_typed_tool_exception_used", + "retention_class" => Keyword.get(opts, :retention_class, @default_retention_class) + }, + "deterministic_tests" => [ + "test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception_test.exs" + ], + "real_integration_evidence" => ["evidence/typed-tool-exceptions/linear-github-review-read.md"], + "operator_observability" => %{ + "metrics" => ["typed_tool_exception_used_total"], + "alerts" => ["typed_tool_exception_usage_outside_scope"], + "runbook" => "runbooks/coding-pr-delivery/typed-tool-exception.md" + }, + "rollback" => %{ + "owner" => Keyword.get(opts, :rollback_owner, "workflow-runtime"), + "instructions" => "Remove exception id from production enablement packet and require typed tool inventory.", + "disables_exception" => true, + "restores_typed_tool_requirement" => true + }, + "expires_at" => Keyword.get(opts, :typed_tool_exception_expires_at, "2026-07-25T00:00:00Z") + } + end + + defp profile_instance_id(template_id, opts) do + Keyword.get(opts, :profile_instance_id, "coding_pr_delivery.production.phase2.#{template_id}") + end + + defp workflow_profile do + %{"kind" => ProfileContract.kind(), "version" => ProfileContract.version()} + end + + defp singleton_topology do + %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + } + end + + defp gates(transition_readiness_required) do + %{ + Gates.enabled_gate_key() => true, + Gates.provider_adapters_enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => transition_readiness_required + } + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery Phase 2 claim template is invalid.", + errors: errors + } + end + + defp issue(code, path, message, extra \\ %{}) do + Map.merge( + %{ + code: code, + path: path, + message: message + }, + extra + ) + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_evidence_plan.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_evidence_plan.ex new file mode 100644 index 0000000..a278b11 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_evidence_plan.ex @@ -0,0 +1,283 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan do + @moduledoc """ + Builds bounded Phase 2 evidence plans for provider-matrix candidates. + + The plan is a deterministic aggregation over production-claim templates, + evidence runbooks, and evidence-packet templates. It does not collect live + evidence, read evidence files, call providers, mutate workflow state, or + enable production gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + EvidencePacketTemplate, + EvidenceRunbook, + Phase2ClaimTemplate + } + + @schema "coding_pr_delivery.phase2_evidence_plan.v1" + @error_code "coding_pr_delivery_phase2_evidence_plan_invalid" + @plan_ids ["tiered_reference", "linear_github_ready", "tapd_cnb_shadow", "linear_cnb_shadow"] + @single_template_plans ["linear_github_ready", "tapd_cnb_shadow", "linear_cnb_shadow"] + @tier_by_template %{ + "linear_github_ready" => "tier_1_reference", + "tapd_cnb_shadow" => "tier_2_cnb_shadow", + "linear_cnb_shadow" => "tier_2_cnb_shadow" + } + + @type plan :: :tiered_reference | :linear_github_ready | :tapd_cnb_shadow | :linear_cnb_shadow + @type result :: {:ok, map()} | {:error, map()} + + @spec plans() :: [String.t()] + def plans, do: @plan_ids + + @spec build(plan() | String.t(), keyword()) :: result() + def build(plan, opts \\ []) + + def build(plan, opts) when is_list(opts) do + with {:ok, plan_id} <- normalize_plan(plan), + {:ok, entries} <- build_entries(plan_id, opts) do + {:ok, evidence_plan(plan_id, entries, opts)} + end + end + + def build(_plan, _opts) do + {:error, invalid([issue("invalid_options", [], "Phase 2 evidence plan options must be a keyword list.")])} + end + + defp normalize_plan(plan) when is_atom(plan), do: normalize_plan(Atom.to_string(plan)) + + defp normalize_plan(plan) when is_binary(plan) do + plan_id = String.trim(plan) + + if plan_id in @plan_ids do + {:ok, plan_id} + else + {:error, invalid([issue("unknown_plan", ["plan"], "Phase 2 evidence plan is not supported.", %{allowed_values: @plan_ids})])} + end + end + + defp normalize_plan(_plan) do + {:error, invalid([issue("invalid_plan", ["plan"], "Phase 2 evidence plan must be a string or atom.", %{allowed_values: @plan_ids})])} + end + + defp build_entries("tiered_reference", opts) do + [ + {"tier_1_reference", "linear_github_ready"}, + {"tier_2_cnb_shadow", "tapd_cnb_shadow"}, + {"tier_2_cnb_shadow", "linear_cnb_shadow"} + ] + |> build_template_entries(opts) + end + + defp build_entries(plan_id, opts) when plan_id in @single_template_plans do + build_template_entries([{Map.fetch!(@tier_by_template, plan_id), plan_id}], opts) + end + + defp build_template_entries(entries, opts) do + entries + |> Enum.reduce_while({:ok, []}, fn {tier, template_id}, {:ok, acc} -> + case build_template_entry(tier, template_id, opts) do + {:ok, entry} -> {:cont, {:ok, [entry | acc]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, entries} -> {:ok, Enum.reverse(entries)} + {:error, reason} -> {:error, reason} + end + end + + defp build_template_entry(tier, template_id, opts) do + with {:ok, claim} <- Phase2ClaimTemplate.build(template_id, template_opts(template_id, opts)), + {:ok, runbook} <- EvidenceRunbook.build(claim), + {:ok, evidence_template} <- EvidencePacketTemplate.build(claim) do + {:ok, plan_entry(tier, template_id, claim, runbook, evidence_template)} + else + {:error, reason} -> + {:error, + invalid([ + issue("template_build_failed", ["provider_plans", template_id], "Provider evidence-plan template failed validation.", %{ + template: template_id, + reason: reason + }) + ])} + end + end + + defp template_opts("tapd_cnb_shadow", opts) do + put_shadow_run_id(opts, Keyword.get(opts, :tapd_cnb_shadow_run_id, "tapd-cnb-shadow-run-1")) + end + + defp template_opts("linear_cnb_shadow", opts) do + put_shadow_run_id(opts, Keyword.get(opts, :linear_cnb_shadow_run_id, "linear-cnb-shadow-run-1")) + end + + defp template_opts(_template_id, opts), do: opts + + defp put_shadow_run_id(opts, shadow_run_id) do + opts + |> Keyword.delete(:shadow_run_id) + |> Keyword.put(:shadow_run_id, shadow_run_id) + end + + defp evidence_plan(plan_id, entries, opts) do + %{ + "schema" => @schema, + "plan_id" => Keyword.get(opts, :plan_id, "coding_pr_delivery.phase2.#{plan_id}"), + "plan_kind" => plan_id, + "plan_authority" => "phase2_evidence_planning_only", + "does_not_collect_live_evidence" => true, + "does_not_read_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_enable_production" => true, + "provider_plans" => entries, + "live_evidence_status" => "not_collected", + "required_next_step" => "collect provider evidence and validate a completed evidence packet before Phase 4 review" + } + end + + defp plan_entry(tier, template_id, claim, runbook, evidence_template) do + provider_entries = Map.get(claim, "provider_matrix", []) + runbook_entries = Map.get(runbook, "entries", []) + + %{ + "tier" => tier, + "template" => template_id, + "provider_matrix_entry_ids" => Enum.map(provider_entries, &Map.get(&1, "id")), + "tracker_kinds" => provider_entries |> Enum.map(&value_at(&1, ["tracker", "kind"])) |> Enum.uniq(), + "repo_provider_kinds" => provider_entries |> Enum.map(&value_at(&1, ["repo_provider", "kind"])) |> Enum.uniq(), + "side_effect_modes" => provider_entries |> Enum.map(&Map.get(&1, "side_effect_mode")) |> Enum.uniq(), + "production_claim" => claim, + "evidence_runbook" => runbook, + "evidence_packet_template" => evidence_template, + "scenario_count" => scenario_count(runbook_entries), + "live_evidence_status" => "not_collected", + "read_only_preflight" => read_only_preflight(template_id), + "evidence_packet_required_before_review" => true, + "does_not_collect_live_evidence" => true, + "does_not_enable_production" => true + } + end + + defp read_only_preflight(template_id) do + %{ + "status" => "not_run", + "does_not_collect_live_evidence" => true, + "does_not_mutate_workflow_state" => true, + "does_not_enable_production" => true, + "commands" => preflight_commands(template_id) + } + end + + defp preflight_commands("linear_github_ready") do + [ + tracker_preflight("linear", "linear/github/opencode", ["LINEAR_API_KEY", "LINEAR_PROJECT_SLUG"]), + repo_provider_preflight( + "github", + "mise exec -- mix repo_provider.smoke --provider github --repo --pr --json", + [], + ["gh auth status"], + ["repo_slug", "change_proposal_number"] + ) + ] + end + + defp preflight_commands("tapd_cnb_shadow") do + [ + tracker_preflight("tapd", "tapd/cnb/opencode", ["TAPD_API_USER", "TAPD_API_PASSWORD", "TAPD_WORKSPACE_ID"]), + repo_provider_preflight( + "cnb", + "mise exec -- mix repo_provider.smoke --provider cnb --repo --pr --json", + ["CNB_TOKEN"], + [], + ["repo_slug", "change_proposal_number"] + ) + ] + end + + defp preflight_commands("linear_cnb_shadow") do + [ + tracker_preflight("linear", "linear/github/opencode", ["LINEAR_API_KEY", "LINEAR_PROJECT_SLUG"]), + repo_provider_preflight( + "cnb", + "mise exec -- mix repo_provider.smoke --provider cnb --repo --pr --json", + ["CNB_TOKEN"], + [], + ["repo_slug", "change_proposal_number"] + ) + ] + end + + defp tracker_preflight(provider_kind, template, required_env) do + %{ + "id" => "#{provider_kind}-tracker-read-only-smoke", + "target" => "tracker", + "provider_kind" => provider_kind, + "command" => "mise exec -- mix tracker.smoke --template #{template} --json", + "required_env" => required_env, + "required_auth" => [], + "required_targets" => [], + "required_runtime" => ["provider_read_only_smoke"], + "side_effect_mode" => "read_only", + "requires_write_confirmation" => false, + "does_not_write" => true + } + end + + defp repo_provider_preflight(provider_kind, command, required_env, required_auth, required_targets) do + %{ + "id" => "#{provider_kind}-repo-provider-read-only-smoke", + "target" => "repo_provider", + "provider_kind" => provider_kind, + "command" => command, + "required_env" => required_env, + "required_auth" => required_auth, + "required_targets" => required_targets, + "required_runtime" => ["provider_read_only_smoke"], + "side_effect_mode" => "read_only", + "requires_destructive_flag" => false, + "does_not_write" => true + } + end + + defp scenario_count(runbook_entries) do + runbook_entries + |> Enum.flat_map(&(Map.get(&1, "scenario_checklist", []) || [])) + |> length() + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery Phase 2 evidence plan is invalid.", + errors: errors + } + end + + defp issue(code, path, message, extra \\ %{}) do + %{ + code: code, + path: path, + message: message + } + |> Map.merge(extra) + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase4_review_plan.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase4_review_plan.ex new file mode 100644 index 0000000..ea9d206 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase4_review_plan.ex @@ -0,0 +1,324 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase4ReviewPlan do + @moduledoc """ + Builds bounded Phase 4 review plans from Phase 2 evidence plans. + + The plan summarizes what reviewers can prepare before live provider evidence + exists and why the production decision remains blocked. It does not complete + evidence, read evidence files, call providers, mutate workflow state, approve + production, or enable production gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + @schema "coding_pr_delivery.phase4_review_plan.v1" + @phase2_schema "coding_pr_delivery.phase2_evidence_plan.v1" + @error_code "coding_pr_delivery_phase4_review_plan_invalid" + @required_scrubbing_boundaries [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + @required_authority_boundaries [ + "prompt_wording_authoritative", + "workpad_markdown_authoritative", + "raw_provider_passthrough_authorized", + "schema_support_alone_sufficient" + ] + @source_specs [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md", + "specs/workflow/typed_workflow_tools/conformance_spec.md", + "specs/workflow/profiles/coding_pr_delivery/review_handoff_readiness_policy/conformance_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/conformance_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md", + "specs/workflow/execution_plan_adoption/readiness_spec.md", + "specs/workflow/execution_plan_adoption/production_profile_spec.md" + ] + + @type input :: Phase2EvidencePlan.plan() | String.t() | map() + @type result :: {:ok, map()} | {:error, map()} + + @spec build(input(), keyword()) :: result() + def build(input, opts \\ []) + + def build(input, opts) when is_list(opts) do + with {:ok, phase2_plan} <- phase2_plan(input, opts), + :ok <- validate_phase2_plan(phase2_plan) do + {:ok, review_plan(phase2_plan, opts)} + end + end + + def build(_input, _opts) do + {:error, invalid([issue("invalid_options", [], "Phase 4 review plan options must be a keyword list.")])} + end + + defp phase2_plan(%{"schema" => @phase2_schema} = plan, _opts), do: {:ok, plan} + defp phase2_plan(plan, opts), do: Phase2EvidencePlan.build(plan, opts) + + defp validate_phase2_plan(plan) do + provider_plans = Map.get(plan, "provider_plans") + + cond do + Map.get(plan, "schema") != @phase2_schema -> + {:error, + invalid([ + issue("invalid_phase2_schema", ["phase2_plan", "schema"], "Phase 4 review plans require a Phase 2 evidence plan.") + ])} + + not is_list(provider_plans) or provider_plans == [] -> + {:error, + invalid([ + issue("missing_provider_plans", ["phase2_plan", "provider_plans"], "Phase 2 evidence plan must include provider plans.") + ])} + + true -> + :ok + end + end + + defp review_plan(phase2_plan, opts) do + provider_review_plans = phase2_plan |> Map.get("provider_plans", []) |> Enum.map(&provider_review_plan/1) + + %{ + "schema" => @schema, + "plan_id" => Keyword.get(opts, :plan_id, default_plan_id(phase2_plan)), + "phase2_plan_id" => Map.get(phase2_plan, "plan_id"), + "phase2_plan_kind" => Map.get(phase2_plan, "plan_kind"), + "review_authority" => "phase4_review_planning_only", + "phase4_ready" => false, + "review_decision_status" => "blocked", + "does_not_collect_live_evidence" => true, + "does_not_read_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true, + "provider_review_plans" => provider_review_plans, + "review_packet_requirements" => review_packet_requirements(provider_review_plans), + "blocking_requirements" => blocking_requirements(provider_review_plans), + "explicit_non_claims" => explicit_non_claims() + } + end + + defp default_plan_id(phase2_plan) do + phase2_id = Map.get(phase2_plan, "plan_id") || "coding_pr_delivery.phase2.unknown" + "#{phase2_id}.phase4_review" + end + + defp provider_review_plan(provider_plan) do + evidence_template = Map.get(provider_plan, "evidence_packet_template", %{}) + requirements = Map.get(evidence_template, "scenario_evidence_requirements", []) + provider_entry_ids = Map.get(provider_plan, "provider_matrix_entry_ids", []) + + %{ + "tier" => Map.get(provider_plan, "tier"), + "template" => Map.get(provider_plan, "template"), + "provider_matrix_entry_ids" => provider_entry_ids, + "tracker_kinds" => Map.get(provider_plan, "tracker_kinds", []), + "repo_provider_kinds" => Map.get(provider_plan, "repo_provider_kinds", []), + "side_effect_modes" => Map.get(provider_plan, "side_effect_modes", []), + "live_evidence_status" => Map.get(provider_plan, "live_evidence_status", "not_collected"), + "scenario_count" => Map.get(provider_plan, "scenario_count", 0), + "required_evidence_kinds" => required_evidence_kinds(requirements), + "required_evidence_files" => required_evidence_files(requirements), + "read_only_preflight" => Map.get(provider_plan, "read_only_preflight"), + "shadow" => shadow_summary(requirements), + "non_claims" => non_claims(provider_plan), + "preflight_report_required_before_evidence" => true, + "review_packet_blocked_until_completed_evidence" => true, + "evidence_packet_required_before_review" => true + } + end + + defp required_evidence_kinds(requirements) do + requirements + |> Enum.map(&Map.get(&1, "required_evidence_kind")) + |> unique_strings() + end + + defp required_evidence_files(requirements) do + requirements + |> Enum.flat_map(fn requirement -> + case Map.get(requirement, "evidence_files") do + files when is_list(files) -> files + _missing -> [] + end + end) + |> unique_strings() + end + + defp shadow_summary(requirements) do + requirements + |> Enum.map(&Map.get(&1, "shadow")) + |> Enum.find(&is_map/1) + |> case do + nil -> + nil + + shadow -> + %{ + "prefix" => Map.get(shadow, "prefix"), + "run_id" => Map.get(shadow, "run_id"), + "authority" => Map.get(shadow, "authority"), + "canonical_authority" => Map.get(shadow, "canonical_authority"), + "allowed_destinations" => Map.get(shadow, "allowed_destinations", []) + } + end + end + + defp non_claims(provider_plan) do + provider_plan + |> Map.get("evidence_runbook", %{}) + |> Map.get("entries", []) + |> Enum.flat_map(&Map.get(&1, "non_claims", [])) + |> unique_strings() + end + + defp review_packet_requirements(provider_review_plans) do + %{ + "changed_source_specs" => @source_specs, + "implementation_refs" => ["fill-implementation-pr-or-local-patch-ref"], + "deterministic_test_matrix" => ["fill-deterministic-test-matrix"], + "provider_preflight_reports" => provider_preflight_reports(provider_review_plans), + "completed_evidence_packets" => completed_evidence_packets(provider_review_plans), + "rollback_instructions" => %{ + "external_transition_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "legacy_review_handoff_required_mapping" => true, + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key(), + Gates.render_workpad_gate_key(), + Gates.provider_adapters_enabled_gate_key() + ] + }, + "scrubbing_pipeline" => %{ + "owner" => "fill-scrubbing-owner", + "pattern_catalog_version" => "fill-pattern-catalog-version", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "required_boundaries" => @required_scrubbing_boundaries, + "test_results" => "fill-scrubbing-test-results" + }, + "operator_inspection" => %{ + "contains_raw_evidence_payload" => false, + "workpad_markdown_authoritative" => false, + "requires_bounded_evidence_summaries" => true + }, + "retention_policy" => [ + "fill-retention-class", + "fill-retention-period-days", + "fill-cleanup-owner", + "prove-tombstone-preserving-cleanup" + ], + "authority_boundaries" => Map.new(@required_authority_boundaries, &{&1, false}), + "owner_signoffs" => ["fill-owner-signoffs"] + } + end + + defp completed_evidence_packets(provider_review_plans) do + Enum.map(provider_review_plans, fn plan -> + %{ + "template" => Map.get(plan, "template"), + "provider_matrix_entry_ids" => Map.get(plan, "provider_matrix_entry_ids", []), + "required_evidence_kinds" => Map.get(plan, "required_evidence_kinds", []), + "required_evidence_files" => Map.get(plan, "required_evidence_files", []), + "scenario_count" => Map.get(plan, "scenario_count", 0), + "live_evidence_status" => Map.get(plan, "live_evidence_status") + } + end) + end + + defp provider_preflight_reports(provider_review_plans) do + Enum.map(provider_review_plans, fn plan -> + %{ + "template" => Map.get(plan, "template"), + "provider_matrix_entry_ids" => Map.get(plan, "provider_matrix_entry_ids", []), + "required_command_ids" => required_preflight_command_ids(plan), + "report_status" => "fill-preflight-report-status", + "raw_output_included" => false + } + end) + end + + defp required_preflight_command_ids(plan) do + plan + |> Map.get("read_only_preflight", %{}) + |> Map.get("commands", []) + |> Enum.map(&Map.get(&1, "id")) + |> unique_strings() + end + + defp blocking_requirements(provider_review_plans) do + preflight_blockers = + Enum.map(provider_review_plans, fn plan -> + %{ + "code" => "provider_preflight_report_required", + "template" => Map.get(plan, "template"), + "provider_matrix_entry_ids" => Map.get(plan, "provider_matrix_entry_ids", []), + "message" => "A validated read-only provider preflight report is required before completed evidence collection." + } + end) + + evidence_blockers = + Enum.map(provider_review_plans, fn plan -> + %{ + "code" => "completed_evidence_packet_required", + "template" => Map.get(plan, "template"), + "provider_matrix_entry_ids" => Map.get(plan, "provider_matrix_entry_ids", []), + "message" => "Completed provider evidence packet is required before Phase 4 approval." + } + end) + + preflight_blockers ++ + evidence_blockers ++ + [ + %{ + "code" => "implementation_refs_required", + "message" => "Implementation PRs or local patches must be cited in the review packet." + }, + %{ + "code" => "scrubbing_test_results_required", + "message" => "Scrubbing pipeline rules and write/render boundary test results must be attached." + }, + %{ + "code" => "owner_signoffs_required", + "message" => "Owner sign-offs are required before a production decision." + } + ] + end + + defp explicit_non_claims do + [ + "phase4_review_plan_does_not_collect_live_provider_evidence", + "phase4_review_plan_does_not_read_evidence_files", + "phase4_review_plan_does_not_call_provider_apis", + "phase4_review_plan_does_not_approve_production", + "phase4_review_plan_does_not_enable_structured_execution_plan_gates" + ] + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery Phase 4 review plan is invalid.", + errors: errors + } + end + + defp issue(code, path, message) do + %{ + code: code, + path: path, + message: message + } + end + + defp unique_strings(values) do + values + |> Enum.filter(&non_empty_string?/1) + |> Enum.uniq() + end + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report.ex new file mode 100644 index 0000000..195f0e8 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report.ex @@ -0,0 +1,432 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReport do + @moduledoc """ + Admission checks for Phase 2 read-only provider preflight reports. + + The validator checks bounded preflight metadata only. It does not read + evidence files, inspect provider output, call providers, mutate workflow + state, approve production, or enable gates. + """ + + @schema "coding_pr_delivery.provider_preflight_report.v1" + @phase2_schema "coding_pr_delivery.phase2_evidence_plan.v1" + @error_code "coding_pr_delivery_preflight_report_invalid" + @statuses ["passed", "blocked"] + @raw_output_fields ["stdout", "stderr", "raw_output", "raw_payload", "environment", "env", "token"] + @placeholder_tokens ["fill-", "TODO", "REPLACE", "<", ">"] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate(map()) :: validation_result() + def validate(packet) when is_map(packet) do + phase2_plan = value_at(packet, ["phase2_evidence_plan"]) + results = value_at(packet, ["provider_preflight_results"]) + expected_commands = expected_commands(phase2_plan) + + errors = + [] + |> collect_schema(packet) + |> collect_phase2_plan(phase2_plan) + |> collect_results(results, expected_commands) + |> collect_explicit_non_claims(packet) + + if errors == [] do + {:ok, normalize(packet, expected_commands)} + else + {:error, invalid(errors)} + end + end + + def validate(_packet) do + {:error, invalid([issue("invalid_type", [], "Preflight report must be an object.")])} + end + + defp collect_schema(errors, packet) do + schema = Map.get(packet, "schema") + + cond do + not non_empty_string?(schema) -> + errors ++ [issue("required_field_missing", ["schema"], "Preflight report schema is required.")] + + schema != @schema -> + errors ++ [issue("invalid_schema", ["schema"], "Preflight report schema is not supported.")] + + true -> + errors + end + end + + defp collect_phase2_plan(errors, plan) do + provider_plans = value_at(plan, ["provider_plans"]) + + errors + |> collect_required_map(%{"phase2_evidence_plan" => plan}, ["phase2_evidence_plan"]) + |> maybe_add( + is_map(plan) and Map.get(plan, "schema") != @phase2_schema, + issue("invalid_phase2_schema", ["phase2_evidence_plan", "schema"], "Preflight report requires a Phase 2 evidence plan.") + ) + |> maybe_add( + is_map(plan) and (not is_list(provider_plans) or provider_plans == []), + issue("missing_provider_plans", ["phase2_evidence_plan", "provider_plans"], "Phase 2 evidence plan must include provider plans.") + ) + end + + defp collect_results(errors, results, expected_commands) do + cond do + not is_list(results) or results == [] -> + errors ++ [issue("required_field_missing", ["provider_preflight_results"], "Provider preflight results must be a non-empty array.")] + + true -> + expected_by_key = Map.new(expected_commands, &{{Map.get(&1, "template"), Map.get(&1, "id")}, &1}) + + errors ++ + result_errors(results, expected_by_key) ++ + result_coverage_errors(results, expected_by_key) + end + end + + defp result_errors(results, expected_by_key) do + results + |> Enum.with_index() + |> Enum.flat_map(fn {result, index} -> result_errors(result, index, expected_by_key) end) + end + + defp result_errors(result, index, expected_by_key) when is_map(result) do + path = ["provider_preflight_results", index] + template = value_at(result, ["template"]) + command_id = value_at(result, ["command_id"]) + expected = Map.get(expected_by_key, {template, command_id}) + status = value_at(result, ["status"]) + + [] + |> collect_required_string_field(result, path ++ ["template"], "template") + |> collect_required_string_field(result, path ++ ["command_id"], "command_id") + |> maybe_add( + non_empty_string?(template) and non_empty_string?(command_id) and is_nil(expected), + issue("unknown_preflight_command", path, "Preflight result must match a planned read-only preflight command.") + ) + |> collect_expected_value(result, expected, path, "target") + |> collect_expected_value(result, expected, path, "provider_kind") + |> collect_required_string_field(result, path ++ ["status"], "status") + |> maybe_add( + non_empty_string?(status) and status not in @statuses, + issue("invalid_preflight_status", path ++ ["status"], "Preflight status must be passed or blocked.") + ) + |> collect_required_string_field(result, path ++ ["ran_at"], "ran_at") + |> collect_timestamp(result, path ++ ["ran_at"]) + |> maybe_add( + value_at(result, ["side_effect_mode"]) != "read_only", + issue("preflight_not_read_only", path ++ ["side_effect_mode"], "Preflight results must record read-only mode.") + ) + |> maybe_add( + value_at(result, ["write_performed"]) != false, + issue("preflight_write_performed", path ++ ["write_performed"], "Preflight results must not record writes.") + ) + |> maybe_add( + value_at(result, ["production_enabled"]) != false, + issue("preflight_enabled_production", path ++ ["production_enabled"], "Preflight results must not enable production.") + ) + |> collect_blocker(result, expected, path) + |> collect_preflight_evidence_files(result, path, status) + |> collect_raw_output_fields(result, path) + end + + defp result_errors(_result, index, _expected_by_key) do + [issue("invalid_type", ["provider_preflight_results", index], "Preflight result must be an object.")] + end + + defp collect_expected_value(errors, _result, nil, _path, _field), do: errors + + defp collect_expected_value(errors, result, expected, path, field) do + actual = value_at(result, [field]) + expected_value = Map.get(expected, field) + + errors + |> collect_required_string_field(result, path ++ [field], field) + |> maybe_add( + non_empty_string?(actual) and actual != expected_value, + issue("preflight_command_mismatch", path ++ [field], "Preflight result does not match the planned command.") + ) + end + + defp collect_timestamp(errors, result, path) do + timestamp = value_at(result, [List.last(path)]) + + maybe_add( + errors, + non_empty_string?(timestamp) and not valid_datetime?(timestamp), + issue("invalid_timestamp", path, "Preflight timestamp must be ISO8601.") + ) + end + + defp collect_blocker(errors, result, expected, path) do + status = value_at(result, ["status"]) + missing = value_at(result, ["missing_prerequisites"]) + + cond do + status == "blocked" -> + errors + |> collect_required_string_field(result, path ++ ["blocker_code"], "blocker_code") + |> collect_string_list_field(result, path ++ ["missing_prerequisites"], "missing_prerequisites", "Blocked preflight results must list missing prerequisites.") + |> collect_known_missing_prerequisites(missing, expected, path ++ ["missing_prerequisites"]) + + status == "passed" -> + maybe_add( + errors, + is_list(missing) and missing != [], + issue("passed_preflight_has_missing_prerequisites", path ++ ["missing_prerequisites"], "Passed preflight results must not list missing prerequisites.") + ) + + true -> + errors + end + end + + defp collect_known_missing_prerequisites(errors, missing, expected, path) when is_list(missing) and is_map(expected) do + allowed = allowed_prerequisites(expected) + + missing + |> Enum.reject(&(&1 in allowed)) + |> Enum.map(fn prerequisite -> + issue("unknown_preflight_prerequisite", path, "Missing prerequisite must be declared by the planned preflight command.", %{prerequisite: prerequisite}) + end) + |> then(&(errors ++ &1)) + end + + defp collect_known_missing_prerequisites(errors, _missing, _expected, _path), do: errors + + defp collect_preflight_evidence_files(errors, result, path, "passed") do + collect_evidence_files(errors, result, path ++ ["evidence_files"], true) + end + + defp collect_preflight_evidence_files(errors, result, path, _status) do + collect_evidence_files(errors, result, path ++ ["evidence_files"], false) + end + + defp collect_evidence_files(errors, result, path, required?) do + evidence_files = value_at(result, [List.last(path)]) + + errors = + cond do + required? -> + collect_string_list_field(errors, result, path, List.last(path), "Passed preflight results must cite bounded evidence references.") + + is_nil(evidence_files) -> + errors + + true -> + collect_string_list_field(errors, result, path, List.last(path), "Preflight evidence references must be a non-empty string array when present.") + end + + if is_list(evidence_files) do + evidence_files + |> Enum.with_index() + |> Enum.flat_map(fn {evidence_ref, index} -> evidence_ref_errors(evidence_ref, path ++ [index]) end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp evidence_ref_errors(evidence_ref, path) do + [] + |> maybe_add( + non_empty_string?(evidence_ref) and not allowed_evidence_ref?(evidence_ref), + issue("invalid_evidence_ref", path, "Preflight evidence references must be repository evidence paths or HTTP(S) links.") + ) + |> maybe_add( + non_empty_string?(evidence_ref) and placeholder_evidence_ref?(evidence_ref), + issue("placeholder_evidence_ref", path, "Preflight evidence references must not contain placeholders.") + ) + end + + defp allowed_evidence_ref?(evidence_ref) do + String.starts_with?(evidence_ref, "evidence/") or + String.starts_with?(evidence_ref, "https://") or + String.starts_with?(evidence_ref, "http://") + end + + defp placeholder_evidence_ref?(evidence_ref) do + downcased = String.downcase(evidence_ref) + + String.starts_with?(downcased, "/tmp/") or + String.starts_with?(downcased, "tmp/") or + String.starts_with?(downcased, "/var/") or + String.starts_with?(downcased, "file://") or + Enum.any?(@placeholder_tokens, &String.contains?(downcased, String.downcase(&1))) + end + + defp collect_raw_output_fields(errors, result, path) do + @raw_output_fields + |> Enum.filter(&Map.has_key?(result, &1)) + |> Enum.map(fn field -> + issue("raw_preflight_output_forbidden", path ++ [field], "Preflight reports must not store raw output, environment, or token material.") + end) + |> then(&(errors ++ &1)) + end + + defp result_coverage_errors(results, expected_by_key) do + observed = + results + |> Enum.filter(&is_map/1) + |> Enum.map(&{value_at(&1, ["template"]), value_at(&1, ["command_id"])}) + |> Enum.filter(fn {template, command_id} -> non_empty_string?(template) and non_empty_string?(command_id) end) + + observed_counts = Enum.frequencies(observed) + expected_keys = Map.keys(expected_by_key) + observed_set = MapSet.new(observed) + + missing = + expected_keys + |> Enum.reject(&MapSet.member?(observed_set, &1)) + |> Enum.map(fn {template, command_id} -> + issue("missing_preflight_result", ["provider_preflight_results"], "Every planned read-only preflight command must have one result.", %{ + template: template, + command_id: command_id + }) + end) + + duplicate = + observed_counts + |> Enum.filter(fn {_key, count} -> count > 1 end) + |> Enum.map(fn {{template, command_id}, _count} -> + issue("duplicate_preflight_result", ["provider_preflight_results"], "Preflight commands must not have duplicate results.", %{ + template: template, + command_id: command_id + }) + end) + + missing ++ duplicate + end + + defp collect_explicit_non_claims(errors, packet) do + non_claims = value_at(packet, ["explicit_non_claims"]) + + errors + |> collect_string_list(packet, ["explicit_non_claims"], "Explicit non-claims must be a non-empty string array.") + |> maybe_add( + is_list(non_claims) and "preflight_report_does_not_enable_production" not in non_claims, + issue("missing_non_claim", ["explicit_non_claims"], "Preflight reports must state that production is not enabled.") + ) + end + + defp normalize(packet, expected_commands) do + results = Map.get(packet, "provider_preflight_results", []) + status = if Enum.all?(results, &(Map.get(&1, "status") == "passed")), do: "passed", else: "blocked" + + packet + |> Map.put("schema", @schema) + |> Map.put("status", status) + |> Map.put("planned_preflight_command_count", length(expected_commands)) + |> Map.put("preflight_result_count", length(results)) + |> Map.put("raw_output_included", false) + |> Map.put("does_not_read_evidence_files", true) + |> Map.put("does_not_call_providers", true) + |> Map.put("does_not_mutate_workflow_state", true) + |> Map.put("does_not_approve_production", true) + |> Map.put("does_not_enable_production", true) + end + + defp expected_commands(%{"provider_plans" => provider_plans}) when is_list(provider_plans) do + Enum.flat_map(provider_plans, fn provider_plan -> + template = Map.get(provider_plan, "template") + entry_ids = Map.get(provider_plan, "provider_matrix_entry_ids", []) + + provider_plan + |> value_at(["read_only_preflight", "commands"]) + |> case do + commands when is_list(commands) -> + Enum.map(commands, fn command -> + command + |> Map.put("template", template) + |> Map.put("provider_matrix_entry_ids", entry_ids) + end) + + _missing -> + [] + end + end) + end + + defp expected_commands(_plan), do: [] + + defp allowed_prerequisites(expected) do + expected + |> Map.take(["required_env", "required_auth", "required_targets", "required_runtime"]) + |> Map.values() + |> Enum.flat_map(fn + values when is_list(values) -> values + _value -> [] + end) + |> Enum.uniq() + end + + defp collect_required_map(errors, map, path) do + value = value_at(map, path) + + maybe_add(errors, not is_map(value), issue("required_field_missing", path, "Required object field is missing.")) + end + + defp collect_required_string_field(errors, map, path, field) do + value = value_at(map, [field]) + + maybe_add(errors, not non_empty_string?(value), issue("required_field_missing", path, "Required string field is missing.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, path) + + valid? = is_list(value) and value != [] and Enum.all?(value, &non_empty_string?/1) + + maybe_add(errors, not valid?, issue("invalid_string_list", path, message)) + end + + defp collect_string_list_field(errors, map, path, field, message) do + value = value_at(map, [field]) + valid? = is_list(value) and value != [] and Enum.all?(value, &non_empty_string?/1) + + maybe_add(errors, not valid?, issue("invalid_string_list", path, message)) + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery preflight report is invalid.", + errors: errors + } + end + + defp issue(code, path, message, extra \\ %{}) do + %{ + code: code, + path: path, + message: message + } + |> Map.merge(extra) + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + if is_map(current) and Map.has_key?(current, key) do + {:cont, Map.get(current, key)} + else + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp valid_datetime?(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, _datetime, _offset} -> true + {:error, _reason} -> false + end + end + + defp valid_datetime?(_value), do: false + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_collector.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_collector.ex new file mode 100644 index 0000000..54f4896 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_collector.ex @@ -0,0 +1,352 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReportCollector do + @moduledoc """ + Collects Phase 2 read-only provider preflight results into bounded metadata. + + The collector may call read-only tracker/repo-provider smoke probes. It never + passes write/destructive flags, stores raw stdout/stderr, reads evidence files, + mutates workflow state, approves production, or enables gates. + """ + + alias SymphonyElixir.CLI.{RepoProviderSmoke, TrackerSmoke} + alias SymphonyElixir.RepoProvider.CLI.Evaluator, as: RepoProviderEvaluator + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReport + + @report_schema "coding_pr_delivery.provider_preflight_report.v1" + @error_code "coding_pr_delivery_preflight_report_collector_invalid" + @non_claims [ + "preflight_report_does_not_enable_production", + "preflight_report_does_not_mutate_workflow_state", + "preflight_report_uses_read_only_provider_smokes" + ] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @type deps :: %{ + required(:env) => (-> map() | [{String.t(), String.t()}]), + required(:utc_now) => (-> DateTime.t()), + required(:tracker_smoke) => ([String.t()] -> {String.t(), String.t(), non_neg_integer()}), + required(:repo_provider_smoke) => ([String.t()] -> {String.t(), String.t(), non_neg_integer()}) + } + + @spec collect(map(), keyword(), deps()) :: validation_result() + def collect(phase2_plan, opts \\ [], deps \\ runtime_deps()) + + def collect(phase2_plan, opts, deps) when is_map(phase2_plan) and is_list(opts) and is_map(deps) do + with true <- Keyword.keyword?(opts), + :ok <- validate_deps(deps) do + phase2_plan + |> report_packet(opts, deps) + |> PreflightReport.validate() + else + false -> + {:error, invalid([issue("invalid_options", [], "Preflight collector options must be a keyword list.")])} + + {:error, reason} -> + {:error, invalid([reason])} + end + end + + def collect(_phase2_plan, _opts, _deps) do + {:error, invalid([issue("invalid_type", [], "Preflight collector requires a Phase 2 evidence plan object.")])} + end + + @spec runtime_deps() :: deps() + def runtime_deps do + %{ + env: &System.get_env/0, + utc_now: fn -> DateTime.utc_now() end, + tracker_smoke: fn argv -> TrackerSmoke.evaluate(argv) end, + repo_provider_smoke: fn argv -> + RepoProviderSmoke.evaluate(argv, %{ + env: &System.get_env/0, + command_opts: fn -> [] end, + cli_evaluate: fn argv, cli_deps -> + cli_deps = Map.put(cli_deps, :emit_event, fn _level, _event, payload -> payload end) + RepoProviderEvaluator.evaluate(argv, cli_deps) + end, + monotonic_time_ms: fn -> System.monotonic_time(:millisecond) end, + emit_event: fn _level, _event, payload -> payload end + }) + end + } + end + + defp report_packet(phase2_plan, opts, deps) do + env = deps.env.() |> normalize_env() + ran_at = deps.utc_now.() |> DateTime.to_iso8601() + + %{ + "schema" => @report_schema, + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => provider_preflight_results(phase2_plan, opts, deps, env, ran_at), + "explicit_non_claims" => @non_claims, + "collector" => %{ + "mode" => "read_only_provider_preflight", + "raw_output_included" => false, + "does_not_read_evidence_files" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true + } + } + end + + defp provider_preflight_results(phase2_plan, opts, deps, env, ran_at) do + phase2_plan + |> Map.get("provider_plans", []) + |> Enum.flat_map(fn provider_plan -> + template = Map.get(provider_plan, "template") + + provider_plan + |> value_at(["read_only_preflight", "commands"]) + |> case do + commands when is_list(commands) -> + Enum.map(commands, &preflight_result(template, &1, opts, deps, env, ran_at, phase2_plan)) + + _missing -> + [] + end + end) + end + + defp preflight_result(template, command, opts, deps, env, ran_at, phase2_plan) when is_map(command) do + missing = missing_prerequisites(command, opts, env) + + cond do + missing != [] -> + blocked_result(template, command, ran_at, "preflight_prerequisites_missing", missing) + + smoke_passed?(command, opts, deps) -> + passed_result(template, command, ran_at, phase2_plan, opts) + + true -> + blocked_result(template, command, ran_at, "preflight_smoke_failed", fallback_prerequisites(command)) + end + end + + defp preflight_result(template, _command, _opts, _deps, _env, ran_at, _phase2_plan) do + %{ + "template" => template, + "command_id" => "invalid-command", + "target" => "unknown", + "provider_kind" => "unknown", + "status" => "blocked", + "blocker_code" => "invalid_preflight_command", + "missing_prerequisites" => ["provider_read_only_smoke"], + "ran_at" => ran_at, + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false + } + end + + defp passed_result(template, command, ran_at, phase2_plan, opts) do + command + |> base_result(template, ran_at) + |> Map.put("status", "passed") + |> Map.put("evidence_files", [evidence_ref(template, command, phase2_plan, opts)]) + end + + defp blocked_result(template, command, ran_at, blocker_code, missing_prerequisites) do + command + |> base_result(template, ran_at) + |> Map.merge(%{ + "status" => "blocked", + "blocker_code" => blocker_code, + "missing_prerequisites" => missing_prerequisites + }) + end + + defp base_result(command, template, ran_at) do + %{ + "template" => template, + "command_id" => Map.get(command, "id"), + "target" => Map.get(command, "target"), + "provider_kind" => Map.get(command, "provider_kind"), + "ran_at" => ran_at, + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false + } + end + + defp missing_prerequisites(command, opts, env) do + missing_env = + command + |> list_field("required_env") + |> Enum.reject(&env_present?(env, &1)) + + missing_targets = + command + |> list_field("required_targets") + |> Enum.reject(&target_present?(opts, &1)) + + Enum.uniq(missing_env ++ missing_targets) + end + + defp smoke_passed?(%{"target" => "tracker"} = command, _opts, deps) do + command + |> tracker_argv() + |> deps.tracker_smoke.() + |> smoke_ok?() + end + + defp smoke_passed?(%{"target" => "repo_provider"} = command, opts, deps) do + command + |> repo_provider_argv(opts) + |> deps.repo_provider_smoke.() + |> smoke_ok?() + end + + defp smoke_passed?(_command, _opts, _deps), do: false + + defp tracker_argv(command) do + ["--template", tracker_template(command), "--json"] + end + + defp repo_provider_argv(command, opts) do + [ + "--provider", + Map.get(command, "provider_kind"), + "--repo", + target_value(opts, "repo_slug"), + "--pr", + target_value(opts, "change_proposal_number"), + "--json" + ] + end + + defp smoke_ok?({stdout, _stderr, 0}) do + case Jason.decode(stdout) do + {:ok, %{"ok" => true}} -> true + _decoded -> false + end + end + + defp smoke_ok?({_stdout, _stderr, _exit_code}), do: false + + defp tracker_template(%{"command" => command}) when is_binary(command) do + case Regex.run(~r/--template\s+([^\s]+)/, command) do + [_match, template] -> template + _missing -> "" + end + end + + defp tracker_template(_command), do: "" + + defp fallback_prerequisites(command) do + command + |> declared_prerequisites() + |> case do + [] -> ["provider_read_only_smoke"] + prerequisites -> prerequisites + end + end + + defp declared_prerequisites(command) do + ["required_env", "required_auth", "required_targets", "required_runtime"] + |> Enum.flat_map(&list_field(command, &1)) + |> Enum.uniq() + end + + defp evidence_ref(template, command, phase2_plan, opts) do + prefix = opts |> Keyword.get(:evidence_prefix, "evidence/preflight") |> clean_segment() + plan_id = phase2_plan |> Map.get("plan_id", "phase2") |> clean_segment() + template = clean_segment(template) + command_id = command |> Map.get("id", "command") |> clean_segment() + + "#{prefix}/#{plan_id}/#{template}/#{command_id}.json" + end + + defp clean_segment(value) when is_binary(value) do + value + |> String.trim() + |> String.replace(~r/[^A-Za-z0-9._\/-]/, "-") + |> String.trim("/") + |> case do + "" -> "unknown" + cleaned -> cleaned + end + end + + defp clean_segment(_value), do: "unknown" + + defp env_present?(env, key) do + case Map.get(env, key) do + value when is_binary(value) -> String.trim(value) != "" + _value -> false + end + end + + defp target_present?(opts, target), do: is_binary(target_value(opts, target)) + + defp target_value(opts, "repo_slug") do + opts + |> Keyword.get(:repo_slug, Keyword.get(opts, :repo)) + |> normalize_optional_string() + end + + defp target_value(opts, "change_proposal_number") do + opts + |> Keyword.get(:change_proposal_number, Keyword.get(opts, :pr)) + |> normalize_optional_string() + end + + defp target_value(_opts, _target), do: nil + + defp normalize_optional_string(value) when is_binary(value) do + value = String.trim(value) + if value == "", do: nil, else: value + end + + defp normalize_optional_string(value) when is_integer(value), do: Integer.to_string(value) + defp normalize_optional_string(_value), do: nil + + defp list_field(map, field) when is_map(map) do + case Map.get(map, field) do + values when is_list(values) -> Enum.filter(values, &non_empty_string?/1) + _value -> [] + end + end + + defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != "" + defp non_empty_string?(_value), do: false + + defp normalize_env(env) when is_map(env), do: env + defp normalize_env(env) when is_list(env), do: Map.new(env) + defp normalize_env(_env), do: %{} + + defp validate_deps(%{env: env, utc_now: utc_now, tracker_smoke: tracker_smoke, repo_provider_smoke: repo_provider_smoke}) + when is_function(env, 0) and is_function(utc_now, 0) and is_function(tracker_smoke, 1) and is_function(repo_provider_smoke, 1), + do: :ok + + defp validate_deps(_deps), do: {:error, issue("invalid_deps", [], "Preflight collector dependencies are invalid.")} + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery preflight report collector is invalid.", + errors: errors + } + end + + defp issue(code, path, message) do + %{ + code: code, + path: path, + message: message + } + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + if is_map(current) and Map.has_key?(current, key) do + {:cont, Map.get(current, key)} + else + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_template.ex new file mode 100644 index 0000000..52c7afe --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_template.ex @@ -0,0 +1,302 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReportTemplate do + @moduledoc """ + Builds bounded Phase 2 preflight-report fill templates. + + The template is a deterministic projection over a Phase 2 evidence plan. It + describes the metadata operators must collect for read-only provider + preflight commands; it does not execute those commands, call providers, read + evidence files, mutate workflow state, approve production, or enable gates. + """ + + @schema "coding_pr_delivery.provider_preflight_report_template.v1" + @completed_packet_schema "coding_pr_delivery.provider_preflight_report.v1" + @phase2_schema "coding_pr_delivery.phase2_evidence_plan.v1" + @error_code "coding_pr_delivery_preflight_report_template_invalid" + @status_options ["passed", "blocked"] + @allowed_evidence_ref_prefixes ["evidence/", "https://", "http://"] + @non_claims [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + + @type template_result :: {:ok, map()} | {:error, map()} + + @spec build(map()) :: template_result() + def build(phase2_plan) when is_map(phase2_plan) do + errors = collect_phase2_plan_errors(phase2_plan) + + if errors == [] do + {:ok, template(phase2_plan)} + else + {:error, invalid(errors)} + end + end + + def build(_phase2_plan) do + {:error, invalid([issue("invalid_type", [], "Phase 2 evidence plan must be an object.")])} + end + + defp collect_phase2_plan_errors(phase2_plan) do + provider_plans = Map.get(phase2_plan, "provider_plans") + + [] + |> maybe_add( + Map.get(phase2_plan, "schema") != @phase2_schema, + issue("invalid_phase2_schema", ["schema"], "Preflight report templates require a Phase 2 evidence plan.") + ) + |> maybe_add( + not is_list(provider_plans) or provider_plans == [], + issue("missing_provider_plans", ["provider_plans"], "Phase 2 evidence plan must include provider plans.") + ) + |> then(fn errors -> + if is_list(provider_plans) do + errors ++ provider_plan_errors(provider_plans) + else + errors + end + end) + end + + defp provider_plan_errors(provider_plans) do + provider_plans + |> Enum.with_index() + |> Enum.flat_map(fn {provider_plan, index} -> provider_plan_errors(provider_plan, index) end) + end + + defp provider_plan_errors(provider_plan, index) when is_map(provider_plan) do + path = ["provider_plans", index] + commands = value_at(provider_plan, ["read_only_preflight", "commands"]) + + errors = + [] + |> maybe_add( + not non_empty_string?(Map.get(provider_plan, "template")), + issue("required_field_missing", path ++ ["template"], "Provider plan template id is required.") + ) + |> maybe_add( + not is_list(commands) or commands == [], + issue("missing_preflight_commands", path ++ ["read_only_preflight", "commands"], "Provider plan must include read-only preflight commands.") + ) + + if is_list(commands) do + errors ++ commands_errors(commands, path ++ ["read_only_preflight", "commands"]) + else + errors + end + end + + defp provider_plan_errors(_provider_plan, index) do + [issue("invalid_type", ["provider_plans", index], "Provider plan must be an object.")] + end + + defp commands_errors(commands, path) do + commands + |> Enum.with_index() + |> Enum.flat_map(fn {command, index} -> command_errors(command, path ++ [index]) end) + end + + defp command_errors(command, path) when is_map(command) do + [] + |> maybe_add( + not non_empty_string?(Map.get(command, "id")), + issue("required_field_missing", path ++ ["id"], "Preflight command id is required.") + ) + |> maybe_add( + not non_empty_string?(Map.get(command, "target")), + issue("required_field_missing", path ++ ["target"], "Preflight command target is required.") + ) + |> maybe_add( + not non_empty_string?(Map.get(command, "provider_kind")), + issue("required_field_missing", path ++ ["provider_kind"], "Preflight command provider kind is required.") + ) + end + + defp command_errors(_command, path), do: [issue("invalid_type", path, "Preflight command must be an object.")] + + defp template(phase2_plan) do + bounded_plan = bounded_phase2_plan(phase2_plan) + requirements = preflight_requirements(phase2_plan) + + %{ + "schema" => @schema, + "completed_packet_schema" => @completed_packet_schema, + "phase2_plan_id" => Map.get(phase2_plan, "plan_id"), + "phase2_plan_kind" => Map.get(phase2_plan, "plan_kind"), + "template_authority" => "preflight_report_shape_only", + "does_not_collect_live_evidence" => true, + "does_not_read_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true, + "preflight_result_requirements" => requirements, + "required_explicit_non_claims" => @non_claims, + "preflight_report_field_template" => %{ + "schema" => @completed_packet_schema, + "phase2_evidence_plan" => bounded_plan, + "provider_preflight_results" => Enum.map(requirements, &result_placeholder/1), + "explicit_non_claims" => @non_claims + } + } + end + + defp bounded_phase2_plan(phase2_plan) do + %{ + "schema" => @phase2_schema, + "plan_id" => Map.get(phase2_plan, "plan_id"), + "plan_kind" => Map.get(phase2_plan, "plan_kind"), + "plan_authority" => Map.get(phase2_plan, "plan_authority"), + "does_not_collect_live_evidence" => Map.get(phase2_plan, "does_not_collect_live_evidence"), + "does_not_call_providers" => Map.get(phase2_plan, "does_not_call_providers"), + "does_not_enable_production" => Map.get(phase2_plan, "does_not_enable_production"), + "provider_plans" => phase2_plan |> Map.get("provider_plans", []) |> Enum.map(&bounded_provider_plan/1) + } + end + + defp bounded_provider_plan(provider_plan) do + %{ + "tier" => Map.get(provider_plan, "tier"), + "template" => Map.get(provider_plan, "template"), + "provider_matrix_entry_ids" => Map.get(provider_plan, "provider_matrix_entry_ids", []), + "tracker_kinds" => Map.get(provider_plan, "tracker_kinds", []), + "repo_provider_kinds" => Map.get(provider_plan, "repo_provider_kinds", []), + "side_effect_modes" => Map.get(provider_plan, "side_effect_modes", []), + "live_evidence_status" => Map.get(provider_plan, "live_evidence_status"), + "read_only_preflight" => bounded_read_only_preflight(provider_plan), + "does_not_collect_live_evidence" => Map.get(provider_plan, "does_not_collect_live_evidence"), + "does_not_enable_production" => Map.get(provider_plan, "does_not_enable_production") + } + end + + defp bounded_read_only_preflight(provider_plan) do + preflight = value_at(provider_plan, ["read_only_preflight"]) || %{} + + %{ + "status" => Map.get(preflight, "status"), + "does_not_collect_live_evidence" => Map.get(preflight, "does_not_collect_live_evidence"), + "does_not_mutate_workflow_state" => Map.get(preflight, "does_not_mutate_workflow_state"), + "does_not_enable_production" => Map.get(preflight, "does_not_enable_production"), + "commands" => preflight |> Map.get("commands", []) |> Enum.map(&bounded_command/1) + } + end + + defp bounded_command(command) do + if is_map(command) do + Map.take(command, [ + "id", + "target", + "provider_kind", + "command", + "required_env", + "required_auth", + "required_targets", + "required_runtime", + "side_effect_mode", + "requires_write_confirmation", + "requires_destructive_flag", + "does_not_write" + ]) + else + %{} + end + end + + defp preflight_requirements(phase2_plan) do + phase2_plan + |> Map.get("provider_plans", []) + |> Enum.flat_map(&provider_preflight_requirements/1) + end + + defp provider_preflight_requirements(provider_plan) do + template = Map.get(provider_plan, "template") + entry_ids = Map.get(provider_plan, "provider_matrix_entry_ids", []) + + provider_plan + |> value_at(["read_only_preflight", "commands"]) + |> case do + commands when is_list(commands) -> + Enum.map(commands, &preflight_requirement(template, entry_ids, &1)) + + _missing -> + [] + end + end + + defp preflight_requirement(template, entry_ids, command) do + %{ + "template" => template, + "provider_matrix_entry_ids" => entry_ids, + "command_id" => Map.get(command, "id"), + "target" => Map.get(command, "target"), + "provider_kind" => Map.get(command, "provider_kind"), + "command" => Map.get(command, "command"), + "required_env" => Map.get(command, "required_env", []), + "required_auth" => Map.get(command, "required_auth", []), + "required_targets" => Map.get(command, "required_targets", []), + "required_runtime" => Map.get(command, "required_runtime", []), + "status_options" => @status_options, + "required_side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "raw_output_allowed" => false, + "allowed_evidence_ref_prefixes" => @allowed_evidence_ref_prefixes, + "evidence_files" => evidence_files(template, Map.get(command, "id")), + "fields_to_complete" => ["status", "ran_at", "evidence_files"], + "blocked_fields_to_complete" => ["blocker_code", "missing_prerequisites"] + } + end + + defp result_placeholder(requirement) do + %{ + "template" => Map.get(requirement, "template"), + "command_id" => Map.get(requirement, "command_id"), + "target" => Map.get(requirement, "target"), + "provider_kind" => Map.get(requirement, "provider_kind"), + "status" => "fill-passed-or-blocked", + "ran_at" => "fill-iso8601-timestamp", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => Map.get(requirement, "evidence_files", []), + "blocker_code" => "fill-when-blocked", + "missing_prerequisites" => [] + } + end + + defp evidence_files(template, command_id) do + ["evidence/preflight/#{template}/#{command_id}.md"] + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery preflight report template is invalid.", + errors: errors + } + end + + defp issue(code, path, message) do + %{ + code: code, + path: path, + message: message + } + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + if is_map(current) and Map.has_key?(current, key) do + {:cont, Map.get(current, key)} + else + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/provider_matrix.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/provider_matrix.ex new file mode 100644 index 0000000..af48490 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/provider_matrix.ex @@ -0,0 +1,467 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ProviderMatrix do + @moduledoc """ + Admission checks for Coding PR Delivery production provider-matrix claims. + + This module is intentionally pure. It validates review-packet or Phase 2 + evidence metadata before a production claim is accepted; it does not enable a + workflow profile or call providers. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Profile.Contract, as: ProfileContract + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.Config.Contract, as: ReconciliationConfigContract + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @error_code "coding_pr_delivery_provider_matrix_invalid" + @shadow_mode OneShotContract.shadow_mode() + @side_effect_modes [ + "read_only", + @shadow_mode, + "review_handoff_write", + "ready_to_land_write" + ] + @topology_modes ["singleton", "distributed_lock", "external_queue"] + @typed_tool_sections ["tracker", "repo_core", "repo_provider"] + @gate_aliases ["structured_plan_gates", "enabled_gates", "gates"] + @placeholder_tokens ["fill-", "TODO", "REPLACE", "<", ">"] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec side_effect_modes() :: [String.t()] + def side_effect_modes, do: @side_effect_modes + + @spec validate_claim(map()) :: validation_result() + def validate_claim(claim) when is_map(claim) do + errors = + [] + |> collect_required_string(claim, ["profile_instance_id"]) + |> collect_provider_matrix(claim) + + if errors == [] do + {:ok, normalize_claim(claim)} + else + {:error, invalid(errors)} + end + end + + def validate_claim(_claim) do + {:error, invalid([issue("invalid_type", [], "Production provider-matrix claim must be an object.")])} + end + + @spec validate_entry(map()) :: validation_result() + def validate_entry(entry) when is_map(entry) do + errors = entry_errors(entry, []) + + if errors == [] do + {:ok, normalize_entry(entry)} + else + {:error, invalid(errors)} + end + end + + def validate_entry(_entry) do + {:error, invalid([issue("invalid_type", [], "Provider-matrix entry must be an object.")])} + end + + defp collect_provider_matrix(errors, claim) do + case value_at(claim, ["provider_matrix"]) do + entries when is_list(entries) and entries != [] -> + entries + |> Enum.with_index() + |> Enum.flat_map(fn {entry, index} -> entry_errors(entry, ["provider_matrix", index]) end) + |> then(&(errors ++ &1)) + + entries when is_list(entries) -> + errors ++ [issue("required_field_missing", ["provider_matrix"], "Provider matrix must contain at least one entry.")] + + _missing_or_invalid -> + errors ++ [issue("required_field_missing", ["provider_matrix"], "Provider matrix must be a non-empty array.")] + end + end + + defp entry_errors(entry, path) when is_map(entry) do + [] + |> collect_required_string(entry, path ++ ["id"]) + |> collect_workflow_profile(entry, path) + |> collect_provider_kind(entry, path, "tracker") + |> collect_provider_kind(entry, path, "repo_provider") + |> collect_provider_kind(entry, path, "agent_provider") + |> collect_required_string(entry, path ++ ["repository_class"]) + |> collect_required_string(entry, path ++ ["candidate_discovery"]) + |> collect_candidate_discovery(entry, path) + |> collect_side_effect_mode(entry, path) + |> collect_topology(entry, path) + |> collect_gates(entry, path) + |> collect_typed_tool_inventory(entry, path) + |> collect_string_list(entry, path ++ ["evidence_files"], "Evidence files must be a non-empty string array.") + |> collect_evidence_refs(entry, path ++ ["evidence_files"]) + |> collect_required_map(entry, path ++ ["recovery"]) + |> collect_required_string(entry, path ++ ["recovery", "model"]) + |> collect_required_map(entry, path ++ ["rollback"]) + |> collect_required_string(entry, path ++ ["rollback", "owner"]) + |> collect_rollback(entry, path) + |> collect_mode_specific(entry, path) + end + + defp entry_errors(_entry, path), do: [issue("invalid_type", path, "Provider-matrix entry must be an object.")] + + defp collect_workflow_profile(errors, entry, path) do + workflow_profile = value_at(entry, ["workflow_profile"]) + + errors + |> collect_required_map(entry, path ++ ["workflow_profile"]) + |> maybe_add( + value_at(workflow_profile, ["kind"]) != ProfileContract.kind(), + issue("invalid_workflow_profile", path ++ ["workflow_profile", "kind"], "Workflow profile kind must be coding_pr_delivery.") + ) + |> maybe_add( + value_at(workflow_profile, ["version"]) != ProfileContract.version(), + issue("invalid_workflow_profile", path ++ ["workflow_profile", "version"], "Workflow profile version is unsupported.") + ) + end + + defp collect_provider_kind(errors, entry, path, provider_key) do + errors + |> collect_required_map(entry, path ++ [provider_key]) + |> collect_required_string(entry, path ++ [provider_key, "kind"]) + end + + defp collect_candidate_discovery(errors, entry, path) do + value = value_at(entry, ["candidate_discovery"]) + + maybe_add( + errors, + is_binary(value) and value not in ReconciliationConfigContract.candidate_discovery_modes(), + issue("invalid_candidate_discovery", path ++ ["candidate_discovery"], "Candidate discovery mode is unsupported.", allowed_values: ReconciliationConfigContract.candidate_discovery_modes()) + ) + end + + defp collect_side_effect_mode(errors, entry, path) do + value = value_at(entry, ["side_effect_mode"]) + + cond do + not non_empty_string?(value) -> + errors ++ [issue("required_field_missing", path ++ ["side_effect_mode"], "Side-effect mode is required.")] + + value not in @side_effect_modes -> + errors ++ [issue("invalid_side_effect_mode", path ++ ["side_effect_mode"], "Side-effect mode is unsupported.", allowed_values: @side_effect_modes)] + + true -> + errors + end + end + + defp collect_topology(errors, entry, path) do + topology = value_at(entry, ["deployment_topology"]) + mode = value_at(topology, ["mode"]) + + errors + |> collect_required_map(entry, path ++ ["deployment_topology"]) + |> maybe_add( + not non_empty_string?(mode), + issue("required_field_missing", path ++ ["deployment_topology", "mode"], "Deployment topology mode is required.") + ) + |> maybe_add( + is_binary(mode) and mode not in @topology_modes, + issue("invalid_topology_mode", path ++ ["deployment_topology", "mode"], "Deployment topology mode is unsupported.", allowed_values: @topology_modes) + ) + |> collect_topology_proof(topology, mode, path) + end + + defp collect_topology_proof(errors, topology, "singleton", path) do + maybe_add( + errors, + not non_empty_string?(value_at(topology, ["readiness_check"])), + issue("required_field_missing", path ++ ["deployment_topology", "readiness_check"], "Field must be a non-empty string.") + ) + end + + defp collect_topology_proof(errors, topology, mode, path) when mode in ["distributed_lock", "external_queue"] do + maybe_add( + errors, + not non_empty_string?(value_at(topology, ["ownership_proof"])), + issue("required_field_missing", path ++ ["deployment_topology", "ownership_proof"], "Field must be a non-empty string.") + ) + end + + defp collect_topology_proof(errors, _topology, _mode, _path), do: errors + + defp collect_gates(errors, entry, path) do + gates = gate_map(entry) + + errors + |> maybe_add( + not is_map(gates), + issue("required_field_missing", path ++ ["structured_plan_gates"], "Structured execution-plan gate values are required.") + ) + |> collect_gate_values(gates, path) + end + + defp collect_gate_values(errors, gates, path) when is_map(gates) do + missing_or_invalid = + Enum.flat_map(Gates.gate_keys(), fn key -> + case Map.fetch(gates, key) do + {:ok, value} when is_boolean(value) -> + [] + + {:ok, _value} -> + [issue("invalid_gate_value", path ++ ["structured_plan_gates", key], "Structured execution-plan gate value must be boolean.")] + + :error -> + [issue("required_field_missing", path ++ ["structured_plan_gates", key], "Structured execution-plan gate value is required.")] + end + end) + + unknown = + gates + |> Map.keys() + |> Enum.reject(&(&1 in Gates.gate_keys())) + |> Enum.map(fn key -> issue("unknown_gate_key", path ++ ["structured_plan_gates", key], "Structured execution-plan gate key is not supported.") end) + + errors ++ missing_or_invalid ++ unknown + end + + defp collect_gate_values(errors, _gates, _path), do: errors + + defp collect_typed_tool_inventory(errors, entry, path) do + inventory = value_at(entry, ["typed_tool_inventory"]) || value_at(entry, ["typed_tool_requirements"]) + + errors = + maybe_add( + errors, + not is_map(inventory), + issue("required_field_missing", path ++ ["typed_tool_inventory"], "Typed tool inventory is required.") + ) + + if is_map(inventory) do + Enum.reduce(@typed_tool_sections, errors, fn section, acc -> + maybe_add( + acc, + not string_list?(value_at(inventory, [section])) or value_at(inventory, [section]) == [], + issue("required_field_missing", path ++ ["typed_tool_inventory", section], "Typed tool inventory section must be a non-empty string array.") + ) + end) + else + errors + end + end + + defp collect_rollback(errors, entry, path) do + readiness_gate = value_at(entry, ["rollback", "disable_readiness_gate"]) + + maybe_add( + errors, + non_empty_string?(readiness_gate) and readiness_gate != Gates.transition_readiness_required_gate_key(), + issue("invalid_rollback_gate", path ++ ["rollback", "disable_readiness_gate"], "Rollback readiness gate must use the external transition readiness key.") + ) + end + + defp collect_mode_specific(errors, entry, path) do + mode = value_at(entry, ["side_effect_mode"]) + + errors + |> collect_ready_to_land_rules(entry, mode, path) + |> collect_shadow_rules(entry, mode, path) + end + + defp collect_ready_to_land_rules(errors, entry, "ready_to_land_write", path) do + gates = gate_map(entry) || %{} + + maybe_add( + errors, + Map.get(gates, Gates.transition_readiness_required_gate_key()) != true, + issue( + "transition_readiness_required", + path ++ ["structured_plan_gates", Gates.transition_readiness_required_gate_key()], + "Ready-to-land write claims require transition readiness enforcement." + ) + ) + end + + defp collect_ready_to_land_rules(errors, _entry, _mode, _path), do: errors + + defp collect_shadow_rules(errors, entry, @shadow_mode, path) do + shadow = value_at(entry, ["shadow"]) + + errors + |> collect_required_map(entry, path ++ ["shadow"]) + |> maybe_add( + value_at(shadow, ["prefix"]) != OneShotContract.shadow_prefix(), + issue("invalid_shadow_metadata", path ++ ["shadow", "prefix"], "Shadow metadata must use the no-production-write prefix.") + ) + |> maybe_add( + value_at(shadow, ["authority"]) != OneShotContract.shadow_authority(), + issue("invalid_shadow_metadata", path ++ ["shadow", "authority"], "Shadow authority must be diagnostic-only.") + ) + |> maybe_add( + value_at(shadow, ["canonical_authority"]) != false, + issue("invalid_shadow_metadata", path ++ ["shadow", "canonical_authority"], "Shadow metadata must be non-authoritative.") + ) + |> collect_shadow_destinations(shadow, path) + |> collect_shadow_no_write_gates(entry, path) + end + + defp collect_shadow_rules(errors, _entry, _mode, _path), do: errors + + defp collect_shadow_destinations(errors, shadow, path) do + destinations = value_at(shadow, ["allowed_destinations"]) + + cond do + not string_list?(destinations) or destinations == [] -> + errors ++ [issue("required_field_missing", path ++ ["shadow", "allowed_destinations"], "Shadow destinations must be a non-empty string array.")] + + Enum.sort(destinations) != Enum.sort(OneShotContract.shadow_allowed_destinations()) -> + errors ++ + [ + issue("invalid_shadow_metadata", path ++ ["shadow", "allowed_destinations"], "Shadow destinations must match the diagnostic-only allowlist.", + allowed_values: OneShotContract.shadow_allowed_destinations() + ) + ] + + true -> + errors + end + end + + defp collect_shadow_no_write_gates(errors, entry, path) do + gates = gate_map(entry) || %{} + + maybe_add( + errors, + Map.get(gates, Gates.transition_readiness_required_gate_key()) == true, + issue( + "shadow_not_authoritative", + path ++ ["structured_plan_gates", Gates.transition_readiness_required_gate_key()], + "Shadow/no-write entries cannot claim production transition authority." + ) + ) + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, path_from_root(path))), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, path_from_root(path))), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, path_from_root(path)) + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp collect_evidence_refs(errors, map, path) do + refs = value_at(map, path_from_root(path)) + + if is_list(refs) do + refs + |> Enum.with_index() + |> Enum.flat_map(fn {ref, index} -> evidence_ref_errors(ref, path ++ [index]) end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp evidence_ref_errors(ref, path) do + [] + |> maybe_add( + non_empty_string?(ref) and not allowed_evidence_ref?(ref), + issue("invalid_evidence_ref", path, "Evidence references must be repository evidence paths or HTTP(S) links.") + ) + |> maybe_add( + non_empty_string?(ref) and placeholder_evidence_ref?(ref), + issue("placeholder_evidence_ref", path, "Evidence references must not contain placeholders.") + ) + end + + defp allowed_evidence_ref?(ref) do + String.starts_with?(ref, "evidence/") or + String.starts_with?(ref, "https://") or + String.starts_with?(ref, "http://") + end + + defp placeholder_evidence_ref?(ref) do + downcased = String.downcase(ref) + + String.starts_with?(downcased, "/tmp/") or + String.starts_with?(downcased, "tmp/") or + String.starts_with?(downcased, "/var/") or + String.starts_with?(downcased, "file://") or + Enum.any?(@placeholder_tokens, &String.contains?(downcased, String.downcase(&1))) + end + + defp gate_map(entry) do + Enum.find_value(@gate_aliases, &value_at(entry, [&1])) + end + + defp normalize_claim(claim) do + %{ + "profile_instance_id" => value_at(claim, ["profile_instance_id"]), + "provider_matrix" => Enum.map(value_at(claim, ["provider_matrix"]), &normalize_entry/1) + } + end + + defp normalize_entry(entry) do + %{ + "id" => value_at(entry, ["id"]), + "workflow_profile" => value_at(entry, ["workflow_profile"]), + "tracker" => %{"kind" => value_at(entry, ["tracker", "kind"])}, + "repo_provider" => %{"kind" => value_at(entry, ["repo_provider", "kind"])}, + "agent_provider" => %{"kind" => value_at(entry, ["agent_provider", "kind"])}, + "repository_class" => value_at(entry, ["repository_class"]), + "candidate_discovery" => value_at(entry, ["candidate_discovery"]), + "deployment_topology" => value_at(entry, ["deployment_topology"]), + "side_effect_mode" => value_at(entry, ["side_effect_mode"]), + "structured_plan_gates" => gate_map(entry), + "typed_tool_inventory" => value_at(entry, ["typed_tool_inventory"]) || value_at(entry, ["typed_tool_requirements"]), + "evidence_files" => value_at(entry, ["evidence_files"]), + "recovery" => value_at(entry, ["recovery"]), + "rollback" => value_at(entry, ["rollback"]), + "shadow" => value_at(entry, ["shadow"]) + } + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery production provider-matrix claim is invalid.", + errors: errors + } + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message, extra \\ []) do + %{ + code: code, + path: path, + message: message + } + |> Map.merge(Map.new(extra)) + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp path_from_root([_root, index | rest]) when is_integer(index), do: rest + defp path_from_root(path), do: path + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" + defp string_list?(values), do: is_list(values) and Enum.all?(values, &non_empty_string?/1) +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_decision.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_decision.ex new file mode 100644 index 0000000..a42d172 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_decision.ex @@ -0,0 +1,164 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewDecision do + @moduledoc """ + Builds a bounded reviewer-facing decision projection for production packets. + + The decision is derived from `ReviewPacket.validate/1`. It summarizes whether + a packet is ready for review or blocked, plus provider scope, evidence counts, + explicit non-claims, and bounded blocker metadata. It intentionally omits raw + evidence payloads and does not read files, call providers, or enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacket + + @schema "coding_pr_delivery.production_review_decision.v1" + + @spec build(map()) :: {:ok, map()} + def build(packet) when is_map(packet) do + case ReviewPacket.validate(packet) do + {:ok, review_packet} -> + {:ok, ready_decision(review_packet)} + + {:error, reason} -> + {:ok, blocked_decision(packet, reason)} + end + end + + def build(packet) do + {:ok, + %{ + "schema" => @schema, + "status" => "blocked", + "review_packet_id" => nil, + "profile_instance_id" => nil, + "provider_entries" => [], + "evidence_summary" => %{"scenario_evidence_count" => 0, "non_claim_acknowledgement_count" => 0}, + "owner_signoffs" => [], + "blockers" => [blocker(%{code: "invalid_type", path: [], message: "Review packet must be an object."})], + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false, + "input_type" => inspect(packet) + }} + end + + defp ready_decision(review_packet) do + evidence_packet = Map.get(review_packet, "evidence_packet", %{}) + + %{ + "schema" => @schema, + "status" => "ready_for_approval", + "review_packet_id" => Map.get(review_packet, "review_packet_id"), + "profile_instance_id" => Map.get(review_packet, "profile_instance_id"), + "provider_entries" => provider_entries(evidence_packet), + "evidence_summary" => evidence_summary(evidence_packet), + "owner_signoffs" => owner_signoffs(review_packet), + "blockers" => [], + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false + } + end + + defp blocked_decision(packet, reason) do + %{ + "schema" => @schema, + "status" => "blocked", + "review_packet_id" => value_at(packet, ["review_packet_id"]), + "profile_instance_id" => value_at(packet, ["evidence_packet", "production_claim", "profile_instance_id"]), + "provider_entries" => [], + "evidence_summary" => %{ + "scenario_evidence_count" => count_at(packet, ["evidence_packet", "scenario_evidence"]), + "non_claim_acknowledgement_count" => count_at(packet, ["evidence_packet", "non_claim_acknowledgements"]) + }, + "owner_signoffs" => owner_signoffs(packet), + "blockers" => blockers(reason), + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false + } + end + + defp provider_entries(evidence_packet) do + evidence_packet + |> value_at(["runbook", "entries"]) + |> case do + entries when is_list(entries) -> + Enum.map(entries, &provider_entry/1) + + _missing -> + [] + end + end + + defp provider_entry(entry) when is_map(entry) do + %{ + "entry_id" => Map.get(entry, "entry_id"), + "workflow_profile" => Map.get(entry, "workflow_profile"), + "tracker" => provider_kind(Map.get(entry, "tracker")), + "repo_provider" => provider_kind(Map.get(entry, "repo_provider")), + "agent_provider" => provider_kind(Map.get(entry, "agent_provider")), + "side_effect_mode" => Map.get(entry, "side_effect_mode"), + "topology_mode" => value_at(entry, ["topology", "mode"]), + "non_claims" => Map.get(entry, "non_claims", []) + } + end + + defp evidence_summary(evidence_packet) do + %{ + "scenario_evidence_count" => count_at(evidence_packet, ["scenario_evidence"]), + "non_claim_acknowledgement_count" => count_at(evidence_packet, ["non_claim_acknowledgements"]) + } + end + + defp owner_signoffs(packet) do + packet + |> value_at(["owner_signoffs"]) + |> case do + signoffs when is_list(signoffs) -> + Enum.map(signoffs, fn signoff -> + %{ + "role" => value_at(signoff, ["role"]), + "owner" => value_at(signoff, ["owner"]), + "decision" => value_at(signoff, ["decision"]) + } + end) + + _missing -> + [] + end + end + + defp provider_kind(%{"kind" => kind}), do: %{"kind" => kind} + defp provider_kind(_provider), do: %{"kind" => nil} + + defp blockers(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &blocker/1) + + defp blocker(error) when is_map(error) do + %{ + "code" => Map.get(error, :code) || Map.get(error, "code") || "invalid", + "path" => Map.get(error, :path) || Map.get(error, "path") || [], + "message" => Map.get(error, :message) || Map.get(error, "message") || "Review packet is invalid." + } + end + + defp count_at(map, path) do + case value_at(map, path) do + values when is_list(values) -> length(values) + _missing -> 0 + end + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet.ex new file mode 100644 index 0000000..fee7de5 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet.ex @@ -0,0 +1,528 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacket do + @moduledoc """ + Admission checks for final Coding PR Delivery production review packets. + + This validator ties together the completed Phase 2 evidence packet with the + Phase 4 review metadata required for a production decision. It validates + packet structure only; it does not read evidence files, call providers, mutate + workflow state, or enable production gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + EvidencePacket, + PreflightReport + } + + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + @schema "coding_pr_delivery.production_review_packet.v1" + @error_code "coding_pr_delivery_review_packet_invalid" + @operator_inspection_schema "workflow.execution_plan.operator_inspection.v1" + @required_scrubbing_boundaries [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + @authority_boundary_flags [ + "prompt_wording_authoritative", + "workpad_markdown_authoritative", + "raw_provider_passthrough_authorized", + "schema_support_alone_sufficient" + ] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate(map()) :: validation_result() + def validate(packet) when is_map(packet) do + evidence_result = packet |> value_at(["evidence_packet"]) |> EvidencePacket.validate() + preflight_reports_result = packet |> value_at(["provider_preflight_reports"]) |> validate_preflight_reports() + + errors = + [] + |> collect_required_string(packet, ["review_packet_id"]) + |> collect_string_list(packet, ["changed_source_specs"], "Changed source specs must be a non-empty string array.") + |> collect_string_list(packet, ["implementation_refs"], "Implementation refs must be a non-empty string array.") + |> collect_deterministic_test_matrix(packet) + |> collect_nested_errors(evidence_result, ["evidence_packet"]) + |> collect_preflight_reports(preflight_reports_result, evidence_result) + |> collect_rollback_instructions(packet) + |> collect_scrubbing_pipeline(packet) + |> collect_operator_inspection(packet) + |> collect_retention_policy(packet) + |> collect_authority_boundaries(packet) + |> collect_owner_signoffs(packet) + + if errors == [] do + {:ok, normalize(packet, evidence_result, preflight_reports_result)} + else + {:error, invalid(errors)} + end + end + + def validate(_packet) do + {:error, invalid([issue("invalid_type", [], "Review packet must be an object.")])} + end + + defp collect_deterministic_test_matrix(errors, packet) do + matrix = value_at(packet, ["deterministic_test_matrix"]) + + cond do + not is_list(matrix) or matrix == [] -> + errors ++ [issue("required_field_missing", ["deterministic_test_matrix"], "Deterministic test matrix must be a non-empty array.")] + + true -> + errors ++ + (matrix + |> Enum.with_index() + |> Enum.flat_map(fn {entry, index} -> deterministic_test_entry_errors(entry, index) end)) + end + end + + defp deterministic_test_entry_errors(entry, index) when is_map(entry) do + path = ["deterministic_test_matrix", index] + + [] + |> collect_required_string(entry, path ++ ["command"]) + |> collect_required_string(entry, path ++ ["status"]) + |> maybe_add( + value_at(entry, ["status"]) != "passed", + issue("test_not_passed", path ++ ["status"], "Deterministic test matrix entries must be passed.") + ) + end + + defp deterministic_test_entry_errors(_entry, index) do + [issue("invalid_type", ["deterministic_test_matrix", index], "Deterministic test matrix entry must be an object.")] + end + + defp collect_rollback_instructions(errors, packet) do + rollback = value_at(packet, ["rollback_instructions"]) + + errors + |> collect_required_map(packet, ["rollback_instructions"]) + |> collect_required_string(rollback, ["rollback_instructions", "owner"]) + |> maybe_add( + value_at(rollback, ["external_transition_readiness_gate"]) != Gates.transition_readiness_required_gate_key(), + issue("invalid_transition_readiness_gate", ["rollback_instructions", "external_transition_readiness_gate"], "Rollback instructions must use the external transition readiness gate key.") + ) + |> maybe_add( + value_at(rollback, ["legacy_review_handoff_required_mapping"]) != true, + issue( + "missing_legacy_gate_mapping", + ["rollback_instructions", "legacy_review_handoff_required_mapping"], + "Rollback instructions must record the review_handoff_required to external gate mapping." + ) + ) + |> collect_string_list(rollback, ["rollback_instructions", "disable_gates"], "Rollback disable gates must be a non-empty string array.") + |> collect_required_gate(rollback, ["rollback_instructions", "disable_gates"], Gates.transition_readiness_required_gate_key()) + end + + defp collect_scrubbing_pipeline(errors, packet) do + scrubbing = value_at(packet, ["scrubbing_pipeline"]) + + errors + |> collect_required_map(packet, ["scrubbing_pipeline"]) + |> collect_required_string(scrubbing, ["scrubbing_pipeline", "owner"]) + |> collect_required_string(scrubbing, ["scrubbing_pipeline", "pattern_catalog_version"]) + |> collect_string_list(scrubbing, ["scrubbing_pipeline", "pattern_catalog_rules"], "Scrubbing pattern catalog rules must be a non-empty string array.") + |> collect_required_scrubbing_rules(scrubbing) + |> maybe_add( + value_at(scrubbing, ["failure_behavior"]) != "fail_closed", + issue("scrubbing_not_fail_closed", ["scrubbing_pipeline", "failure_behavior"], "Scrubbing pipeline must fail closed.") + ) + |> collect_string_list(scrubbing, ["scrubbing_pipeline", "enforced_boundaries"], "Scrubbing enforced boundaries must be a non-empty string array.") + |> collect_required_boundaries(scrubbing) + |> collect_test_results(scrubbing, ["scrubbing_pipeline", "test_results"]) + end + + defp collect_required_boundaries(errors, scrubbing) do + boundaries = value_at(scrubbing, ["enforced_boundaries"]) + + if is_list(boundaries) do + @required_scrubbing_boundaries + |> Enum.reject(&(&1 in boundaries)) + |> Enum.map(fn boundary -> + issue("missing_scrubbing_boundary", ["scrubbing_pipeline", "enforced_boundaries"], "Required scrubbing boundary is missing.", %{boundary: boundary}) + end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp collect_required_scrubbing_rules(errors, scrubbing) do + rules = value_at(scrubbing, ["pattern_catalog_rules"]) + + if is_list(rules) do + Governance.required_scrubbing_pattern_rules() + |> Enum.reject(&(&1 in rules)) + |> Enum.map(fn rule -> + issue("missing_scrubbing_pattern_rule", ["scrubbing_pipeline", "pattern_catalog_rules", rule], "Required scrubbing pattern rule is missing.") + end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp collect_operator_inspection(errors, packet) do + inspection = value_at(packet, ["operator_inspection"]) + gate_values = value_at(inspection, ["gate_values"]) + + errors + |> collect_required_map(packet, ["operator_inspection"]) + |> maybe_add( + value_at(inspection, ["schema"]) != @operator_inspection_schema, + issue("invalid_operator_inspection_schema", ["operator_inspection", "schema"], "Operator inspection packet schema is invalid.") + ) + |> maybe_add( + not is_map(gate_values), + issue("required_field_missing", ["operator_inspection", "gate_values"], "Operator inspection gate values must be an object.") + ) + |> maybe_add( + is_map(gate_values) and Map.has_key?(gate_values, "review_handoff_required"), + issue("legacy_gate_name", ["operator_inspection", "gate_values", "review_handoff_required"], "Legacy review_handoff_required gate must not appear in production review packets.") + ) + |> maybe_add( + is_map(gate_values) and not Map.has_key?(gate_values, Gates.transition_readiness_required_gate_key()), + issue("missing_transition_readiness_gate", ["operator_inspection", "gate_values"], "Operator inspection must include the external transition readiness gate.") + ) + |> maybe_add( + value_at(inspection, ["contains_raw_evidence_payload"]) != false, + issue("raw_evidence_payload_present", ["operator_inspection", "contains_raw_evidence_payload"], "Operator inspection must not include raw evidence payloads.") + ) + |> maybe_add( + value_at(inspection, ["workpad_markdown_authoritative"]) != false, + issue("workpad_authority", ["operator_inspection", "workpad_markdown_authoritative"], "Workpad Markdown must not be authoritative.") + ) + end + + defp collect_retention_policy(errors, packet) do + retention = value_at(packet, ["retention_policy"]) + days = value_at(retention, ["retention_period_days"]) + + errors + |> collect_required_map(packet, ["retention_policy"]) + |> collect_required_string(retention, ["retention_policy", "retention_class"]) + |> collect_required_string(retention, ["retention_policy", "cleanup_owner"]) + |> maybe_add( + not is_integer(days) or days <= 0, + issue("invalid_retention_period", ["retention_policy", "retention_period_days"], "Retention period must be a positive integer.") + ) + |> maybe_add( + value_at(retention, ["tombstone_preserving"]) != true, + issue("retention_without_tombstones", ["retention_policy", "tombstone_preserving"], "Retention policy must preserve tombstones.") + ) + end + + defp collect_authority_boundaries(errors, packet) do + boundaries = value_at(packet, ["authority_boundaries"]) + + errors = + collect_required_map(errors, packet, ["authority_boundaries"]) + + if is_map(boundaries) do + @authority_boundary_flags + |> Enum.filter(&(value_at(boundaries, [&1]) != false)) + |> Enum.map(fn flag -> + issue("invalid_authority_boundary", ["authority_boundaries", flag], "Production review packet must explicitly reject non-canonical authority.", %{flag: flag}) + end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp collect_owner_signoffs(errors, packet) do + signoffs = value_at(packet, ["owner_signoffs"]) + + cond do + not is_list(signoffs) or signoffs == [] -> + errors ++ [issue("required_field_missing", ["owner_signoffs"], "Owner sign-offs must be a non-empty array.")] + + true -> + errors ++ + (signoffs + |> Enum.with_index() + |> Enum.flat_map(fn {signoff, index} -> signoff_errors(signoff, index) end)) + end + end + + defp signoff_errors(signoff, index) when is_map(signoff) do + path = ["owner_signoffs", index] + approved_at = value_at(signoff, ["approved_at"]) + + [] + |> collect_required_string(signoff, path ++ ["role"]) + |> collect_required_string(signoff, path ++ ["owner"]) + |> collect_required_string(signoff, path ++ ["approved_at"]) + |> maybe_add( + non_empty_string?(approved_at) and not valid_datetime?(approved_at), + issue("invalid_timestamp", path ++ ["approved_at"], "Approved-at timestamp must be ISO8601.") + ) + |> maybe_add( + value_at(signoff, ["decision"]) != "approved", + issue("signoff_not_approved", path ++ ["decision"], "Owner sign-off decision must be approved.") + ) + end + + defp signoff_errors(_signoff, index) do + [issue("invalid_type", ["owner_signoffs", index], "Owner sign-off must be an object.")] + end + + defp validate_preflight_reports(reports) do + cond do + not is_list(reports) or reports == [] -> + {:error, + [ + issue( + "required_field_missing", + ["provider_preflight_reports"], + "Provider preflight reports must be a non-empty array." + ) + ]} + + true -> + reports + |> Enum.with_index() + |> Enum.reduce({[], []}, fn {report, index}, {normalized, errors} -> + case validate_preflight_report(report, index) do + {:ok, normalized_report} -> {[normalized_report | normalized], errors} + {:error, report_errors} -> {normalized, errors ++ report_errors} + end + end) + |> case do + {normalized, []} -> {:ok, Enum.reverse(normalized)} + {_normalized, errors} -> {:error, errors} + end + end + end + + defp validate_preflight_report(report, index) when is_map(report) do + case PreflightReport.validate(report) do + {:ok, normalized_report} -> + {:ok, normalized_report} + + {:error, %{errors: nested_errors}} when is_list(nested_errors) -> + {:error, Enum.map(nested_errors, &prefix_error(&1, ["provider_preflight_reports", index]))} + end + end + + defp validate_preflight_report(_report, index) do + {:error, [issue("invalid_type", ["provider_preflight_reports", index], "Provider preflight report must be an object.")]} + end + + defp collect_preflight_reports(errors, {:error, preflight_errors}, _evidence_result) do + errors ++ preflight_errors + end + + defp collect_preflight_reports(errors, {:ok, preflight_reports}, {:ok, evidence_packet}) do + errors + |> collect_preflight_report_statuses(preflight_reports) + |> collect_preflight_report_coverage(preflight_reports, evidence_packet) + end + + defp collect_preflight_reports(errors, {:ok, preflight_reports}, _invalid_evidence_result) do + collect_preflight_report_statuses(errors, preflight_reports) + end + + defp collect_preflight_report_statuses(errors, preflight_reports) do + preflight_reports + |> Enum.with_index() + |> Enum.filter(fn {report, _index} -> Map.get(report, "status") != "passed" end) + |> Enum.map(fn {_report, index} -> + issue("preflight_report_not_passed", ["provider_preflight_reports", index, "status"], "Provider preflight reports must pass before Phase 4 review.") + end) + |> then(&(errors ++ &1)) + end + + defp collect_preflight_report_coverage(errors, preflight_reports, evidence_packet) do + covered_entry_ids = + preflight_reports + |> Enum.flat_map(&preflight_report_provider_entry_ids/1) + |> MapSet.new() + + evidence_packet + |> value_at(["production_claim", "provider_matrix"]) + |> case do + provider_entries when is_list(provider_entries) -> + provider_entries + |> Enum.map(&Map.get(&1, "id")) + |> Enum.filter(&non_empty_string?/1) + |> Enum.reject(&MapSet.member?(covered_entry_ids, &1)) + |> Enum.map(fn entry_id -> + issue( + "missing_provider_preflight_report", + ["provider_preflight_reports"], + "Every provider matrix entry in the evidence packet must have a passed preflight report.", + %{provider_matrix_entry_id: entry_id} + ) + end) + |> then(&(errors ++ &1)) + + _missing -> + errors + end + end + + defp preflight_report_provider_entry_ids(report) do + report + |> value_at(["phase2_evidence_plan", "provider_plans"]) + |> case do + provider_plans when is_list(provider_plans) -> + Enum.flat_map(provider_plans, fn plan -> + case Map.get(plan, "provider_matrix_entry_ids") do + entry_ids when is_list(entry_ids) -> entry_ids + _missing -> [] + end + end) + + _missing -> + [] + end + |> Enum.filter(&non_empty_string?/1) + end + + defp collect_test_results(errors, map, path) do + results = value_at(map, [List.last(path)]) + + cond do + not is_list(results) or results == [] -> + errors ++ [issue("required_field_missing", path, "Test results must be a non-empty array.")] + + true -> + errors ++ + (results + |> Enum.with_index() + |> Enum.flat_map(fn {result, index} -> test_result_errors(result, path, index) end)) + end + end + + defp test_result_errors(result, path, index) when is_map(result) do + result_path = path ++ [index] + + [] + |> collect_required_string(result, result_path ++ ["name"]) + |> collect_required_string(result, result_path ++ ["status"]) + |> maybe_add( + value_at(result, ["status"]) != "passed", + issue("test_not_passed", result_path ++ ["status"], "Test result must be passed.") + ) + end + + defp test_result_errors(_result, path, index) do + [issue("invalid_type", path ++ [index], "Test result must be an object.")] + end + + defp collect_required_gate(errors, map, path, gate_key) do + gates = value_at(map, [List.last(path)]) + + if is_list(gates) and gate_key not in gates do + errors ++ [issue("missing_rollback_gate", path, "Rollback instructions must disable the transition readiness gate.", %{gate: gate_key})] + else + errors + end + end + + defp normalize(packet, {:ok, evidence_packet}, {:ok, preflight_reports}) do + %{ + "schema" => @schema, + "review_packet_id" => value_at(packet, ["review_packet_id"]), + "profile_instance_id" => Map.get(evidence_packet, "profile_instance_id"), + "evidence_packet" => evidence_packet, + "provider_preflight_reports" => preflight_reports, + "changed_source_specs" => value_at(packet, ["changed_source_specs"]), + "implementation_refs" => value_at(packet, ["implementation_refs"]), + "deterministic_test_matrix" => value_at(packet, ["deterministic_test_matrix"]), + "rollback_instructions" => value_at(packet, ["rollback_instructions"]), + "scrubbing_pipeline" => value_at(packet, ["scrubbing_pipeline"]), + "operator_inspection" => value_at(packet, ["operator_inspection"]), + "retention_policy" => value_at(packet, ["retention_policy"]), + "authority_boundaries" => value_at(packet, ["authority_boundaries"]), + "owner_signoffs" => value_at(packet, ["owner_signoffs"]) + } + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery review packet is invalid.", + errors: errors + } + end + + defp collect_nested_errors(errors, {:ok, _value}, _path), do: errors + + defp collect_nested_errors(errors, {:error, %{errors: nested_errors}}, path) when is_list(nested_errors) do + errors ++ Enum.map(nested_errors, &prefix_error(&1, path)) + end + + defp prefix_error(error, path) do + %{ + code: error_code(error), + path: path ++ error_path(error), + message: error_message(error) + } + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, [List.last(path)])), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, [List.last(path)]) + + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message, extra \\ %{}) do + Map.merge( + %{ + code: code, + path: path, + message: message + }, + extra + ) + end + + defp valid_datetime?(value) when is_binary(value) do + match?({:ok, _datetime, _offset}, DateTime.from_iso8601(value)) + end + + defp valid_datetime?(_value), do: false + + defp string_list?(value) when is_list(value), do: Enum.all?(value, &non_empty_string?/1) + defp string_list?(_value), do: false + + defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != "" + defp non_empty_string?(_value), do: false + + defp error_code(error), do: Map.get(error, :code) || Map.get(error, "code") || "invalid" + defp error_path(error), do: Map.get(error, :path) || Map.get(error, "path") || [] + defp error_message(error), do: Map.get(error, :message) || Map.get(error, "message") || "Invalid review packet." + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_template.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_template.ex new file mode 100644 index 0000000..567b42d --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_template.ex @@ -0,0 +1,219 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacketTemplate do + @moduledoc """ + Builds Phase 4 review-packet fill templates from completed evidence packets. + + The template is a bounded handoff for reviewers. It validates the completed + evidence packet first, then projects the review-packet fields that can be + derived from the accepted evidence. It does not approve the packet, read + evidence files, call providers, mutate workflow state, or enable gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacket + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + @schema "coding_pr_delivery.production_review_packet_template.v1" + @completed_packet_schema "coding_pr_delivery.production_review_packet.v1" + @operator_inspection_schema "workflow.execution_plan.operator_inspection.v1" + @required_scrubbing_boundaries [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + @authority_boundary_flags [ + "prompt_wording_authoritative", + "workpad_markdown_authoritative", + "raw_provider_passthrough_authorized", + "schema_support_alone_sufficient" + ] + + @type result :: {:ok, map()} | {:error, map()} + + @spec build(map()) :: result() + def build(evidence_packet) when is_map(evidence_packet) do + with {:ok, normalized_evidence_packet} <- EvidencePacket.validate(evidence_packet) do + {:ok, template(normalized_evidence_packet)} + end + end + + def build(evidence_packet), do: EvidencePacket.validate(evidence_packet) + + defp template(evidence_packet) do + claim = Map.get(evidence_packet, "production_claim", %{}) + provider_entries = Map.get(claim, "provider_matrix", []) + governance_packets = Map.get(claim, "production_governance", []) + + %{ + "schema" => @schema, + "completed_packet_schema" => @completed_packet_schema, + "profile_instance_id" => Map.get(evidence_packet, "profile_instance_id"), + "template_authority" => "review_packet_shape_only", + "does_not_read_evidence_files" => true, + "evidence_packet" => evidence_packet, + "review_packet_field_template" => %{ + "review_packet_id" => "fill-review-packet-id", + "changed_source_specs" => [], + "implementation_refs" => [], + "deterministic_test_matrix" => [], + "evidence_packet" => evidence_packet, + "provider_preflight_reports" => [], + "rollback_instructions" => rollback_instructions(provider_entries), + "scrubbing_pipeline" => scrubbing_pipeline(governance_packets), + "operator_inspection" => operator_inspection(provider_entries), + "retention_policy" => retention_policy(governance_packets), + "authority_boundaries" => authority_boundaries(), + "owner_signoffs" => [] + }, + "fields_to_complete" => [ + "review_packet_id", + "changed_source_specs", + "implementation_refs", + "deterministic_test_matrix", + "provider_preflight_reports", + "scrubbing_pipeline.test_results", + "owner_signoffs" + ] + } + end + + defp rollback_instructions(provider_entries) do + %{ + "owner" => rollback_owner(provider_entries), + "external_transition_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "legacy_review_handoff_required_mapping" => true, + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key(), + Gates.render_workpad_gate_key(), + Gates.provider_adapters_enabled_gate_key() + ] + } + end + + defp rollback_owner(provider_entries) do + provider_entries + |> Enum.map(&value_at(&1, ["rollback", "owner"])) + |> first_non_empty("workflow-runtime") + end + + defp scrubbing_pipeline(governance_packets) do + pipelines = Enum.map(governance_packets, &value_at(&1, ["data_governance", "scrubbing_pipeline"])) + owner = pipelines |> Enum.map(&value_at(&1, ["owner"])) |> first_non_empty("workflow-runtime-security") + catalog = pipelines |> Enum.map(&value_at(&1, ["pattern_catalog_version"])) |> first_non_empty("fill-pattern-catalog-version") + + %{ + "owner" => owner, + "pattern_catalog_version" => catalog, + "pattern_catalog_rules" => scrubbing_pattern_rules(pipelines), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => scrubbing_boundaries(pipelines), + "test_results" => [], + "source_provider_matrix_entry_ids" => provider_matrix_entry_ids(governance_packets) + } + end + + defp scrubbing_pattern_rules(pipelines) do + pipelines + |> Enum.flat_map(fn pipeline -> + case value_at(pipeline, ["pattern_catalog_rules"]) do + rules when is_list(rules) -> rules + _missing -> [] + end + end) + |> Kernel.++(Governance.required_scrubbing_pattern_rules()) + |> unique_strings() + end + + defp scrubbing_boundaries(pipelines) do + pipelines + |> Enum.flat_map(fn pipeline -> + case value_at(pipeline, ["enforced_boundaries"]) do + boundaries when is_list(boundaries) -> boundaries + _missing -> [] + end + end) + |> Kernel.++(@required_scrubbing_boundaries) + |> unique_strings() + end + + defp operator_inspection(provider_entries) do + %{ + "schema" => @operator_inspection_schema, + "gate_values" => first_gate_values(provider_entries), + "candidate_gate_values_by_entry" => gate_values_by_entry(provider_entries), + "contains_raw_evidence_payload" => false, + "workpad_markdown_authoritative" => false + } + end + + defp first_gate_values(provider_entries) do + provider_entries + |> Enum.map(&value_at(&1, ["structured_plan_gates"])) + |> Enum.find(&is_map/1) + |> case do + nil -> %{Gates.transition_readiness_required_gate_key() => false} + gates -> gates + end + end + + defp gate_values_by_entry(provider_entries) do + Enum.map(provider_entries, fn entry -> + %{ + "provider_matrix_entry_id" => Map.get(entry, "id"), + "gate_values" => Map.get(entry, "structured_plan_gates", %{}) + } + end) + end + + defp retention_policy(governance_packets) do + governance = Enum.find(governance_packets, &is_map/1) || %{} + data_governance = value_at(governance, ["data_governance"]) || %{} + + %{ + "retention_class" => value_at(data_governance, ["retention_class"]) || "fill-retention-class", + "retention_period_days" => value_at(data_governance, ["retention_period_days"]) || 1, + "cleanup_owner" => value_at(data_governance, ["cleanup_owner"]) || "workflow-runtime", + "tombstone_preserving" => value_at(data_governance, ["tombstone", "preserves_audit_trail"]) == true, + "source_provider_matrix_entry_ids" => provider_matrix_entry_ids(governance_packets) + } + end + + defp authority_boundaries do + Map.new(@authority_boundary_flags, &{&1, false}) + end + + defp provider_matrix_entry_ids(governance_packets) do + governance_packets + |> Enum.map(&value_at(&1, ["provider_matrix_entry_id"])) + |> unique_strings() + end + + defp first_non_empty(values, default) do + Enum.find(values, default, &non_empty_string?/1) + end + + defp unique_strings(values) do + values + |> Enum.filter(&non_empty_string?/1) + |> Enum.uniq() + end + + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/status_report.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/status_report.ex new file mode 100644 index 0000000..8a00df0 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/status_report.ex @@ -0,0 +1,231 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.StatusReport do + @moduledoc """ + Builds a bounded Coding PR Delivery production-profile status projection. + + The report reconciles a Phase 2 evidence plan, the static Phase 4 review plan, + and an optional provider preflight report. It summarizes why production review + is still blocked without reading evidence files, calling providers, mutating + workflow state, approving production, or enabling gates. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + Phase2EvidencePlan, + Phase4ReviewPlan, + PreflightReport + } + + @schema "coding_pr_delivery.production_profile_status.v1" + @phase2_schema "coding_pr_delivery.phase2_evidence_plan.v1" + @preflight_required_code "provider_preflight_report_required" + + @type input :: Phase2EvidencePlan.plan() | String.t() | map() + @type result :: {:ok, map()} | {:error, map()} + + @spec build(input(), keyword()) :: result() + def build(input, opts \\ []) + + def build(input, opts) when is_list(opts) do + with {:ok, phase2_plan} <- phase2_plan(input, opts), + {:ok, phase4_plan} <- Phase4ReviewPlan.build(phase2_plan, plan_opts(opts)) do + preflight = preflight_state(Keyword.get(opts, :preflight_report)) + blockers = reconcile_blockers(Map.get(phase4_plan, "blocking_requirements", []), preflight) + + {:ok, status_report(phase2_plan, phase4_plan, preflight, blockers)} + end + end + + def build(_input, _opts) do + {:error, invalid([issue("invalid_options", [], "Status report options must be a keyword list.")])} + end + + defp phase2_plan(%{"schema" => @phase2_schema} = plan, _opts), do: {:ok, plan} + defp phase2_plan(plan, opts), do: Phase2EvidencePlan.build(plan, plan_opts(opts)) + + defp plan_opts(opts) do + opts + |> Keyword.take([:plan_id, :tapd_cnb_shadow_run_id, :linear_cnb_shadow_run_id]) + end + + defp status_report(phase2_plan, phase4_plan, preflight, blockers) do + %{ + "schema" => @schema, + "status" => if(blockers == [], do: "ready_for_phase4_review", else: "blocked"), + "phase4_ready" => blockers == [], + "phase2_plan_id" => Map.get(phase2_plan, "plan_id"), + "phase2_plan_kind" => Map.get(phase2_plan, "plan_kind"), + "phase4_plan_id" => Map.get(phase4_plan, "plan_id"), + "provider_plan_count" => length(Map.get(phase2_plan, "provider_plans", [])), + "provider_entries" => provider_entries(phase2_plan), + "preflight" => preflight_summary(preflight), + "blockers" => blockers, + "does_not_collect_live_evidence" => true, + "does_not_read_evidence_files" => true, + "does_not_call_providers" => true, + "does_not_mutate_workflow_state" => true, + "does_not_approve_production" => true, + "does_not_enable_production" => true, + "raw_input_included" => false, + "normalized_artifacts_included" => false + } + end + + defp provider_entries(phase2_plan) do + phase2_plan + |> Map.get("provider_plans", []) + |> Enum.map(fn provider_plan -> + %{ + "template" => Map.get(provider_plan, "template"), + "tier" => Map.get(provider_plan, "tier"), + "provider_matrix_entry_ids" => Map.get(provider_plan, "provider_matrix_entry_ids", []), + "tracker_kinds" => Map.get(provider_plan, "tracker_kinds", []), + "repo_provider_kinds" => Map.get(provider_plan, "repo_provider_kinds", []), + "side_effect_modes" => Map.get(provider_plan, "side_effect_modes", []), + "live_evidence_status" => Map.get(provider_plan, "live_evidence_status") + } + end) + end + + defp preflight_state(nil), do: %{provided?: false, valid?: false, status: "missing", results: [], errors: []} + + defp preflight_state(report) do + case PreflightReport.validate(report) do + {:ok, normalized} -> + %{ + provided?: true, + valid?: true, + status: Map.get(normalized, "status"), + planned_count: Map.get(normalized, "planned_preflight_command_count", 0), + result_count: Map.get(normalized, "preflight_result_count", 0), + results: Map.get(normalized, "provider_preflight_results", []), + errors: [] + } + + {:error, reason} -> + %{provided?: true, valid?: false, status: "invalid", results: [], errors: errors(reason)} + end + end + + defp preflight_summary(%{provided?: false}) do + %{ + "provided" => false, + "valid" => false, + "status" => "missing", + "planned_preflight_command_count" => 0, + "preflight_result_count" => 0, + "passed_count" => 0, + "blocked_count" => 0, + "blocked_results" => [], + "errors" => [] + } + end + + defp preflight_summary(%{valid?: false, errors: errors}) do + %{ + "provided" => true, + "valid" => false, + "status" => "invalid", + "planned_preflight_command_count" => 0, + "preflight_result_count" => 0, + "passed_count" => 0, + "blocked_count" => 0, + "blocked_results" => [], + "errors" => errors + } + end + + defp preflight_summary(%{results: results} = preflight) do + blocked_results = Enum.filter(results, &(Map.get(&1, "status") == "blocked")) + + %{ + "provided" => true, + "valid" => true, + "status" => Map.get(preflight, :status), + "planned_preflight_command_count" => Map.get(preflight, :planned_count, 0), + "preflight_result_count" => Map.get(preflight, :result_count, 0), + "passed_count" => Enum.count(results, &(Map.get(&1, "status") == "passed")), + "blocked_count" => length(blocked_results), + "blocked_results" => Enum.map(blocked_results, &blocked_preflight_summary/1), + "errors" => [] + } + end + + defp blocked_preflight_summary(result) do + %{ + "template" => Map.get(result, "template"), + "command_id" => Map.get(result, "command_id"), + "target" => Map.get(result, "target"), + "provider_kind" => Map.get(result, "provider_kind"), + "blocker_code" => Map.get(result, "blocker_code"), + "missing_prerequisites" => Map.get(result, "missing_prerequisites", []) + } + end + + defp reconcile_blockers(blockers, %{provided?: false}), do: blockers + + defp reconcile_blockers(blockers, %{valid?: false, errors: errors}) do + blockers + |> without_preflight_required() + |> then(&[preflight_invalid_blocker(errors) | &1]) + end + + defp reconcile_blockers(blockers, %{status: "passed"}), do: without_preflight_required(blockers) + + defp reconcile_blockers(blockers, %{status: "blocked", results: results}) do + preflight_blockers = + results + |> Enum.filter(&(Map.get(&1, "status") == "blocked")) + |> Enum.map(&preflight_blocked_blocker/1) + + preflight_blockers ++ without_preflight_required(blockers) + end + + defp reconcile_blockers(blockers, _preflight), do: blockers + + defp without_preflight_required(blockers) do + Enum.reject(blockers, &(Map.get(&1, "code") == @preflight_required_code)) + end + + defp preflight_invalid_blocker(errors) do + %{ + "code" => "provider_preflight_report_invalid", + "message" => "Provider preflight report must validate before completed evidence collection.", + "error_count" => length(errors), + "errors" => errors + } + end + + defp preflight_blocked_blocker(result) do + %{ + "code" => "provider_preflight_blocked", + "message" => "Provider preflight remains blocked by missing prerequisites.", + "template" => Map.get(result, "template"), + "command_id" => Map.get(result, "command_id"), + "provider_kind" => Map.get(result, "provider_kind"), + "target" => Map.get(result, "target"), + "blocker_code" => Map.get(result, "blocker_code"), + "missing_prerequisites" => Map.get(result, "missing_prerequisites", []) + } + end + + defp errors(%{errors: errors}) when is_list(errors), do: Enum.map(errors, &error_to_map/1) + + defp error_to_map(error) when is_map(error) do + error + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Map.new() + end + + defp error_to_map(_error), do: %{"code" => "status_report_error", "message" => "Status report failed."} + + defp invalid(errors) do + %{ + code: "coding_pr_delivery_status_report_invalid", + message: "Coding PR Delivery production-profile status report is invalid.", + errors: errors + } + end + + defp issue(code, path, message, meta \\ %{}) do + %{code: code, path: path, message: message, meta: meta} + end +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception.ex new file mode 100644 index 0000000..c7301f5 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception.ex @@ -0,0 +1,346 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.TypedToolException do + @moduledoc """ + Admission checks for scoped non-typed production tool exceptions. + + Production Coding PR Delivery defaults to typed tracker, repo-core, and + repo-provider tools. This module validates exception evidence records before a + production claim can cite them; it does not enable raw provider execution. + """ + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Profile.Contract, as: ProfileContract + + @error_code "coding_pr_delivery_typed_tool_exception_invalid" + @broad_operation_tokens ["*", "all", "any", "raw_provider_passthrough", "provider_native_api", "provider_native_cli"] + @placeholder_tokens ["fill-", "TODO", "REPLACE", "<", ">"] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec validate_record(map()) :: validation_result() + def validate_record(record) when is_map(record) do + errors = + [] + |> collect_required_string(record, ["exception_id"]) + |> collect_workflow_profile(record) + |> collect_provider_kind(record, "tracker") + |> collect_provider_kind(record, "repo_provider") + |> collect_provider_kind(record, "agent_provider") + |> collect_required_string(record, ["repository_class"]) + |> collect_required_string(record, ["workspace_class"]) + |> collect_route_set(record) + |> collect_operation_set(record) + |> collect_fallback_authority(record) + |> collect_string_list(record, ["compensating_controls"], "Compensating controls must be a non-empty string array.") + |> collect_input_schema_allowlist(record) + |> collect_limits(record) + |> collect_audit_logging(record) + |> collect_string_list(record, ["deterministic_tests"], "Deterministic tests must be a non-empty string array.") + |> collect_string_list(record, ["real_integration_evidence"], "Real integration evidence must be a non-empty string array.") + |> collect_evidence_refs(record, ["real_integration_evidence"]) + |> collect_operator_observability(record) + |> collect_rollback(record) + |> collect_expiry_or_review(record) + |> collect_forbidden_passthrough(record) + + if errors == [] do + {:ok, normalize(record)} + else + {:error, invalid(errors)} + end + end + + def validate_record(_record) do + {:error, invalid([issue("invalid_type", [], "Typed-tool production exception record must be an object.")])} + end + + defp collect_workflow_profile(errors, record) do + workflow_profile = value_at(record, ["workflow_profile"]) + + errors + |> collect_required_map(record, ["workflow_profile"]) + |> maybe_add( + value_at(workflow_profile, ["kind"]) != ProfileContract.kind(), + issue("invalid_workflow_profile", ["workflow_profile", "kind"], "Workflow profile kind must be coding_pr_delivery.") + ) + |> maybe_add( + value_at(workflow_profile, ["version"]) != ProfileContract.version(), + issue("invalid_workflow_profile", ["workflow_profile", "version"], "Workflow profile version is unsupported.") + ) + end + + defp collect_provider_kind(errors, record, provider_key) do + errors + |> collect_required_map(record, [provider_key]) + |> collect_required_string(record, [provider_key, "kind"]) + end + + defp collect_route_set(errors, record) do + route_set = value_at(record, ["route_set"]) + allowed = allowed_route_keys() + + errors = + maybe_add( + errors, + not string_list?(route_set) or route_set == [], + issue("required_field_missing", ["route_set"], "Route set must be a non-empty string array.") + ) + + if is_list(route_set) do + route_set + |> Enum.reject(&(&1 in allowed)) + |> Enum.map(fn route -> issue("invalid_route_key", ["route_set", route], "Route key is outside the Coding PR Delivery profile.") end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp collect_operation_set(errors, record) do + operation_set = value_at(record, ["operation_set"]) + + errors = + maybe_add( + errors, + not string_list?(operation_set) or operation_set == [], + issue("required_field_missing", ["operation_set"], "Operation set must be a non-empty string array.") + ) + + if is_list(operation_set) do + operation_set + |> Enum.filter(&broad_operation?/1) + |> Enum.map(fn operation -> + issue("overbroad_operation_scope", ["operation_set", operation], "Exception operation set must not authorize broad raw provider passthrough.") + end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp collect_fallback_authority(errors, record) do + authority = value_at(record, ["fallback_authority"]) + + errors + |> collect_required_map(record, ["fallback_authority"]) + |> collect_required_string(record, ["fallback_authority", "owner"]) + |> collect_required_string(record, ["fallback_authority", "authority_kind"]) + |> maybe_add( + value_at(authority, ["accepted_by_profile_owners"]) != true, + issue("owner_approval_required", ["fallback_authority", "accepted_by_profile_owners"], "Fallback authority must be accepted by profile owners.") + ) + end + + defp collect_input_schema_allowlist(errors, record) do + allowlist = value_at(record, ["input_schema_allowlist"]) + + errors + |> collect_required_map(record, ["input_schema_allowlist"]) + |> collect_string_list(record, ["input_schema_allowlist", "schema_ids"], "Input schema allowlist must name at least one schema id.") + |> maybe_add( + value_at(allowlist, ["rejects_unknown_fields"]) != true, + issue("strict_schema_required", ["input_schema_allowlist", "rejects_unknown_fields"], "Input schema allowlist must reject unknown fields.") + ) + end + + defp collect_limits(errors, record) do + errors + |> collect_required_map(record, ["limits"]) + |> collect_positive_integer(record, ["limits", "max_calls_per_run"]) + |> collect_positive_integer(record, ["limits", "max_concurrency"]) + end + + defp collect_audit_logging(errors, record) do + errors + |> collect_required_map(record, ["audit_logging"]) + |> collect_required_string(record, ["audit_logging", "event_name"]) + |> collect_required_string(record, ["audit_logging", "retention_class"]) + end + + defp collect_operator_observability(errors, record) do + errors + |> collect_required_map(record, ["operator_observability"]) + |> collect_string_list(record, ["operator_observability", "metrics"], "Operator observability metrics must be a non-empty string array.") + |> collect_string_list(record, ["operator_observability", "alerts"], "Operator observability alerts must be a non-empty string array.") + |> collect_required_string(record, ["operator_observability", "runbook"]) + end + + defp collect_rollback(errors, record) do + rollback = value_at(record, ["rollback"]) + + errors + |> collect_required_map(record, ["rollback"]) + |> collect_required_string(record, ["rollback", "owner"]) + |> collect_required_string(record, ["rollback", "instructions"]) + |> maybe_add( + value_at(rollback, ["disables_exception"]) != true, + issue("rollback_must_disable_exception", ["rollback", "disables_exception"], "Rollback must disable the production exception.") + ) + |> maybe_add( + value_at(rollback, ["restores_typed_tool_requirement"]) != true, + issue("rollback_must_restore_typed_tools", ["rollback", "restores_typed_tool_requirement"], "Rollback must restore typed-tool requirements.") + ) + end + + defp collect_expiry_or_review(errors, record) do + expires_at = value_at(record, ["expires_at"]) + trigger = value_at(record, ["re_review_trigger"]) + + cond do + non_empty_string?(expires_at) and String.downcase(String.trim(expires_at)) != "never" -> + errors + + is_map(trigger) -> + errors + |> collect_required_string(record, ["re_review_trigger", "condition"]) + |> collect_required_string(record, ["re_review_trigger", "owner"]) + + true -> + errors ++ [issue("expiry_or_re_review_required", [], "Exception record must include an expiry or re-review trigger.")] + end + end + + defp collect_forbidden_passthrough(errors, record) do + errors + |> maybe_add( + value_at(record, ["raw_provider_passthrough"]) == true, + issue("raw_provider_passthrough_forbidden", ["raw_provider_passthrough"], "Production exception must not authorize arbitrary raw provider passthrough.") + ) + |> maybe_add( + value_at(record, ["provider_native_prompt_snippets"]) == true, + issue("provider_native_prompt_snippets_forbidden", ["provider_native_prompt_snippets"], "Production exception must not authorize provider-native API or CLI prompt snippets.") + ) + end + + defp normalize(record) do + %{ + "exception_id" => value_at(record, ["exception_id"]), + "workflow_profile" => value_at(record, ["workflow_profile"]), + "tracker" => %{"kind" => value_at(record, ["tracker", "kind"])}, + "repo_provider" => %{"kind" => value_at(record, ["repo_provider", "kind"])}, + "agent_provider" => %{"kind" => value_at(record, ["agent_provider", "kind"])}, + "repository_class" => value_at(record, ["repository_class"]), + "workspace_class" => value_at(record, ["workspace_class"]), + "route_set" => value_at(record, ["route_set"]), + "operation_set" => value_at(record, ["operation_set"]), + "fallback_authority" => value_at(record, ["fallback_authority"]), + "compensating_controls" => value_at(record, ["compensating_controls"]), + "input_schema_allowlist" => value_at(record, ["input_schema_allowlist"]), + "limits" => value_at(record, ["limits"]), + "audit_logging" => value_at(record, ["audit_logging"]), + "deterministic_tests" => value_at(record, ["deterministic_tests"]), + "real_integration_evidence" => value_at(record, ["real_integration_evidence"]), + "operator_observability" => value_at(record, ["operator_observability"]), + "rollback" => value_at(record, ["rollback"]), + "expires_at" => value_at(record, ["expires_at"]), + "re_review_trigger" => value_at(record, ["re_review_trigger"]) + } + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, path)), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, path)), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, path) + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp collect_evidence_refs(errors, map, path) do + refs = value_at(map, path) + + if is_list(refs) do + refs + |> Enum.with_index() + |> Enum.flat_map(fn {ref, index} -> evidence_ref_errors(ref, path ++ [index]) end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp evidence_ref_errors(ref, path) do + [] + |> maybe_add( + non_empty_string?(ref) and not allowed_evidence_ref?(ref), + issue("invalid_evidence_ref", path, "Evidence references must be repository evidence paths or HTTP(S) links.") + ) + |> maybe_add( + non_empty_string?(ref) and placeholder_evidence_ref?(ref), + issue("placeholder_evidence_ref", path, "Evidence references must not contain placeholders.") + ) + end + + defp allowed_evidence_ref?(ref) do + String.starts_with?(ref, "evidence/") or + String.starts_with?(ref, "https://") or + String.starts_with?(ref, "http://") + end + + defp placeholder_evidence_ref?(ref) do + downcased = String.downcase(ref) + + String.starts_with?(downcased, "/tmp/") or + String.starts_with?(downcased, "tmp/") or + String.starts_with?(downcased, "/var/") or + String.starts_with?(downcased, "file://") or + Enum.any?(@placeholder_tokens, &String.contains?(downcased, String.downcase(&1))) + end + + defp collect_positive_integer(errors, map, path) do + maybe_add(errors, not positive_integer?(value_at(map, path)), issue("required_field_missing", path, "Field must be a positive integer.")) + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Coding PR Delivery typed-tool production exception is invalid.", + errors: errors + } + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message) do + %{ + code: code, + path: path, + message: message + } + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp allowed_route_keys, do: Enum.map(ProfileContract.route_keys(), &Atom.to_string/1) + + defp broad_operation?(operation) when is_binary(operation) do + operation + |> String.trim() + |> String.downcase() + |> then(&(&1 in @broad_operation_tokens)) + end + + defp broad_operation?(_operation), do: false + + defp positive_integer?(value), do: is_integer(value) and value > 0 + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" + defp string_list?(values), do: is_list(values) and Enum.all?(values, &non_empty_string?/1) +end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store.ex index b7bb085..1c9a6a6 100644 --- a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store.ex +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store.ex @@ -9,6 +9,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Readiness.Evidence package can provide another backend without changing policy code. """ + alias SymphonyElixir.Storage.Scrubber alias SymphonyElixir.Workflow.Extension.Diagnostics alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.HostAdapters.Readiness.EventEmitterDefaults alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.HostAdapters.Readiness.StateTransitionReadinessBackend @@ -51,7 +52,8 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Readiness.Evidence def record(keys, evidence, opts) when is_map(evidence) do with {:ok, opts} <- normalize_opts(opts), {:ok, backend} <- backend(opts), - :ok <- call_record(backend, keys, evidence, backend_opts(opts)) do + {:ok, scrubbed_evidence} <- Scrubber.scrub_map(evidence, opts), + :ok <- call_record(backend, keys, scrubbed_evidence, backend_opts(opts)) do :ok else {:error, reason} -> @@ -194,7 +196,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Readiness.Evidence defp emit_event_fn(_opts), do: &EventEmitterDefaults.emit/3 defp bounded_reason(reason) when is_map(reason) do - Map.take(reason, [:reason, :value_type, :backend_type, :return_type, :kind, :exception, :reason_type]) + Map.take(reason, [:code, :reason, :value_type, :backend_type, :return_type, :kind, :exception, :reason_type]) end defp empty_evidence do diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation.ex index 745f1a2..4eb5126 100644 --- a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation.ex +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation.ex @@ -18,6 +18,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation do alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.KnownTarget.Registration alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.Producer alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.Reconciler + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.RuntimeTopology alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Runtime.Input, as: RuntimeInput @spec reconcile_runtime(map(), RuntimeInput.t(), keyword()) :: Reconciler.result() @@ -35,4 +36,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation do @spec run_known_target_watcher_once(keyword()) :: Producer.Watcher.run_result() defdelegate run_known_target_watcher_once(opts \\ []), to: Producer.Watcher, as: :run_once + + @spec runtime_topology_readiness(term()) :: RuntimeTopology.readiness() + defdelegate runtime_topology_readiness(opts \\ []), to: RuntimeTopology, as: :readiness end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/contract.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/contract.ex index fd246f8..3afe8a6 100644 --- a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/contract.ex +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/contract.ex @@ -12,6 +12,10 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One @mode_dry_run "dry_run" @mode_invalid "invalid" @mode_state_write "state_write" + @shadow_prefix "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" + @shadow_mode "shadow_no_write" + @shadow_authority "diagnostic_only" + @shadow_allowed_destinations ["diagnostic_logs", "review_packets", "non_authoritative_evidence"] @spec probe_id( :config_validation @@ -39,4 +43,19 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One def mode_from_options(opts) when is_list(opts) do if Keyword.get(opts, :confirm_state_write, false), do: @mode_state_write, else: @mode_dry_run end + + @spec shadow_prefix() :: String.t() + def shadow_prefix, do: @shadow_prefix + + @spec shadow_mode() :: String.t() + def shadow_mode, do: @shadow_mode + + @spec shadow_authority() :: String.t() + def shadow_authority, do: @shadow_authority + + @spec shadow_allowed_destinations() :: [String.t()] + def shadow_allowed_destinations, do: @shadow_allowed_destinations + + @spec shadow_mode?(String.t()) :: boolean() + def shadow_mode?(mode), do: mode == @mode_dry_run end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/report.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/report.ex index 6142bcd..bc792b8 100644 --- a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/report.ex +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/report.ex @@ -3,10 +3,12 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One alias SymphonyElixir.Issue alias SymphonyElixir.Smoke.ResultStatus + alias SymphonyElixir.Storage.Scrubber alias SymphonyElixir.Tracker alias SymphonyElixir.Tracker.ProjectRef alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.Config, as: ReconciliationConfig alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.Contract + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Fields @type probe_result :: %{ @@ -26,6 +28,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One project_url: String.t() | nil, candidate_discovery: String.t() | nil, mode: String.t(), + shadow: map() | nil, ok: boolean(), duration_ms: non_neg_integer(), before_state: String.t() | nil, @@ -51,7 +54,8 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One [map()], [map()], [probe_result()], - integer() + integer(), + String.t() | nil ) :: t() def build( workflow_label, @@ -64,7 +68,8 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One issue_events, recent_events, probes, - duration_ms + duration_ms, + shadow_run_id \\ nil ) when is_list(issue_events) and is_list(recent_events) and is_list(probes) do passed_count = Enum.count(probes, & &1.ok) @@ -82,6 +87,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One project_url: project_ref_value(settings, :url), candidate_discovery: candidate_discovery(reconciliation_config), mode: mode, + shadow: shadow_metadata(mode, shadow_run_id), ok: failed_count == 0 and not transition_failed?(transition), duration_ms: max(duration_ms, 0), before_state: issue_state(before_issue), @@ -93,26 +99,26 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One probe_count: length(probes), passed_count: passed_count, failed_count: failed_count, - probes: probes + probes: Enum.map(probes, &scrub_map/1) } end @spec format_text(t()) :: String.t() def format_text(report) when is_map(report) do - report - |> text_lines() - |> Enum.join("\n") - |> Kernel.<>("\n") + Enum.map_join(text_lines(report), "\n", &scrub_text/1) <> "\n" end @spec to_map(t()) :: map() - def to_map(report) when is_map(report), do: report + def to_map(report) when is_map(report), do: scrub_map(report) defp text_lines(report) do - [text_header(report)] ++ - decision_text_line(report.decision) ++ - transition_text_line(report.transition) ++ - probe_text_lines(report.probes) + lines = + [text_header(report)] ++ + decision_text_line(report.decision) ++ + transition_text_line(report.transition) ++ + probe_text_lines(report.probes) + + shadow_prefixed_lines(report, lines) end defp text_header(report) do @@ -149,6 +155,22 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One end) end + defp shadow_prefixed_lines(report, lines) when is_map(report) and is_list(lines) do + case Map.get(report, :shadow) do + %{} = shadow -> + prefix = Map.get(shadow, "prefix") + run_id = Map.get(shadow, "run_id") + authority = Map.get(shadow, "authority") + + Enum.map(lines, fn line -> + "#{prefix} shadow_run_id=#{run_id} shadow_authority=#{authority} #{line}" + end) + + _shadow -> + lines + end + end + defp latest_transition_event(events) when is_list(events) do Enum.find(events, fn event -> Map.get(event, Fields.event()) in Contract.transition_events() end) end @@ -161,8 +183,38 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One defp summarize_event(event) when is_map(event) do Map.take(event, Fields.summary_fields()) + |> scrub_map() + end + + defp scrub_map(map) when is_map(map) do + case Scrubber.scrub_map(map) do + {:ok, scrubbed} -> scrubbed + {:error, reason} -> %{redaction_error: reason} + end + end + + defp scrub_text(text) when is_binary(text) do + case Scrubber.scrub(text) do + {:ok, scrubbed} when is_binary(scrubbed) -> scrubbed + _result -> "[REDACTED]" + end end + defp shadow_metadata(mode, run_id) when is_binary(run_id) do + if OneShotContract.shadow_mode?(mode) do + %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => run_id, + "mode" => OneShotContract.shadow_mode(), + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + end + end + + defp shadow_metadata(_mode, _run_id), do: nil + defp transition_failed?(%{} = transition), do: Map.get(transition, Fields.event()) == Contract.event_name(:transition_failed) defp transition_failed?(_transition), do: false diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/runner.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/runner.ex index b015153..8a7f092 100644 --- a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/runner.ex +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot/runner.ex @@ -27,6 +27,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One previous_workflow_env = deps.workflow_file_env.() issue_id = opts |> Keyword.get(:issue_id) |> normalize_optional_string() mode = Contract.mode_from_options(opts) + shadow_run_id = shadow_run_id(mode, started_at_ms) try do case WorkflowRef.resolve(opts, deps) do @@ -35,7 +36,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One {probes, settings, reconciliation_config} = run_config_probe(deps) - with_known_target_registry(deps, workflow_label, issue_id, mode, started_at_ms, settings, fn known_target_registry -> + with_known_target_registry(deps, workflow_label, issue_id, mode, started_at_ms, settings, shadow_run_id, fn known_target_registry -> {probes, before_issue} = append_issue_probe(probes, settings, issue_id, deps, Contract.probe_id(:fetch_before)) @@ -55,12 +56,13 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One deps.issue_events.(issue_id || ""), deps.recent_events.(), probes, - deps.monotonic_time_ms.() - started_at_ms + deps.monotonic_time_ms.() - started_at_ms, + shadow_run_id ) end) {:error, reason} -> - failure_report(issue_id, mode, Contract.probe_id(:workflow), reason, deps.monotonic_time_ms.() - started_at_ms) + failure_report(issue_id, mode, Contract.probe_id(:workflow), reason, deps.monotonic_time_ms.() - started_at_ms, shadow_run_id) end after deps.restore_workflow_file_env.(previous_workflow_env) @@ -81,7 +83,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One failure_report(nil, Contract.mode(:invalid), Contract.probe_id(:options), reason, 0) end - defp failure_report(issue_id, mode, probe_id, reason, duration_ms) do + defp failure_report(issue_id, mode, probe_id, reason, duration_ms, shadow_run_id \\ nil) do Report.build( nil, issue_id, @@ -93,7 +95,8 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One [], [], [Probe.failed(probe_id, reason)], - duration_ms + duration_ms, + shadow_run_id ) end @@ -195,12 +198,12 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One defp single_issue(issues) when is_list(issues), do: {:error, %{code: :unexpected_issue_count, count: length(issues)}} defp single_issue(_issues), do: {:error, %{code: :unexpected_issue_lookup_payload}} - defp with_known_target_registry(_deps, _workflow_label, _issue_id, _mode, _started_at_ms, nil, fun) + defp with_known_target_registry(_deps, _workflow_label, _issue_id, _mode, _started_at_ms, nil, _shadow_run_id, fun) when is_function(fun, 1) do fun.(nil) end - defp with_known_target_registry(deps, workflow_label, issue_id, mode, started_at_ms, settings, fun) + defp with_known_target_registry(deps, workflow_label, issue_id, mode, started_at_ms, settings, shadow_run_id, fun) when is_map(deps) and is_function(fun, 1) do case deps.start_known_target_registry.(settings) do {:ok, pid} when is_pid(pid) -> @@ -225,7 +228,8 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One [], [], [Probe.failed(Contract.probe_id(:known_target_registry), reason)], - deps.monotonic_time_ms.() - started_at_ms + deps.monotonic_time_ms.() - started_at_ms, + shadow_run_id ) end end @@ -253,4 +257,10 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One defp normalize_optional_string(value) when is_atom(value), do: value |> Atom.to_string() |> normalize_optional_string() defp normalize_optional_string(value) when is_integer(value), do: value |> Integer.to_string() |> normalize_optional_string() defp normalize_optional_string(_value), do: nil + + defp shadow_run_id(mode, started_at_ms) do + if Contract.shadow_mode?(mode) do + "shadow-#{abs(started_at_ms)}-#{:erlang.unique_integer([:positive, :monotonic])}" + end + end end diff --git a/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/runtime_topology.ex b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/runtime_topology.ex new file mode 100644 index 0000000..d661303 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/runtime_topology.ex @@ -0,0 +1,135 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.RuntimeTopology do + @moduledoc false + + alias SymphonyElixir.Workflow.Extension.Diagnostics + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.KnownTarget + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.Candidate.Inbox + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.Producer.Watcher + + @topology_singleton "singleton" + @ready_status "ready" + @blocked_status "blocked" + + @type readiness :: %{ + required(:ready?) => boolean(), + required(:status) => String.t(), + required(:topology) => String.t() | nil, + required(:process_local_state) => boolean(), + required(:writers) => [map()], + optional(:reason) => atom(), + optional(:value_type) => atom() + } + + @spec readiness(term()) :: readiness() + def readiness(opts \\ []) + + def readiness(opts) when is_list(opts) do + if Keyword.keyword?(opts) do + opts + |> normalize_topology() + |> build_readiness(opts) + else + blocked(nil, [], :options_not_keyword, Diagnostics.type_atom(opts)) + end + end + + def readiness(opts), do: blocked(nil, [], :options_not_keyword, Diagnostics.type_atom(opts)) + + defp build_readiness({:ok, @topology_singleton = topology}, opts) do + writers = Enum.map(writer_specs(opts), fn {id, server} -> writer_status(id, server) end) + + case Enum.find(writers, &Map.has_key?(&1, :reason)) do + nil -> + %{ + ready?: true, + status: @ready_status, + topology: topology, + process_local_state: true, + ownership: "single_active_writer_per_node", + writers: writers + } + + %{reason: reason} = writer -> + blocked(topology, writers, reason, Map.get(writer, :value_type)) + end + end + + defp build_readiness({:error, reason, value_type}, _opts), do: blocked(nil, [], reason, value_type) + + defp normalize_topology(opts) do + case Keyword.fetch(opts, :topology) do + {:ok, :singleton} -> {:ok, @topology_singleton} + {:ok, @topology_singleton} -> {:ok, @topology_singleton} + {:ok, value} -> {:error, :singleton_topology_required, Diagnostics.type_atom(value)} + :error -> {:error, :singleton_topology_required, nil} + end + end + + defp writer_specs(opts) do + [ + candidate_inbox: Keyword.get(opts, :inbox, Inbox), + known_target_registry: Keyword.get(opts, :known_target_registry, KnownTarget.Registry), + known_target_watcher: Keyword.get(opts, :watcher, Watcher) + ] + end + + defp writer_status(id, server) when is_atom(server) and not is_nil(server) do + case Process.whereis(server) do + pid when is_pid(pid) -> + %{ + id: Atom.to_string(id), + server: inspect(server), + registered?: true, + alive?: true, + ownership: "process_local_registered_singleton" + } + + _missing -> + %{ + id: Atom.to_string(id), + server: inspect(server), + registered?: true, + alive?: false, + reason: :writer_unavailable + } + end + end + + defp writer_status(id, server) when is_pid(server) do + %{ + id: Atom.to_string(id), + server: "pid", + registered?: false, + alive?: Process.alive?(server), + reason: :writer_not_registered + } + end + + defp writer_status(id, server) do + %{ + id: Atom.to_string(id), + server: "invalid", + registered?: false, + alive?: false, + reason: :invalid_writer_server, + value_type: Diagnostics.type_atom(server) + } + end + + defp blocked(topology, writers, reason, nil) do + %{ + ready?: false, + status: @blocked_status, + topology: topology, + process_local_state: true, + reason: reason, + writers: writers + } + end + + defp blocked(topology, writers, reason, value_type) do + topology + |> blocked(writers, reason, nil) + |> Map.put(:value_type, value_type) + end +end diff --git a/elixir/lib/symphony_elixir/workflow/structured_execution_plan.ex b/elixir/lib/symphony_elixir/workflow/structured_execution_plan.ex index 7b404f4..a38433d 100644 --- a/elixir/lib/symphony_elixir/workflow/structured_execution_plan.ex +++ b/elixir/lib/symphony_elixir/workflow/structured_execution_plan.ex @@ -8,6 +8,8 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan do """ alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.OperatorInspection + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance alias SymphonyElixir.Workflow.StructuredExecutionPlan.Schema alias SymphonyElixir.Workflow.StructuredExecutionPlan.Store @@ -41,4 +43,10 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan do @spec append_evidence_ref(String.t(), String.t(), map(), pos_integer(), keyword()) :: {:ok, map()} | {:error, map()} def append_evidence_ref(plan_id, item_id, evidence_ref, expected_revision, opts \\ []), do: Store.append_evidence_ref(plan_id, item_id, evidence_ref, expected_revision, opts) + + @spec operator_inspection(map(), keyword()) :: {:ok, map()} | {:error, map()} + def operator_inspection(plan, opts \\ []), do: OperatorInspection.build(plan, opts) + + @spec validate_production_governance(map()) :: {:ok, map()} | {:error, map()} + def validate_production_governance(packet), do: Governance.validate_packet(packet) end diff --git a/elixir/lib/symphony_elixir/workflow/structured_execution_plan/operator_inspection.ex b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/operator_inspection.ex new file mode 100644 index 0000000..4b4f50d --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/operator_inspection.ex @@ -0,0 +1,293 @@ +defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.OperatorInspection do + @moduledoc """ + Operator-facing inspection packet for canonical structured execution plans. + + The inspection packet is a bounded projection. It reads canonical plan fields, + evidence-ref metadata, gate values, rollback values, and render markers; it + never imports Workpad Markdown or exposes raw evidence payloads. + """ + + alias SymphonyElixir.Agent.ExecutionPlan.Fields, as: AgentFields + alias SymphonyElixir.Storage.Scrubber + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Fields + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Reconciler.Freshness + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Workpad.Contract, as: WorkpadContract + + @schema "workflow.execution_plan.operator_inspection.v1" + @max_items 100 + @max_evidence_refs 200 + @max_rejected_updates 50 + + @plan_keys [ + Fields.plan_id(), + Fields.run_id(), + Fields.issue_id(), + Fields.issue_identifier(), + Fields.tracker_kind(), + Fields.workflow_profile(), + Fields.route_key(), + Fields.lifecycle_phase(), + Fields.status(), + Fields.created_at(), + Fields.updated_at(), + Fields.revision() + ] + + @render_marker_keys [ + Fields.schema(), + Fields.plan_id(), + WorkpadContract.plan_revision_key(), + Fields.tracker_kind(), + WorkpadContract.mode_key(), + WorkpadContract.rendered_item_count_key(), + WorkpadContract.fingerprint_key(), + WorkpadContract.workpad_id_key() + ] + + @spec build(map(), keyword()) :: {:ok, map()} | {:error, map()} + def build(plan, opts \\ []) + + def build(plan, opts) when is_map(plan) and is_list(opts) do + gates = Keyword.get(opts, :gates, Contract.gate_defaults()) + gate_values = gate_values(gates) + items = Map.get(plan, Fields.items(), []) + stale_item_ids = stale_item_ids(items) + + packet = + %{ + "schema" => @schema, + "plan" => Map.take(plan, @plan_keys), + "gate_values" => gate_values, + "gate_validation" => validate_gates(gates), + "rollback_gate_values" => rollback_gate_values(gate_values), + "required_item_statuses" => required_item_statuses(items, stale_item_ids), + "evidence_refs" => evidence_ref_summaries(items), + "freshness_state" => freshness_state(stale_item_ids), + "latest_render_marker" => latest_render_marker(plan), + "readiness_gate_result" => scrub_optional_map(Keyword.get(opts, :readiness_gate_result)), + "rejected_updates" => scrub_list(Keyword.get(opts, :rejected_updates, [])) + } + + {:ok, packet} + end + + def build(_plan, _opts) do + {:error, + %{ + code: "operator_inspection_invalid", + message: "Operator inspection requires a structured execution plan object." + }} + end + + @spec validate_gates(term()) :: map() + def validate_gates(gates) when is_map(gates) do + errors = + gate_missing_errors(gates) ++ + gate_value_errors(gates) ++ + unknown_gate_errors(gates) + + %{ + "valid" => errors == [], + "errors" => errors + } + end + + def validate_gates(_gates) do + %{ + "valid" => false, + "errors" => [ + %{ + "code" => "invalid_gate_values", + "path" => ["gates"], + "message" => "Structured execution plan gates must be an object." + } + ] + } + end + + defp gate_values(gates) when is_map(gates) do + Contract.gate_keys() + |> Map.new(fn gate -> {gate, Map.get(gates, gate, false) == true} end) + end + + defp gate_values(_gates), do: Contract.gate_defaults() + + defp rollback_gate_values(gates) when is_map(gates) do + %{ + "disable_recording_gate" => rollback_gate(Contract.enabled_gate_key(), gates), + "disable_rendering_gate" => rollback_gate(Contract.render_workpad_gate_key(), gates), + "disable_readiness_gate" => rollback_gate(Contract.transition_readiness_required_gate_key(), gates), + "disable_provider_adapters_gate" => rollback_gate(Contract.provider_adapters_enabled_gate_key(), gates) + } + end + + defp rollback_gate(gate, gates) do + %{ + "gate" => gate, + "current_value" => Map.get(gates, gate, false) == true, + "rollback_value" => false + } + end + + defp required_item_statuses(items, stale_item_ids) do + items + |> Enum.filter(&operator_relevant_item?/1) + |> Enum.take(@max_items) + |> Enum.map(fn item -> + evidence_refs = evidence_refs(item) + item_id = Map.get(item, AgentFields.item_id()) + + %{ + "item_id" => item_id, + "title" => Map.get(item, AgentFields.title()), + "status" => Map.get(item, AgentFields.status()), + "required" => Map.get(item, AgentFields.required()) == true, + "criticality" => Map.get(item, AgentFields.criticality()), + "owned_by" => Map.get(item, AgentFields.owned_by()), + "source" => Map.get(item, AgentFields.source()), + "evidence_ref_count" => length(evidence_refs), + "evidence_kinds" => evidence_refs |> Enum.map(&Map.get(&1, AgentFields.evidence_kind())) |> Enum.reject(&is_nil/1) |> Enum.uniq(), + "freshness" => if(item_id in stale_item_ids, do: "stale", else: "fresh") + } + end) + end + + defp operator_relevant_item?(item) when is_map(item) do + Map.get(item, AgentFields.required()) == true or + Contract.evidence_required_criticality?(Map.get(item, AgentFields.criticality())) + end + + defp evidence_ref_summaries(items) do + items + |> Enum.flat_map(fn item -> + item_id = Map.get(item, AgentFields.item_id()) + + item + |> evidence_refs() + |> Enum.map(&evidence_ref_summary(item_id, &1)) + end) + |> Enum.take(@max_evidence_refs) + end + + defp evidence_ref_summary(item_id, ref) do + %{ + "item_id" => item_id, + "evidence_id" => Map.get(ref, AgentFields.evidence_id()), + "evidence_kind" => Map.get(ref, AgentFields.evidence_kind()), + "source" => Map.get(ref, AgentFields.source()), + "producer" => Map.get(ref, AgentFields.producer()), + "observed_at" => Map.get(ref, AgentFields.observed_at()), + "payload_present" => is_map(Map.get(ref, AgentFields.payload())), + "payload_key_count" => payload_key_count(Map.get(ref, AgentFields.payload())) + } + end + + defp freshness_state([]) do + %{ + "status" => "fresh", + "stale_item_ids" => [] + } + end + + defp freshness_state(stale_item_ids) do + %{ + "status" => "stale", + "stale_item_ids" => stale_item_ids + } + end + + defp latest_render_marker(plan) do + case Map.get(plan, Fields.rendering()) do + marker when is_map(marker) -> Map.take(marker, @render_marker_keys) + _marker -> nil + end + end + + defp stale_item_ids(items) when is_list(items) do + items + |> Enum.filter(&Freshness.stale?(&1, items)) + |> Enum.map(&Map.get(&1, AgentFields.item_id())) + |> Enum.reject(&is_nil/1) + end + + defp scrub_optional_map(nil), do: nil + + defp scrub_optional_map(value) when is_map(value) do + case Scrubber.scrub_map(value) do + {:ok, scrubbed} -> scrubbed + {:error, reason} -> %{"redaction_error" => reason} + end + end + + defp scrub_optional_map(value), do: scrub_optional_map(%{"value_type" => type_name(value)}) + + defp scrub_list(values) when is_list(values) do + values + |> Enum.take(@max_rejected_updates) + |> Enum.map(fn + value when is_map(value) -> scrub_optional_map(value) + value -> %{"value_type" => type_name(value)} + end) + end + + defp scrub_list(_values), do: [] + + defp gate_missing_errors(gates) do + Contract.gate_keys() + |> Enum.reject(&Map.has_key?(gates, &1)) + |> Enum.map(fn gate -> + %{ + "code" => "missing_gate_value", + "path" => ["gates", gate], + "message" => "Structured execution plan gate value is missing." + } + end) + end + + defp gate_value_errors(gates) do + gates + |> Map.take(Contract.gate_keys()) + |> Enum.reject(fn {_gate, value} -> is_boolean(value) end) + |> Enum.map(fn {gate, _value} -> + %{ + "code" => "invalid_gate_value", + "path" => ["gates", gate], + "message" => "Structured execution plan gate value must be boolean." + } + end) + end + + defp unknown_gate_errors(gates) do + gates + |> Map.keys() + |> Enum.reject(&(&1 in Contract.gate_keys())) + |> Enum.map(fn gate -> + %{ + "code" => "unknown_gate_key", + "path" => ["gates", gate], + "message" => "Structured execution plan gate key is not supported." + } + end) + end + + defp evidence_refs(item) when is_map(item) do + case Map.get(item, AgentFields.evidence_refs()) do + refs when is_list(refs) -> refs + _refs -> [] + end + end + + defp payload_key_count(payload) when is_map(payload), do: map_size(payload) + defp payload_key_count(_payload), do: 0 + + defp type_name(value) when is_binary(value), do: "string" + defp type_name(value) when is_atom(value), do: "atom" + defp type_name(value) when is_integer(value), do: "integer" + defp type_name(value) when is_float(value), do: "float" + defp type_name(value) when is_boolean(value), do: "boolean" + defp type_name(value) when is_list(value), do: "list" + defp type_name(value) when is_map(value), do: "map" + defp type_name(value) when is_tuple(value), do: "tuple" + defp type_name(_value), do: "term" +end diff --git a/elixir/lib/symphony_elixir/workflow/structured_execution_plan/production_profile/governance.ex b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/production_profile/governance.ex new file mode 100644 index 0000000..6e262d3 --- /dev/null +++ b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/production_profile/governance.ex @@ -0,0 +1,321 @@ +defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance do + @moduledoc """ + Production governance evidence checks for structured execution plans. + + This module validates the retention, tombstone, cleanup, and rollback portions + of a production enablement packet. It is intentionally pure: it does not prune, + redact, delete, or mutate stored plan records. + """ + + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract + + @schema "workflow.execution_plan.production_governance.v1" + @error_code "structured_execution_plan_production_governance_invalid" + @required_tombstone_fields [ + "plan_id", + "run_id", + "issue_id", + "workflow_profile", + "route_key", + "retention_class", + "tombstoned_at", + "tombstoned_by", + "tombstone_reason" + ] + @required_scrubbing_pattern_rules [ + "api_keys", + "bearer_tokens", + "jwt", + "passwords", + "private_keys", + "connection_strings", + "cloud_credentials", + "provider_auth_material" + ] + @placeholder_tokens ["fill-", "TODO", "REPLACE", "<", ">"] + + @type validation_result :: {:ok, map()} | {:error, map()} + + @spec schema() :: String.t() + def schema, do: @schema + + @spec required_tombstone_fields() :: [String.t()] + def required_tombstone_fields, do: @required_tombstone_fields + + @spec required_scrubbing_pattern_rules() :: [String.t()] + def required_scrubbing_pattern_rules, do: @required_scrubbing_pattern_rules + + @spec validate_packet(map()) :: validation_result() + def validate_packet(packet) when is_map(packet) do + errors = + [] + |> collect_required_string(packet, ["profile_instance_id"]) + |> collect_durability_profile(packet) + |> collect_data_governance(packet) + |> collect_rollback(packet) + |> collect_string_list(packet, ["evidence_files"], "Evidence files must be a non-empty string array.") + |> collect_evidence_refs(packet, ["evidence_files"]) + + if errors == [] do + {:ok, normalize_packet(packet)} + else + {:error, invalid(errors)} + end + end + + def validate_packet(_packet) do + {:error, invalid([issue("invalid_type", [], "Production governance packet must be an object.")])} + end + + defp collect_durability_profile(errors, packet) do + profile = value_at(packet, ["durability_profile"]) + + errors + |> collect_required_map(packet, ["durability_profile"]) + |> collect_required_string(packet, ["durability_profile", "storage_backend"]) + |> collect_required_string(packet, ["durability_profile", "restart_behavior"]) + |> collect_required_string(packet, ["durability_profile", "backup_restore"]) + |> collect_required_string(packet, ["durability_profile", "retention_class"]) + |> collect_positive_integer(packet, ["durability_profile", "retention_period_days"]) + |> collect_required_string(packet, ["durability_profile", "cleanup_behavior"]) + |> collect_required_boolean(profile, ["durability_profile", "survives_process_restart"]) + |> collect_required_boolean(profile, ["durability_profile", "survives_worker_restart"]) + |> collect_required_boolean(profile, ["durability_profile", "survives_service_restart"]) + end + + defp collect_data_governance(errors, packet) do + data_governance = value_at(packet, ["data_governance"]) + + errors + |> collect_required_map(packet, ["data_governance"]) + |> collect_required_string(packet, ["data_governance", "data_classification"]) + |> collect_required_string(packet, ["data_governance", "redaction_policy"]) + |> collect_required_string(packet, ["data_governance", "retention_class"]) + |> collect_positive_integer(packet, ["data_governance", "retention_period_days"]) + |> collect_required_string(packet, ["data_governance", "cleanup_owner"]) + |> collect_required_string(packet, ["data_governance", "cleanup_schedule"]) + |> collect_scrubbing_pipeline(data_governance) + |> collect_tombstone(data_governance) + end + + defp collect_scrubbing_pipeline(errors, data_governance) do + pipeline = value_at(data_governance, ["scrubbing_pipeline"]) + + errors + |> collect_required_map(data_governance, ["scrubbing_pipeline"]) + |> collect_required_string(data_governance, ["scrubbing_pipeline", "owner"]) + |> collect_required_string(data_governance, ["scrubbing_pipeline", "pattern_catalog_version"]) + |> collect_string_list(data_governance, ["scrubbing_pipeline", "pattern_catalog_rules"], "Scrubbing pattern catalog rules must be a non-empty string array.") + |> collect_required_scrubbing_rules(pipeline) + |> collect_string_list(data_governance, ["scrubbing_pipeline", "enforced_boundaries"], "Scrubbing enforced boundaries must be a non-empty string array.") + |> maybe_add( + value_at(pipeline, ["failure_behavior"]) != "fail_closed", + issue("invalid_scrubbing_failure_behavior", ["data_governance", "scrubbing_pipeline", "failure_behavior"], "Scrubbing failure behavior must fail closed.") + ) + end + + defp collect_required_scrubbing_rules(errors, pipeline) do + rules = value_at(pipeline, ["pattern_catalog_rules"]) + + if is_list(rules) do + @required_scrubbing_pattern_rules + |> Enum.reject(&(&1 in rules)) + |> Enum.map(fn rule -> + issue("missing_scrubbing_pattern_rule", ["data_governance", "scrubbing_pipeline", "pattern_catalog_rules", rule], "Required scrubbing pattern rule is missing.") + end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp collect_tombstone(errors, data_governance) do + tombstone = value_at(data_governance, ["tombstone"]) + fields = value_at(tombstone, ["metadata_fields"]) + + errors + |> collect_required_map(data_governance, ["tombstone"]) + |> maybe_add( + not string_list?(fields) or fields == [], + issue("required_field_missing", ["data_governance", "tombstone", "metadata_fields"], "Tombstone metadata fields must be a non-empty string array.") + ) + |> collect_required_tombstone_fields(fields) + |> collect_true(tombstone, ["prevents_id_reuse"], ["data_governance", "tombstone", "prevents_id_reuse"], "Tombstones must prevent plan id reuse.") + |> collect_true( + tombstone, + ["prevents_hidden_readiness_claims"], + ["data_governance", "tombstone", "prevents_hidden_readiness_claims"], + "Tombstones must prevent hidden readiness claims." + ) + |> collect_true(tombstone, ["preserves_audit_trail"], ["data_governance", "tombstone", "preserves_audit_trail"], "Tombstones must preserve audit trail context.") + end + + defp collect_required_tombstone_fields(errors, fields) when is_list(fields) do + missing = + @required_tombstone_fields + |> Enum.reject(&(&1 in fields)) + |> Enum.map(fn field -> + issue("missing_tombstone_metadata", ["data_governance", "tombstone", "metadata_fields", field], "Required tombstone metadata field is missing.") + end) + + errors ++ missing + end + + defp collect_required_tombstone_fields(errors, _fields), do: errors + + defp collect_rollback(errors, packet) do + rollback = value_at(packet, ["rollback"]) + + errors + |> collect_required_map(packet, ["rollback"]) + |> collect_true( + rollback, + ["preserves_stored_plans_and_evidence"], + ["rollback", "preserves_stored_plans_and_evidence"], + "Rollback must preserve stored plans and evidence." + ) + |> collect_false(rollback, ["deletes_records"], ["rollback", "deletes_records"], "Rollback must not delete plan, evidence, or audit records.") + |> collect_false(rollback, ["rewrites_evidence"], ["rollback", "rewrites_evidence"], "Rollback must not rewrite evidence.") + |> collect_false( + rollback, + ["makes_workpad_authoritative"], + ["rollback", "makes_workpad_authoritative"], + "Rollback must not make Workpad Markdown authoritative." + ) + |> collect_rollback_gate(rollback, "disable_recording_gate", Contract.enabled_gate_key()) + |> collect_rollback_gate(rollback, "disable_rendering_gate", Contract.render_workpad_gate_key()) + |> collect_rollback_gate(rollback, "disable_readiness_gate", Contract.transition_readiness_required_gate_key()) + |> collect_rollback_gate(rollback, "disable_provider_adapters_gate", Contract.provider_adapters_enabled_gate_key()) + end + + defp collect_rollback_gate(errors, rollback, key, expected) do + maybe_add( + errors, + value_at(rollback, [key]) != expected, + issue("invalid_rollback_gate", ["rollback", key], "Rollback gate must use the canonical external gate key.") + ) + end + + defp normalize_packet(packet) do + %{ + "schema" => @schema, + "profile_instance_id" => value_at(packet, ["profile_instance_id"]), + "durability_profile" => value_at(packet, ["durability_profile"]), + "data_governance" => value_at(packet, ["data_governance"]), + "rollback" => value_at(packet, ["rollback"]), + "evidence_files" => value_at(packet, ["evidence_files"]) + } + end + + defp collect_required_map(errors, map, path) do + maybe_add(errors, not is_map(value_at(map, path)), issue("required_field_missing", path, "Field must be an object.")) + end + + defp collect_required_string(errors, map, path) do + maybe_add(errors, not non_empty_string?(value_at(map, path)), issue("required_field_missing", path, "Field must be a non-empty string.")) + end + + defp collect_positive_integer(errors, map, path) do + maybe_add(errors, not positive_integer?(value_at(map, path)), issue("required_field_missing", path, "Field must be a positive integer.")) + end + + defp collect_required_boolean(errors, map, path) do + maybe_add(errors, not is_boolean(value_at(map, path_from_root(path))), issue("required_field_missing", path, "Field must be a boolean.")) + end + + defp collect_string_list(errors, map, path, message) do + value = value_at(map, path) + maybe_add(errors, not string_list?(value) or value == [], issue("required_field_missing", path, message)) + end + + defp collect_evidence_refs(errors, map, path) do + refs = value_at(map, path) + + if is_list(refs) do + refs + |> Enum.with_index() + |> Enum.flat_map(fn {ref, index} -> evidence_ref_errors(ref, path ++ [index]) end) + |> then(&(errors ++ &1)) + else + errors + end + end + + defp evidence_ref_errors(ref, path) do + [] + |> maybe_add( + non_empty_string?(ref) and not allowed_evidence_ref?(ref), + issue("invalid_evidence_ref", path, "Evidence references must be repository evidence paths or HTTP(S) links.") + ) + |> maybe_add( + non_empty_string?(ref) and placeholder_evidence_ref?(ref), + issue("placeholder_evidence_ref", path, "Evidence references must not contain placeholders.") + ) + end + + defp allowed_evidence_ref?(ref) do + String.starts_with?(ref, "evidence/") or + String.starts_with?(ref, "https://") or + String.starts_with?(ref, "http://") + end + + defp placeholder_evidence_ref?(ref) do + downcased = String.downcase(ref) + + String.starts_with?(downcased, "/tmp/") or + String.starts_with?(downcased, "tmp/") or + String.starts_with?(downcased, "/var/") or + String.starts_with?(downcased, "file://") or + Enum.any?(@placeholder_tokens, &String.contains?(downcased, String.downcase(&1))) + end + + defp collect_true(errors, map, value_path, error_path, message) do + maybe_add(errors, value_at(map, value_path) != true, issue("required_field_missing", error_path, message)) + end + + defp collect_false(errors, map, value_path, error_path, message) do + maybe_add(errors, value_at(map, value_path) != false, issue("invalid_destructive_rollback", error_path, message)) + end + + defp invalid(errors) do + %{ + code: @error_code, + message: "Structured execution plan production governance packet is invalid.", + errors: errors + } + end + + defp maybe_add(errors, true, issue), do: errors ++ [issue] + defp maybe_add(errors, false, _issue), do: errors + + defp issue(code, path, message) do + %{ + code: code, + path: path, + message: message + } + end + + defp value_at(map, path) when is_map(map) and is_list(path) do + Enum.reduce_while(path, map, fn key, current -> + cond do + is_map(current) and Map.has_key?(current, key) -> + {:cont, Map.get(current, key)} + + is_map(current) and is_atom(key) and Map.has_key?(current, Atom.to_string(key)) -> + {:cont, Map.get(current, Atom.to_string(key))} + + true -> + {:halt, nil} + end + end) + end + + defp value_at(_map, _path), do: nil + + defp path_from_root([_root | rest]), do: rest + + defp positive_integer?(value), do: is_integer(value) and value > 0 + defp non_empty_string?(value), do: is_binary(value) and String.trim(value) != "" + defp string_list?(values), do: is_list(values) and Enum.all?(values, &non_empty_string?/1) +end diff --git a/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/commands.ex b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/commands.ex index 49362a4..3aa5a1d 100644 --- a/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/commands.ex +++ b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/commands.ex @@ -8,6 +8,7 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.Store.Commands do """ alias SymphonyElixir.Agent.ExecutionPlan.Fields, as: AgentFields + alias SymphonyElixir.Storage.Scrubber alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract alias SymphonyElixir.Workflow.StructuredExecutionPlan.Evidence alias SymphonyElixir.Workflow.StructuredExecutionPlan.Fields @@ -98,7 +99,8 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.Store.Commands do :ok <- Guards.plan_mutable(plan), :ok <- Guards.revision_matches(plan, expected_revision), {:ok, item} <- fetch_item(plan, item_id), - {:ok, updated_item} <- Evidence.append_ref(item, evidence_ref) do + {:ok, scrubbed_evidence_ref} <- Scrubber.scrub_map(evidence_ref, opts), + {:ok, updated_item} <- Evidence.append_ref(item, scrubbed_evidence_ref) do if updated_item == item do {:ok, state} else @@ -164,7 +166,8 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.Store.Commands do with {:ok, plan} <- Persistence.fetch(state, plan_id), :ok <- Guards.plan_mutable(plan), :ok <- Guards.revision_matches(plan, expected_revision), - {:ok, valid_event} <- ProviderSessionEvent.validate(event), + {:ok, scrubbed_event} <- Scrubber.scrub_map(event, opts), + {:ok, valid_event} <- ProviderSessionEvent.validate(scrubbed_event), {:ok, updated_plan} <- ProviderSessionEvents.record(plan, valid_event, opts), {:ok, valid_plan} <- Schema.validate(updated_plan), {:ok, _agent_plan} <- Persistence.replace_projected_plan(state, valid_plan, expected_revision) do diff --git a/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/evidence_refs.ex b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/evidence_refs.ex index 31e9af5..4f7f360 100644 --- a/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/evidence_refs.ex +++ b/elixir/lib/symphony_elixir/workflow/structured_execution_plan/store/evidence_refs.ex @@ -9,6 +9,7 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.Store.EvidenceRefs do alias SymphonyElixir.Agent.ExecutionPlan.Contract, as: AgentContract alias SymphonyElixir.Agent.ExecutionPlan.Fields, as: AgentFields + alias SymphonyElixir.Storage.Scrubber alias SymphonyElixir.Workflow.StructuredExecutionPlan.Evidence alias SymphonyElixir.Workflow.StructuredExecutionPlan.Fields alias SymphonyElixir.Workflow.StructuredExecutionPlan.Reconciler @@ -17,7 +18,8 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.Store.EvidenceRefs do @spec record_and_reconcile(map(), [map()], keyword()) :: {:ok, map()} | {:error, map()} def record_and_reconcile(plan, evidence_refs, opts) when is_map(plan) and is_list(evidence_refs) and is_list(opts) do - with {:ok, valid_refs} <- validate_evidence_refs(evidence_refs), + with {:ok, scrubbed_refs} <- Scrubber.scrub_map_list(evidence_refs, opts), + {:ok, valid_refs} <- validate_evidence_refs(scrubbed_refs), :ok <- ensure_scope(plan, valid_refs), {:ok, updated_plan} <- record_refs_and_reconcile(plan, valid_refs, opts) do {:ok, updated_plan} diff --git a/elixir/lib/symphony_elixir/workflow/template/assets.ex b/elixir/lib/symphony_elixir/workflow/template/assets.ex index 161085a..09e8ee9 100644 --- a/elixir/lib/symphony_elixir/workflow/template/assets.ex +++ b/elixir/lib/symphony_elixir/workflow/template/assets.ex @@ -7,51 +7,13 @@ defmodule SymphonyElixir.Workflow.Template.Assets do contributions own concrete template entries. """ - alias SymphonyElixir.Workflow.Template.PathRules + alias SymphonyElixir.Platform.PrivAssets @default_otp_app :symphony_elixir @spec app_priv_root!(Path.t(), keyword()) :: Path.t() def app_priv_root!(relative_dir, opts \\ []) when is_binary(relative_dir) and is_list(opts) do otp_app = Keyword.get(opts, :otp_app, @default_otp_app) - - otp_app - |> priv_dir!() - |> Path.join(validate_relative_dir!(relative_dir)) - |> Path.expand() - end - - defp priv_dir!(otp_app) when is_atom(otp_app) and not is_nil(otp_app) do - case :code.priv_dir(otp_app) do - path when is_list(path) -> - to_string(path) - - {:error, reason} -> - raise ArgumentError, - "could not resolve priv directory for otp_app #{inspect(otp_app)}: #{inspect(reason)}" - end - end - - defp priv_dir!(otp_app) do - raise ArgumentError, "template asset otp_app must be an atom, got #{inspect(otp_app)}" - end - - defp validate_relative_dir!(relative_dir) do - relative_dir = String.trim(relative_dir) - segments = Path.split(relative_dir) - - cond do - relative_dir == "" -> - raise ArgumentError, "template asset root must be non-empty" - - Path.type(relative_dir) == :absolute -> - raise ArgumentError, "template asset root must be relative" - - PathRules.contains_forbidden_relative_segment?(segments) -> - raise ArgumentError, "template asset root must stay under the application priv directory" - - true -> - Path.join(segments) - end + PrivAssets.app_priv_root!(relative_dir, otp_app: otp_app) end end diff --git a/elixir/mix.exs b/elixir/mix.exs index 15fdc8a..78be76a 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -157,7 +157,7 @@ defmodule SymphonyElixir.MixProject do defp escript do [ app: nil, - include_priv_for: [:symphony_elixir], + include_priv_for: [:symphony_elixir, :exqlite], main_module: SymphonyElixir.CLI, emu_args: "+B i", name: "symphony", diff --git a/elixir/mix.lock b/elixir/mix.lock index 5e88da7..ba28a93 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -1,45 +1,45 @@ %{ - bandit: {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, - bunt: {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - cc_precompiler: {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - credo: {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, - date_time_parser: {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, - db_connection: {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, - decimal: {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - dialyxir: {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, - ecto: {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, - ecto_sql: {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, - ecto_sqlite3: {:hex, :ecto_sqlite3, "0.23.0", "79da75815627582f081f00d418c130c4cf587672b720b54e7a8798c6d46b5415", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "e97041bcec746ed525df7d9ad996fbae3b0660767f99fbe9e9b58d6208729703"}, - elixir_make: {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, - erlex: {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - exqlite: {:hex, :exqlite, "0.37.0", "701e7e02679e8c1bb6da331ea93d83b481c714b0831e82e2f8a73375b3d93a9e", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a44816dd0d234fba68c47a3609af61d306d24ef517a89bfaee4d6a811792d913"}, - file_system: {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - finch: {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, - fine: {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, - floki: {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, - hpax: {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - jason: {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - kday: {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, - lazy_html: {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, - mime: {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - mint: {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - mox: {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, - nimble_options: {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - nimble_ownership: {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, - nimble_pool: {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - phoenix: {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, - phoenix_html: {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, - phoenix_live_view: {:hex, :phoenix_live_view, "1.1.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"}, - phoenix_pubsub: {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, - phoenix_template: {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - plug: {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, - plug_crypto: {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - req: {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, - solid: {:hex, :solid, "1.2.2", "615d3fb75e12b575d99976ca49f242b1e603f98489d30bf8634b5ab47d85e33f", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "410d0af6c0cdfd9d58ed2d22158f4fb0733a49f7b59b8e3bdb26f05919ae38ae"}, - telemetry: {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - thousand_island: {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - websock: {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - websock_adapter: {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, - yamerl: {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - yaml_elixir: {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, + "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, + "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.23.0", "79da75815627582f081f00d418c130c4cf587672b720b54e7a8798c6d46b5415", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "e97041bcec746ed525df7d9ad996fbae3b0660767f99fbe9e9b58d6208729703"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "exqlite": {:hex, :exqlite, "0.38.0", "324bca1d9b26d34b02ea64bd3724302a7d11978c682bc4a945186aeae7f61adb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f3da7b6e7b08bd548c33a118890d0eb8c5395fe093b31c8b329663234d0e988e"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, + "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, + "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.32", "4977ad4cfab7868f3d2ba390a2cd6c62412614d48f798e85785602281a0a3827", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c9d8e76373414259ec5699a809991d1324808bee1d297730599e273282cee34"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.20.1", "82cdee1d7535d4f4db5c5602a7fd49512d64690be54fd62374856ee70e62eb29", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "892d2a1a7a3f5368c5a3b9067bba1050c031495f48c430ec00b09691dbf211b7"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"}, + "solid": {:hex, :solid, "1.3.2", "269c43d50c7b1bcb3d36ec7b2286340c266d7eebf10d66301620f627210ba728", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "4352ce765319ab21535398c246ae3812cb0576c8b48d36261928743aec3910a3"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, } diff --git a/elixir/test/symphony_elixir/repo_architecture_test.exs b/elixir/test/symphony_elixir/repo_architecture_test.exs index fab4763..b6c2f56 100644 --- a/elixir/test/symphony_elixir/repo_architecture_test.exs +++ b/elixir/test/symphony_elixir/repo_architecture_test.exs @@ -446,7 +446,7 @@ defmodule SymphonyElixir.RepoArchitectureTest do @coding_pr_delivery_reconciliation_path "lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation.ex" @coding_pr_delivery_reconciliation_line_limit 70 - @coding_pr_delivery_reconciliation_public_functions ~w(enqueue_issue_ids known_targets reconcile_runtime register_known_target run_known_target_watcher_once) + @coding_pr_delivery_reconciliation_public_functions ~w(enqueue_issue_ids known_targets reconcile_runtime register_known_target run_known_target_watcher_once runtime_topology_readiness) @coding_pr_delivery_reconciliation_config_path "lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/config.ex" @coding_pr_delivery_reconciliation_config_dir "lib/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/config" diff --git a/elixir/test/symphony_elixir/storage/governance_test.exs b/elixir/test/symphony_elixir/storage/governance_test.exs index e25a512..5034f83 100644 --- a/elixir/test/symphony_elixir/storage/governance_test.exs +++ b/elixir/test/symphony_elixir/storage/governance_test.exs @@ -37,12 +37,30 @@ defmodule SymphonyElixir.Storage.GovernanceTest do } end + test "redaction boundary scrubs secret-like strings in non-secret fields" do + payload = %{ + note: "Authorization: Bearer bearer-secret token=ghp_secret123", + nested: %{ + "message" => "LINEAR_API_KEY=lin-secret safe=value" + } + } + + redacted = Redaction.redact(payload) + + assert redacted.note =~ "Authorization: [REDACTED]" + assert redacted.note =~ "token=[REDACTED]" + assert redacted.nested["message"] =~ "LINEAR_API_KEY=[REDACTED]" + refute inspect(redacted) =~ "bearer-secret" + refute inspect(redacted) =~ "ghp_secret123" + refute inspect(redacted) =~ "lin-secret" + end + test "redaction boundary preserves structs" do payload = %URI{scheme: "https", host: "example.test", query: "token=visible"} assert %URI{} = redacted = Redaction.redact(payload) assert redacted.scheme == "https" - assert redacted.query == "token=visible" + assert redacted.query == "token=[REDACTED]" end test "governance boundaries can delegate to explicit backends" do diff --git a/elixir/test/symphony_elixir/workflow/extension/runtime_test.exs b/elixir/test/symphony_elixir/workflow/extension/runtime_test.exs index 52d8b2c..2cfb23b 100644 --- a/elixir/test/symphony_elixir/workflow/extension/runtime_test.exs +++ b/elixir/test/symphony_elixir/workflow/extension/runtime_test.exs @@ -103,7 +103,15 @@ defmodule SymphonyElixir.Workflow.Extension.RuntimeTest do assert {:ok, entries} = OperatorCommandRegistry.entries(entries: [CodingPrDelivery]) assert Enum.map(entries, & &1.id) == [ - "symphony.workflow.extension.coding_pr_delivery.change_proposal_reconcile" + "symphony.workflow.extension.coding_pr_delivery.change_proposal_reconcile", + "symphony.workflow.extension.coding_pr_delivery.production_profile_plan", + "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_request", + "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_bundle", + "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_handoff", + "symphony.workflow.extension.coding_pr_delivery.production_profile_validate", + "symphony.workflow.extension.coding_pr_delivery.production_profile_template", + "symphony.workflow.extension.coding_pr_delivery.production_profile_preflight_collect", + "symphony.workflow.extension.coding_pr_delivery.production_profile_status" ] end @@ -191,7 +199,7 @@ defmodule SymphonyElixir.Workflow.Extension.RuntimeTest do OperatorCommandDispatcher.evaluate( "test.operator.echo", [], - registry_opts: %{entries: [__MODULE__.CommandExtension], secret: "private-registry-payload"} + registry_opts: %{entries: [__MODULE__.CommandExtension], payload_marker: "private-registry-payload"} ) assert message =~ "Workflow extension operator command registration is invalid" @@ -202,7 +210,7 @@ defmodule SymphonyElixir.Workflow.Extension.RuntimeTest do OperatorCommandDispatcher.evaluate( "test.operator.echo", [], - command_opts: [{"secret", "private-command-payload"}] + command_opts: [{"payload_marker", "private-command-payload"}] ) assert message =~ "Workflow extension operator command registration is invalid" @@ -235,10 +243,10 @@ defmodule SymphonyElixir.Workflow.Extension.RuntimeTest do } = error} = ToolResultRecorderDispatcher.record_tool_result( "tracker", - %{private_context: "secret-value"}, + %{private_context: "private-value"}, "tracker_attach_external_reference", - %{"secret" => "argument"}, - {:success, %{"secret" => "payload"}}, + %{"private_marker" => "argument"}, + {:success, %{"private_marker" => "payload"}}, tool_result_recorder_registry_opts: [recorder_modules: [__MODULE__.RaisingToolResultRecorder]] ) diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target/payload_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target/payload_test.exs index 7688d91..206a575 100644 --- a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target/payload_test.exs +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target/payload_test.exs @@ -59,6 +59,36 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.KnownTarget.Payloa refute inspect(error) =~ "secret" end + test "to_map and from_map round-trip Linear + CNB provider identity without raw payloads" do + attrs = Map.put(linear_cnb_payload(), "raw_provider_payload", %{"authorization" => "Bearer secret-token"}) + + {:ok, target} = KnownTarget.new(attrs, now_ms: 1_000) + + assert {:ok, payload} = Payload.to_map(target) + assert payload[Fields.issue_id()] == "LIN-42" + assert payload[Fields.tracker_kind()] == "linear" + assert payload[Fields.repo_provider_kind()] == "cnb" + assert payload[Fields.repository()] == "cnb/acme/widgets" + assert payload[Fields.number()] == "42" + assert payload[Fields.url()] == "https://cnb.cool/acme/widgets/-/merge_requests/42" + assert payload[Fields.branch()] == "feature/linear-cnb-shadow" + assert payload[Fields.head_sha()] == "abc123" + refute Map.has_key?(payload, "raw_provider_payload") + refute inspect(payload) =~ "secret-token" + + assert {:ok, + %KnownTarget{ + issue_id: "LIN-42", + tracker_kind: "linear", + repo_provider_kind: "cnb", + repository: "cnb/acme/widgets", + number: "42", + url: "https://cnb.cool/acme/widgets/-/merge_requests/42", + branch: "feature/linear-cnb-shadow", + head_sha: "abc123" + }} = Payload.from_map(payload, now_ms: 2_000) + end + defp valid_payload do %{ Fields.issue_id() => "issue-1", @@ -66,4 +96,17 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.KnownTarget.Payloa Fields.repository() => "acme/widgets" } end + + defp linear_cnb_payload do + %{ + Fields.issue_id() => "LIN-42", + Fields.tracker_kind() => "linear", + Fields.repo_provider_kind() => "cnb", + Fields.repository() => "cnb/acme/widgets", + Fields.number() => "42", + Fields.url() => "https://cnb.cool/acme/widgets/-/merge_requests/42", + Fields.branch() => "feature/linear-cnb-shadow", + Fields.head_sha() => "abc123" + } + end end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target_test.exs index 486473b..0aef8fc 100644 --- a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target_test.exs +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/known_target_test.exs @@ -54,6 +54,25 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.KnownTargetTest do KnownTarget.new(attrs, now_ms: 1_000) end + test "new preserves Linear + CNB provider identity without retaining raw provider payloads" do + attrs = Map.put(linear_cnb_attrs(), "raw_provider_payload", %{"authorization" => "Bearer secret-token"}) + + assert {:ok, + %KnownTarget{ + issue_id: "LIN-42", + tracker_kind: "linear", + repo_provider_kind: "cnb", + repository: "cnb/acme/widgets", + number: "42", + url: "https://cnb.cool/acme/widgets/-/merge_requests/42", + branch: "feature/linear-cnb-shadow", + head_sha: "abc123" + } = target} = KnownTarget.new(attrs, now_ms: 1_000) + + refute inspect(target) =~ "raw_provider_payload" + refute inspect(target) =~ "secret-token" + end + test "merge rejects non-keyword opts and returns merged target on valid opts" do {:ok, existing} = KnownTarget.new(valid_attrs(), now_ms: 1_000) {:ok, incoming} = KnownTarget.new(Map.put(valid_attrs(), Fields.branch(), "feature/demo"), now_ms: 1_001) @@ -90,4 +109,17 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.KnownTargetTest do Fields.repository() => "acme/widgets" } end + + defp linear_cnb_attrs do + %{ + Fields.issue_id() => "LIN-42", + Fields.tracker_kind() => "linear", + Fields.repo_provider_kind() => "cnb", + Fields.repository() => "cnb/acme/widgets", + Fields.number() => "42", + Fields.url() => "https://cnb.cool/acme/widgets/-/merge_requests/42", + Fields.branch() => "feature/linear-cnb-shadow", + Fields.head_sha() => "abc123" + } + end end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/change_proposal_reconcile_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/change_proposal_reconcile_test.exs index a117708..321993e 100644 --- a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/change_proposal_reconcile_test.exs +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/change_proposal_reconcile_test.exs @@ -5,7 +5,7 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.C test "runs one-shot command through validated deps" do parent = self() - report = fake_report("issue-123") + report = fake_report("issue-123", shadow?: false) deps = %{ one_shot_run: fn opts -> @@ -27,6 +27,142 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.C ]} end + test "dry-run text output is marked as shadow no-write diagnostic evidence" do + report = fake_report("issue-shadow", shadow?: true) + + assert {stdout, "", 0} = + ChangeProposalReconcile.evaluate(["--issue", "issue-shadow"], deps: %{one_shot_run: fn _opts -> report end}) + + for line <- String.split(String.trim(stdout), "\n") do + assert String.starts_with?(line, "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]") + assert line =~ "shadow_run_id=shadow-test-run" + assert line =~ "shadow_authority=diagnostic_only" + end + end + + test "dry-run text output preserves Linear + CNB shadow provider identity" do + report = fake_report("issue-linear-cnb-shadow", shadow?: true, tracker_kind: "linear", repo_provider_kind: "cnb") + + assert {stdout, "", 0} = + ChangeProposalReconcile.evaluate(["--issue", "issue-linear-cnb-shadow"], + deps: %{one_shot_run: fn _opts -> report end} + ) + + for line <- String.split(String.trim(stdout), "\n") do + assert String.starts_with?(line, "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]") + assert line =~ "shadow_run_id=shadow-test-run" + assert line =~ "shadow_authority=diagnostic_only" + end + + assert stdout =~ "issue=issue-linear-cnb-shadow" + assert stdout =~ "tracker=linear repo_provider=cnb" + refute stdout =~ "state_write" + end + + test "dry-run json output carries shadow no-write isolation metadata" do + report = fake_report("issue-shadow-json", shadow?: true) + + assert {stdout, "", 0} = + ChangeProposalReconcile.evaluate(["--issue", "issue-shadow-json", "--json"], + deps: %{one_shot_run: fn _opts -> report end} + ) + + decoded = Jason.decode!(stdout) + + assert decoded["shadow"]["prefix"] == "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" + assert decoded["shadow"]["run_id"] == "shadow-test-run" + assert decoded["shadow"]["authority"] == "diagnostic_only" + assert decoded["shadow"]["canonical_authority"] == false + + assert decoded["shadow"]["allowed_destinations"] == [ + "diagnostic_logs", + "review_packets", + "non_authoritative_evidence" + ] + end + + test "dry-run json output preserves Linear + CNB shadow provider identity" do + report = fake_report("issue-linear-cnb-shadow-json", shadow?: true, tracker_kind: "linear", repo_provider_kind: "cnb") + + assert {stdout, "", 0} = + ChangeProposalReconcile.evaluate(["--issue", "issue-linear-cnb-shadow-json", "--json"], + deps: %{one_shot_run: fn _opts -> report end} + ) + + decoded = Jason.decode!(stdout) + + assert decoded["issue_id"] == "issue-linear-cnb-shadow-json" + assert decoded["mode"] == "dry_run" + assert decoded["tracker_kind"] == "linear" + assert decoded["repo_provider_kind"] == "cnb" + assert decoded["shadow"]["prefix"] == "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" + assert decoded["shadow"]["run_id"] == "shadow-test-run" + assert decoded["shadow"]["authority"] == "diagnostic_only" + assert decoded["shadow"]["canonical_authority"] == false + refute stdout =~ "state_write" + end + + test "confirmed json output for Linear + CNB omits shadow metadata" do + parent = self() + report = fake_report("issue-linear-cnb-write-json", shadow?: false, mode: "state_write", tracker_kind: "linear", repo_provider_kind: "cnb") + + deps = %{ + one_shot_run: fn opts -> + send(parent, {:one_shot_opts, opts}) + report + end + } + + assert {stdout, "", 0} = + ChangeProposalReconcile.evaluate(["--issue", "issue-linear-cnb-write-json", "--confirm-state-write", "--json"], + deps: deps + ) + + decoded = Jason.decode!(stdout) + + assert_receive {:one_shot_opts, + [ + issue_id: "issue-linear-cnb-write-json", + confirm_state_write: true + ]} + + assert decoded["issue_id"] == "issue-linear-cnb-write-json" + assert decoded["mode"] == "state_write" + assert decoded["tracker_kind"] == "linear" + assert decoded["repo_provider_kind"] == "cnb" + assert decoded["shadow"] == nil + refute stdout =~ "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" + refute stdout =~ "shadow-test-run" + end + + test "confirmed text output for Linear + CNB omits shadow metadata" do + parent = self() + report = fake_report("issue-linear-cnb-write", shadow?: false, mode: "state_write", tracker_kind: "linear", repo_provider_kind: "cnb") + + deps = %{ + one_shot_run: fn opts -> + send(parent, {:one_shot_opts, opts}) + report + end + } + + assert {stdout, "", 0} = + ChangeProposalReconcile.evaluate(["--issue", "issue-linear-cnb-write", "--confirm-state-write"], deps: deps) + + assert_receive {:one_shot_opts, + [ + issue_id: "issue-linear-cnb-write", + confirm_state_write: true + ]} + + assert stdout =~ "issue=issue-linear-cnb-write" + assert stdout =~ "mode=state_write" + assert stdout =~ "tracker=linear repo_provider=cnb" + refute stdout =~ "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" + refute stdout =~ "shadow-test-run" + refute stdout =~ "shadow_authority=diagnostic_only" + end + test "rejects non-keyword command opts without leaking raw opts" do assert {"", stderr, 70} = ChangeProposalReconcile.evaluate(["--issue", "issue-123"], [:secret_opts]) @@ -78,16 +214,17 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.C end defp valid_deps do - %{one_shot_run: fn _opts -> fake_report("issue-123") end} + %{one_shot_run: fn _opts -> fake_report("issue-123", shadow?: false) end} end - defp fake_report(issue_id) do + defp fake_report(issue_id, opts) do %{ ok: true, issue_id: issue_id, - mode: "dry_run", - tracker_kind: "memory", - repo_provider_kind: "memory", + mode: Keyword.get(opts, :mode, "dry_run"), + shadow: shadow(opts), + tracker_kind: Keyword.get(opts, :tracker_kind, "memory"), + repo_provider_kind: Keyword.get(opts, :repo_provider_kind, "memory"), before_state: "In Review", after_state: "In Review", decision: nil, @@ -95,4 +232,17 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.C probes: [] } end + + defp shadow(opts) do + if Keyword.fetch!(opts, :shadow?) do + %{ + "prefix" => "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]", + "run_id" => "shadow-test-run", + "mode" => "shadow_no_write", + "authority" => "diagnostic_only", + "canonical_authority" => false, + "allowed_destinations" => ["diagnostic_logs", "review_packets", "non_authoritative_evidence"] + } + end + end end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_bundle_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_bundle_test.exs new file mode 100644 index 0000000..8435260 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_bundle_test.exs @@ -0,0 +1,194 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceBundleTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceBundle + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfileEvidenceBundle in CodingPrDelivery.operator_commands() + end + + test "exports a bounded blocked bundle for the default Phase 2 plan" do + assert {stdout, "", 0} = ProductionProfileEvidenceBundle.evaluate(["--plan", "linear_cnb_shadow", "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_evidence_bundle.v1" + assert payload["status"] == "blocked" + assert payload["phase4_ready"] == false + assert payload["preflight"]["status"] == "missing" + assert payload["evidence_packets"]["provided_count"] == 0 + assert payload["does_not_read_evidence_files"] == true + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + end + + test "merges request, preflight, and evidence packet files without reading referenced evidence" do + phase2_plan = phase2_plan() + assert {:ok, evidence_request} = ProductionProfile.production_evidence_request(phase2_plan) + preflight_report = passed_preflight_report(phase2_plan) + evidence_packet = completed_evidence_packet(List.first(phase2_plan["provider_plans"])) + + deps = %{ + read_file: fn + "phase2.json" -> {:ok, Jason.encode!(phase2_plan)} + "request.json" -> {:ok, Jason.encode!(evidence_request)} + "preflight.json" -> {:ok, Jason.encode!(preflight_report)} + "evidence.json" -> {:ok, Jason.encode!(evidence_packet)} + end, + production_evidence_bundle: fn input, opts -> ProductionProfile.production_evidence_bundle(input, opts) end + } + + assert {stdout, "", 0} = + ProductionProfileEvidenceBundle.evaluate( + [ + "--phase2-plan-file", + "phase2.json", + "--evidence-request-file", + "request.json", + "--preflight-report-file", + "preflight.json", + "--evidence-packet-file", + "evidence.json", + "--pretty" + ], + deps: deps + ) + + payload = Jason.decode!(stdout) + + assert payload["status"] == "ready_for_phase4_review" + assert payload["evidence_request"]["provided"] == true + assert payload["preflight"]["status"] == "passed" + assert payload["evidence_packets"]["valid_count"] == 1 + assert payload["blockers"] == [] + end + + test "does not echo unreadable file paths or raw args" do + deps = %{ + read_file: fn _path -> {:error, :enoent} end, + production_evidence_bundle: fn _input, _opts -> flunk("bundle should not run") end + } + + assert {"", stderr, 64} = + ProductionProfileEvidenceBundle.evaluate(["--phase2-plan-file", "secret-phase2.json"], deps: deps) + + assert stderr =~ "Phase 2 plan file could not be read or parsed." + refute stderr =~ "secret-phase2" + + assert {"", argv_stderr, 64} = + ProductionProfileEvidenceBundle.evaluate(["--plan", :secret_plan], deps: valid_deps()) + + assert argv_stderr =~ "Command argv must contain only strings" + assert argv_stderr =~ "value_type=atom" + refute argv_stderr =~ "secret_plan" + end + + test "rejects invalid deps without leaking raw deps" do + assert {"", stderr, 70} = ProductionProfileEvidenceBundle.evaluate([], deps: %{secret: true}) + + assert stderr =~ "reason=deps_invalid" + assert stderr =~ "value_type=map" + refute stderr =~ "secret" + end + + defp phase2_plan do + assert {:ok, plan} = + ProductionProfile.phase2_evidence_plan(:linear_cnb_shadow, + linear_cnb_shadow_run_id: "bundle-command-shadow" + ) + + plan + end + + defp passed_preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(fn command -> + %{ + "template" => Map.fetch!(provider_plan, "template"), + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-26T04:45:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "missing_prerequisites" => [], + "evidence_files" => ["evidence/preflight/#{Map.fetch!(command, "id")}.md"] + } + end) + end) + end + + defp completed_evidence_packet(provider_plan) do + runbook = Map.fetch!(provider_plan, "evidence_runbook") + + %{ + "production_claim" => Map.fetch!(provider_plan, "production_claim"), + "scenario_evidence" => scenario_evidence(runbook), + "non_claim_acknowledgements" => non_claim_acknowledgements(runbook) + } + end + + defp scenario_evidence(runbook) do + Enum.flat_map(runbook["entries"], fn entry -> + Enum.map(entry["scenario_checklist"], fn scenario -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "scenario_id" => scenario["id"], + "status" => "passed", + "evidence_kind" => "shadow_integration", + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-26T04:45:00Z", + "evidence_files" => ["evidence/live/#{entry["entry_id"]}/#{scenario["id"]}.md"], + "production_write_performed" => false, + "canonical_surface_mutated" => false, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => get_in(entry, ["shadow_requirements", "run_id"]) || "bundle-command-shadow", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + } + end) + end) + end + + defp non_claim_acknowledgements(runbook) do + Enum.map(runbook["entries"], fn entry -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "non_claims" => entry["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-26T04:45:00Z" + } + end) + end + + defp valid_deps do + %{ + read_file: fn _path -> {:error, :enoent} end, + production_evidence_bundle: fn input, opts -> ProductionProfile.production_evidence_bundle(input, opts) end + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_handoff_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_handoff_test.exs new file mode 100644 index 0000000..2dec47e --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_handoff_test.exs @@ -0,0 +1,194 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceHandoffTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceHandoff + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfileEvidenceHandoff in CodingPrDelivery.operator_commands() + end + + test "exports a bounded blocked handoff for the default Phase 2 plan" do + assert {stdout, "", 0} = ProductionProfileEvidenceHandoff.evaluate(["--plan", "linear_cnb_shadow", "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_evidence_handoff.v1" + assert payload["status"] == "blocked_pending_external_evidence" + assert payload["phase4_ready"] == false + assert payload["preflight"]["status"] == "missing" + assert payload["evidence_bundle"]["status"] == "blocked" + assert payload["does_not_read_evidence_files"] == true + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + end + + test "merges request, preflight, and evidence packet files without reading referenced evidence" do + phase2_plan = phase2_plan() + assert {:ok, evidence_request} = ProductionProfile.production_evidence_request(phase2_plan) + preflight_report = passed_preflight_report(phase2_plan) + evidence_packet = completed_evidence_packet(List.first(phase2_plan["provider_plans"])) + + deps = %{ + read_file: fn + "phase2.json" -> {:ok, Jason.encode!(phase2_plan)} + "request.json" -> {:ok, Jason.encode!(evidence_request)} + "preflight.json" -> {:ok, Jason.encode!(preflight_report)} + "evidence.json" -> {:ok, Jason.encode!(evidence_packet)} + end, + production_evidence_handoff: fn input, opts -> ProductionProfile.production_evidence_handoff(input, opts) end + } + + assert {stdout, "", 0} = + ProductionProfileEvidenceHandoff.evaluate( + [ + "--phase2-plan-file", + "phase2.json", + "--evidence-request-file", + "request.json", + "--preflight-report-file", + "preflight.json", + "--evidence-packet-file", + "evidence.json", + "--pretty" + ], + deps: deps + ) + + payload = Jason.decode!(stdout) + + assert payload["status"] == "ready_for_phase4_review" + assert payload["preflight"]["status"] == "passed" + assert payload["evidence_bundle"]["phase4_ready"] == true + assert payload["provider_handoffs"] |> List.first() |> Map.fetch!("evidence_packet_status") == "valid" + assert payload["blockers"] == [] + end + + test "does not echo unreadable file paths or raw args" do + deps = %{ + read_file: fn _path -> {:error, :enoent} end, + production_evidence_handoff: fn _input, _opts -> flunk("handoff should not run") end + } + + assert {"", stderr, 64} = + ProductionProfileEvidenceHandoff.evaluate(["--phase2-plan-file", "secret-phase2.json"], deps: deps) + + assert stderr =~ "Phase 2 plan file could not be read or parsed." + refute stderr =~ "secret-phase2" + + assert {"", argv_stderr, 64} = + ProductionProfileEvidenceHandoff.evaluate(["--plan", :secret_plan], deps: valid_deps()) + + assert argv_stderr =~ "Command argv must contain only strings" + assert argv_stderr =~ "value_type=atom" + refute argv_stderr =~ "secret_plan" + end + + test "rejects invalid deps without leaking raw deps" do + assert {"", stderr, 70} = ProductionProfileEvidenceHandoff.evaluate([], deps: %{secret: true}) + + assert stderr =~ "reason=deps_invalid" + assert stderr =~ "value_type=map" + refute stderr =~ "secret" + end + + defp phase2_plan do + assert {:ok, plan} = + ProductionProfile.phase2_evidence_plan(:linear_cnb_shadow, + linear_cnb_shadow_run_id: "handoff-command-shadow" + ) + + plan + end + + defp passed_preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(fn command -> + %{ + "template" => Map.fetch!(provider_plan, "template"), + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-26T06:40:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "missing_prerequisites" => [], + "evidence_files" => ["evidence/preflight/#{Map.fetch!(command, "id")}.md"] + } + end) + end) + end + + defp completed_evidence_packet(provider_plan) do + runbook = Map.fetch!(provider_plan, "evidence_runbook") + + %{ + "production_claim" => Map.fetch!(provider_plan, "production_claim"), + "scenario_evidence" => scenario_evidence(runbook), + "non_claim_acknowledgements" => non_claim_acknowledgements(runbook) + } + end + + defp scenario_evidence(runbook) do + Enum.flat_map(runbook["entries"], fn entry -> + Enum.map(entry["scenario_checklist"], fn scenario -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "scenario_id" => scenario["id"], + "status" => "passed", + "evidence_kind" => "shadow_integration", + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-26T06:40:00Z", + "evidence_files" => ["evidence/live/#{entry["entry_id"]}/#{scenario["id"]}.md"], + "production_write_performed" => false, + "canonical_surface_mutated" => false, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => get_in(entry, ["shadow_requirements", "run_id"]) || "handoff-command-shadow", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + } + end) + end) + end + + defp non_claim_acknowledgements(runbook) do + Enum.map(runbook["entries"], fn entry -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "non_claims" => entry["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-26T06:40:00Z" + } + end) + end + + defp valid_deps do + %{ + read_file: fn _path -> {:error, :enoent} end, + production_evidence_handoff: fn input, opts -> ProductionProfile.production_evidence_handoff(input, opts) end + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_request_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_request_test.exs new file mode 100644 index 0000000..73c154b --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_evidence_request_test.exs @@ -0,0 +1,84 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceRequestTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileEvidenceRequest + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfileEvidenceRequest in CodingPrDelivery.operator_commands() + end + + test "exports bounded evidence requests for provider owners" do + assert {stdout, "", 0} = + ProductionProfileEvidenceRequest.evaluate(["--plan", "tiered_reference", "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_evidence_request.v1" + assert payload["status"] == "blocked_pending_external_evidence" + assert payload["provider_request_count"] == 3 + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + assert "CNB_TOKEN" in payload["external_input_summary"]["required_env"] + assert "repo_slug" in payload["external_input_summary"]["required_targets"] + end + + test "can project from a Phase 2 plan file without reading evidence files" do + assert {:ok, phase2_plan} = + SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.phase2_evidence_plan(:linear_cnb_shadow) + + deps = %{ + read_file: fn "phase2.json" -> {:ok, Jason.encode!(phase2_plan)} end, + production_evidence_request: fn input, opts -> + SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.production_evidence_request(input, opts) + end + } + + assert {stdout, "", 0} = + ProductionProfileEvidenceRequest.evaluate(["--phase2-plan-file", "phase2.json", "--pretty"], deps: deps) + + payload = Jason.decode!(stdout) + + assert payload["phase2_plan_kind"] == "linear_cnb_shadow" + assert payload["provider_request_count"] == 1 + assert payload["does_not_read_evidence_files"] == true + assert [%{"template" => "linear_cnb_shadow"}] = payload["provider_requests"] + end + + test "does not echo unreadable file paths or raw args" do + deps = %{ + read_file: fn _path -> {:error, :enoent} end, + production_evidence_request: fn _input, _opts -> flunk("request should not run") end + } + + assert {"", stderr, 64} = + ProductionProfileEvidenceRequest.evaluate(["--phase2-plan-file", "secret-phase2.json"], deps: deps) + + assert stderr =~ "Phase 2 plan file could not be read or parsed." + refute stderr =~ "secret-phase2" + + assert {"", argv_stderr, 64} = + ProductionProfileEvidenceRequest.evaluate(["--plan", :secret_plan], deps: valid_deps()) + + assert argv_stderr =~ "Command argv must contain only strings" + assert argv_stderr =~ "value_type=atom" + refute argv_stderr =~ "secret_plan" + end + + test "rejects invalid deps without leaking raw deps" do + assert {"", stderr, 70} = ProductionProfileEvidenceRequest.evaluate([], deps: %{secret: true}) + + assert stderr =~ "reason=deps_invalid" + assert stderr =~ "value_type=map" + refute stderr =~ "secret" + end + + defp valid_deps do + %{ + read_file: fn _path -> {:error, :enoent} end, + production_evidence_request: fn input, opts -> + SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.production_evidence_request(input, opts) + end + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_plan_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_plan_test.exs new file mode 100644 index 0000000..720a0a1 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_plan_test.exs @@ -0,0 +1,127 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfilePlanTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfilePlan + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfilePlan in CodingPrDelivery.operator_commands() + end + + test "exports a Phase 2 tiered evidence plan as JSON" do + assert {stdout, "", 0} = + ProductionProfilePlan.evaluate(["--phase", "phase2", "--plan", "tiered_reference", "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.phase2_evidence_plan.v1" + assert payload["plan_kind"] == "tiered_reference" + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + + assert Enum.map(payload["provider_plans"], & &1["template"]) == [ + "linear_github_ready", + "tapd_cnb_shadow", + "linear_cnb_shadow" + ] + + assert [ + %{ + "template" => "linear_github_ready", + "read_only_preflight" => %{ + "status" => "not_run", + "commands" => [ + %{"provider_kind" => "linear", "does_not_write" => true}, + %{"provider_kind" => "github", "required_auth" => ["gh auth status"], "required_targets" => ["repo_slug", "change_proposal_number"]} + ] + } + }, + %{ + "template" => "tapd_cnb_shadow", + "read_only_preflight" => %{ + "commands" => [ + %{"provider_kind" => "tapd", "required_env" => ["TAPD_API_USER", "TAPD_API_PASSWORD", "TAPD_WORKSPACE_ID"]}, + %{"provider_kind" => "cnb", "required_env" => ["CNB_TOKEN"], "required_targets" => ["repo_slug", "change_proposal_number"]} + ] + } + }, + %{ + "template" => "linear_cnb_shadow", + "read_only_preflight" => %{ + "commands" => [ + %{"provider_kind" => "linear", "required_env" => ["LINEAR_API_KEY", "LINEAR_PROJECT_SLUG"]}, + %{"provider_kind" => "cnb", "does_not_write" => true} + ] + } + } + ] = payload["provider_plans"] + end + + test "exports a Phase 4 review plan with custom shadow run ids" do + assert {stdout, "", 0} = + ProductionProfilePlan.evaluate([ + "--phase", + "phase4", + "--plan", + "linear_cnb_shadow", + "--linear-cnb-shadow-run-id", + "linear-shadow-cli-1", + "--pretty" + ]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.phase4_review_plan.v1" + assert payload["phase4_ready"] == false + assert payload["review_decision_status"] == "blocked" + assert payload["does_not_approve_production"] == true + assert [%{"shadow" => shadow}] = payload["provider_review_plans"] + assert shadow["run_id"] == "linear-shadow-cli-1" + assert shadow["canonical_authority"] == false + end + + test "rejects unknown phases without echoing the supplied value" do + assert {"", stderr, 64} = ProductionProfilePlan.evaluate(["--phase", "secret-phase"]) + + assert stderr =~ "Unsupported phase" + refute stderr =~ "secret-phase" + end + + test "rejects unknown plans without echoing the supplied value" do + assert {"", stderr, 64} = ProductionProfilePlan.evaluate(["--plan", "secret-plan"]) + + assert stderr =~ "Coding PR Delivery Phase 2 evidence plan is invalid" + refute stderr =~ "secret-plan" + end + + test "does not echo unexpected command arguments" do + assert {"", stderr, 64} = ProductionProfilePlan.evaluate(["secret-extra"]) + + assert stderr =~ "Unexpected argument count: 1" + refute stderr =~ "secret-extra" + end + + test "does not echo invalid command options" do + assert {"", stderr, 64} = ProductionProfilePlan.evaluate(["--secret-option", "secret-value"]) + + assert stderr =~ "Invalid option count:" + refute stderr =~ "--secret-option" + refute stderr =~ "secret-value" + end + + test "rejects invalid deps without leaking raw deps" do + assert {"", stderr, 70} = ProductionProfilePlan.evaluate([], deps: %{secret: true}) + + assert stderr =~ "reason=deps_invalid" + assert stderr =~ "value_type=map" + refute stderr =~ "secret" + end + + test "rejects argv lists containing non-strings" do + assert {"", stderr, 64} = ProductionProfilePlan.evaluate(["--plan", :secret_plan]) + + assert stderr =~ "Command argv must contain only strings" + assert stderr =~ "value_type=atom" + refute stderr =~ "secret_plan" + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_preflight_collect_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_preflight_collect_test.exs new file mode 100644 index 0000000..5005a79 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_preflight_collect_test.exs @@ -0,0 +1,146 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfilePreflightCollectTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfilePreflightCollect + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfilePreflightCollect in CodingPrDelivery.operator_commands() + end + + test "collects a bounded preflight report without raw provider output" do + test_pid = self() + + deps = %{ + collect_preflight_report: fn plan, opts -> + send(test_pid, {:collect_preflight_report, plan, opts}) + + {:ok, + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "status" => "blocked", + "phase2_evidence_plan" => %{"plan_kind" => plan}, + "planned_preflight_command_count" => 2, + "preflight_result_count" => 2, + "raw_output_included" => false, + "does_not_enable_production" => true, + "provider_preflight_results" => [ + %{ + "template" => "linear_cnb_shadow", + "command_id" => "linear-tracker-read-only-smoke", + "status" => "blocked", + "missing_prerequisites" => ["LINEAR_API_KEY"] + } + ] + }} + end + } + + assert {stdout, "", 0} = + ProductionProfilePreflightCollect.evaluate( + [ + "--plan", + "linear_cnb_shadow", + "--repo", + "acme/widgets", + "--pr", + "7", + "--evidence-prefix", + "evidence/preflight-smoke", + "--json" + ], + deps: deps + ) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_profile_preflight_collect_result.v1" + assert payload["kind"] == "preflight_report" + assert payload["status"] == "blocked" + assert payload["valid"] == true + assert payload["preflight_report_schema"] == "coding_pr_delivery.provider_preflight_report.v1" + assert payload["raw_output_included"] == false + assert payload["does_call_read_only_providers_when_prerequisites_present"] == true + assert payload["does_not_pass_write_or_destructive_flags"] == true + assert payload["does_not_enable_production"] == true + + assert_received {:collect_preflight_report, "linear_cnb_shadow", opts} + assert opts[:repo_slug] == "acme/widgets" + assert opts[:change_proposal_number] == "7" + assert opts[:evidence_prefix] == "evidence/preflight-smoke" + end + + test "returns validation errors without echoing raw input values" do + deps = %{ + collect_preflight_report: fn _plan, _opts -> + {:error, + %{ + code: "coding_pr_delivery_preflight_report_invalid", + errors: [ + %{ + code: "unknown_plan", + path: ["plan"], + message: "Plan is invalid." + } + ] + }} + end + } + + assert {stdout, "", 1} = + ProductionProfilePreflightCollect.evaluate(["--plan", "secret-plan", "--json"], deps: deps) + + payload = Jason.decode!(stdout) + + assert payload["status"] == "invalid" + assert payload["valid"] == false + assert payload["preflight_report"] == nil + assert [%{"code" => "unknown_plan"}] = payload["errors"] + refute stdout =~ "secret-plan" + end + + test "does not echo unexpected args, invalid options, or invalid deps" do + assert {"", unexpected_stderr, 64} = + ProductionProfilePreflightCollect.evaluate(["secret-extra"], deps: valid_deps()) + + assert unexpected_stderr =~ "Unexpected argument count: 1" + refute unexpected_stderr =~ "secret-extra" + + assert {"", invalid_option_stderr, 64} = + ProductionProfilePreflightCollect.evaluate(["--secret-option", "secret-value"], deps: valid_deps()) + + assert invalid_option_stderr =~ "Invalid option count:" + refute invalid_option_stderr =~ "--secret-option" + refute invalid_option_stderr =~ "secret-value" + + assert {"", deps_stderr, 70} = ProductionProfilePreflightCollect.evaluate([], deps: %{secret: true}) + + assert deps_stderr =~ "reason=deps_invalid" + assert deps_stderr =~ "value_type=map" + refute deps_stderr =~ "secret" + end + + test "rejects argv lists containing non-strings" do + assert {"", stderr, 64} = ProductionProfilePreflightCollect.evaluate(["--plan", :secret_plan], deps: valid_deps()) + + assert stderr =~ "Command argv must contain only strings" + assert stderr =~ "value_type=atom" + refute stderr =~ "secret_plan" + end + + defp valid_deps do + %{ + collect_preflight_report: fn _plan, _opts -> + {:ok, + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "status" => "blocked", + "phase2_evidence_plan" => %{"plan_kind" => "tiered_reference"}, + "planned_preflight_command_count" => 0, + "preflight_result_count" => 0, + "raw_output_included" => false + }} + end + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_status_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_status_test.exs new file mode 100644 index 0000000..012b5a6 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_status_test.exs @@ -0,0 +1,117 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileStatusTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileStatus + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfileStatus in CodingPrDelivery.operator_commands() + end + + test "exports a bounded status report for the default Phase 2 plan" do + assert {stdout, "", 0} = ProductionProfileStatus.evaluate(["--plan", "linear_cnb_shadow", "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_profile_status.v1" + assert payload["status"] == "blocked" + assert payload["phase4_ready"] == false + assert payload["preflight"]["provided"] == false + assert payload["does_not_read_evidence_files"] == true + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + assert Enum.any?(payload["blockers"], &(&1["code"] == "provider_preflight_report_required")) + end + + test "merges preflight report files without reading referenced evidence" do + phase2_plan = phase2_plan() + preflight_report = passed_preflight_report(phase2_plan) + + deps = %{ + read_file: fn + "phase2.json" -> {:ok, Jason.encode!(phase2_plan)} + "preflight.json" -> {:ok, Jason.encode!(preflight_report)} + end, + production_status: fn input, opts -> + SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.production_status(input, opts) + end + } + + assert {stdout, "", 0} = + ProductionProfileStatus.evaluate( + ["--phase2-plan-file", "phase2.json", "--preflight-report-file", "preflight.json", "--pretty"], + deps: deps + ) + + payload = Jason.decode!(stdout) + + assert payload["preflight"]["provided"] == true + assert payload["preflight"]["status"] == "passed" + refute Enum.any?(payload["blockers"], &(&1["code"] == "provider_preflight_report_required")) + assert Enum.any?(payload["blockers"], &(&1["code"] == "completed_evidence_packet_required")) + end + + test "does not echo unreadable file paths or raw input" do + deps = %{read_file: fn _path -> {:error, :enoent} end, production_status: fn _input, _opts -> flunk("status should not run") end} + + assert {"", stderr, 64} = + ProductionProfileStatus.evaluate(["--phase2-plan-file", "secret-phase2.json"], deps: deps) + + assert stderr =~ "Phase 2 plan file could not be read or parsed." + refute stderr =~ "secret-phase2" + end + + test "rejects invalid command shapes without leaking arguments or deps" do + assert {"", stderr, 64} = ProductionProfileStatus.evaluate(["--plan", :secret_plan]) + + assert stderr =~ "Command argv must contain only strings" + assert stderr =~ "value_type=atom" + refute stderr =~ "secret_plan" + + assert {"", deps_stderr, 70} = ProductionProfileStatus.evaluate([], deps: %{secret: true}) + + assert deps_stderr =~ "reason=deps_invalid" + assert deps_stderr =~ "value_type=map" + refute deps_stderr =~ "secret" + end + + defp phase2_plan do + assert {:ok, plan} = + SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.phase2_evidence_plan(:linear_cnb_shadow) + + plan + end + + defp passed_preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => ["preflight_report_does_not_enable_production"] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(fn command -> + %{ + "template" => Map.fetch!(provider_plan, "template"), + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-26T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "missing_prerequisites" => [], + "evidence_files" => ["evidence/preflight/#{Map.fetch!(command, "id")}.md"] + } + end) + end) + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_template_test.exs new file mode 100644 index 0000000..6ec2381 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_template_test.exs @@ -0,0 +1,487 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.{ + ProductionProfilePlan, + ProductionProfileTemplate, + ProductionProfileValidate + } + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + EvidencePacketTemplate, + Phase2ClaimTemplate, + Phase2EvidencePlan, + ReviewDecision, + ReviewPacketTemplate + } + + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfileTemplate in CodingPrDelivery.operator_commands() + end + + test "builds an evidence packet template from claim metadata without provider side effects" do + assert {:ok, claim} = Phase2ClaimTemplate.build(:linear_cnb_shadow, shadow_run_id: "template-shadow-1") + claim = Map.put(claim, "raw_payload", "secret-template-payload") + + with_json_file!(claim, fn path -> + assert {stdout, "", 0} = + ProductionProfileTemplate.evaluate(["--kind", "evidence_packet_template", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_profile_template_result.v1" + assert payload["kind"] == "evidence_packet_template" + assert payload["status"] == "ready" + assert payload["valid"] == true + assert payload["template_schema"] == "coding_pr_delivery.production_evidence_packet_template.v1" + assert payload["completed_packet_schema"] == "coding_pr_delivery.production_evidence_packet.v1" + assert payload["does_not_collect_live_evidence"] == true + assert payload["does_not_read_referenced_evidence_files"] == true + assert payload["does_not_call_providers"] == true + assert payload["does_not_mutate_workflow_state"] == true + assert payload["does_not_enable_production"] == true + + template = payload["template"] + assert template["template_authority"] == "evidence_packet_shape_only" + assert template["does_not_collect_live_evidence"] == true + assert [%{"provider_matrix_entry_id" => "linear-cnb-shadow"} | _rest] = template["scenario_evidence_requirements"] + refute stdout =~ "secret-template-payload" + end) + end + + test "builds preflight report templates from Phase 2 evidence plans" do + assert {:ok, phase2_plan} = + Phase2EvidencePlan.build(:tiered_reference, + tapd_cnb_shadow_run_id: "template-command-tapd-cnb", + linear_cnb_shadow_run_id: "template-command-linear-cnb" + ) + + phase2_plan = Map.put(phase2_plan, "raw_payload", "raw command input") + + with_json_file!(phase2_plan, fn path -> + assert {stdout, "", 0} = + ProductionProfileTemplate.evaluate(["--kind", "preflight_report_template", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_profile_template_result.v1" + assert payload["kind"] == "preflight_report_template" + assert payload["status"] == "ready" + assert payload["valid"] == true + assert payload["template_schema"] == "coding_pr_delivery.provider_preflight_report_template.v1" + assert payload["completed_packet_schema"] == "coding_pr_delivery.provider_preflight_report.v1" + assert payload["does_not_collect_live_evidence"] == true + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + + template = payload["template"] + assert template["template_authority"] == "preflight_report_shape_only" + assert length(template["preflight_result_requirements"]) == 6 + refute stdout =~ "raw command input" + end) + end + + test "builds review packet templates from completed evidence metadata" do + with_json_file!(completed_evidence_packet(:linear_cnb_shadow, "review-template-shadow-1"), fn path -> + assert {stdout, "", 0} = + ProductionProfileTemplate.evaluate(["--kind", "review_packet_template", "--file", path, "--pretty"]) + + payload = Jason.decode!(stdout) + + assert payload["kind"] == "review_packet_template" + assert payload["template_schema"] == "coding_pr_delivery.production_review_packet_template.v1" + assert payload["completed_packet_schema"] == "coding_pr_delivery.production_review_packet.v1" + assert payload["template"]["does_not_read_evidence_files"] == true + + inspection = payload["template"]["review_packet_field_template"]["operator_inspection"] + assert inspection["contains_raw_evidence_payload"] == false + assert inspection["workpad_markdown_authoritative"] == false + end) + end + + test "builds enablement request templates with selected provider scope options" do + with_json_file!(ready_review_decision(:linear_cnb_shadow, "enablement-template-shadow-1"), fn path -> + assert {stdout, "", 0} = + ProductionProfileTemplate.evaluate([ + "--kind", + "enablement_request_template", + "--file", + path, + "--provider-matrix-entry-id", + "linear-cnb-shadow", + "--repository", + "acme/widgets", + "--observation-days", + "21", + "--json" + ]) + + payload = Jason.decode!(stdout) + field_template = payload["template"]["enablement_request_field_template"] + + assert payload["template_schema"] == "coding_pr_delivery.production_enablement_request_template.v1" + assert payload["completed_packet_schema"] == "coding_pr_delivery.production_enablement_request.v1" + assert field_template["scope"]["provider_matrix_entry_ids"] == ["linear-cnb-shadow"] + assert field_template["scope"]["repositories"] == ["acme/widgets"] + assert field_template["observation_window"]["duration_days"] == 21 + assert field_template["activation_control"]["requires_operator_apply"] == true + assert field_template["activation_control"]["applies_immediately"] == false + end) + end + + test "builds operator apply and observation status templates" do + apply_plan = ready_operator_apply_plan() + + with_json_file!(apply_plan, fn path -> + assert {stdout, "", 0} = + ProductionProfileTemplate.evaluate(["--kind", "operator_apply_record_template", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["template_schema"] == "coding_pr_delivery.production_operator_apply_record_template.v1" + assert payload["completed_packet_schema"] == "coding_pr_delivery.production_operator_apply_record.v1" + assert payload["template"]["template_authority"] == "operator_apply_record_shape_only" + end) + + apply_record = accepted_operator_apply_record(apply_plan) + + with_json_file!(apply_record, fn path -> + assert {stdout, "", 0} = + ProductionProfileTemplate.evaluate(["--kind", "observation_status_template", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["template_schema"] == "coding_pr_delivery.production_observation_status_template.v1" + assert payload["completed_packet_schema"] == "coding_pr_delivery.production_observation_status.v1" + assert payload["template"]["records_observation_only"] == true + assert payload["template"]["does_not_enable_production"] == true + assert payload["template"]["allowed_evidence_ref_prefixes"] == ["evidence/", "https://", "http://"] + + criteria_results = get_in(payload, ["template", "observation_status_field_template", "criteria_results"]) + assert Enum.all?(criteria_results, &(List.first(&1["evidence_files"]) =~ "evidence/observation/")) + end) + end + + test "returns template validation errors without echoing raw packet fields" do + with_json_file!(%{"profile_instance_id" => "secret-profile"}, fn path -> + assert {stdout, "", 1} = + ProductionProfileTemplate.evaluate(["--kind", "evidence_packet_template", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["kind"] == "evidence_packet_template" + assert payload["status"] == "invalid" + assert payload["valid"] == false + assert payload["errors"] != [] + assert payload["template"] == nil + refute stdout =~ "secret-profile" + end) + end + + test "does not echo unknown kinds, invalid JSON, unexpected args, or invalid deps" do + with_json_file!(%{}, fn path -> + assert {"", unknown_stderr, 64} = + ProductionProfileTemplate.evaluate(["--kind", "secret-kind", "--file", path]) + + assert unknown_stderr =~ "Unsupported template kind" + refute unknown_stderr =~ "secret-kind" + end) + + unique = System.unique_integer([:positive, :monotonic]) + path = Path.join(System.tmp_dir!(), "secret-template-packet-#{unique}.json") + File.write!(path, "{secret-template-json") + + try do + assert {"", invalid_json_stderr, 64} = + ProductionProfileTemplate.evaluate(["--kind", "evidence_packet_template", "--file", path]) + + assert invalid_json_stderr =~ "Unable to read or parse metadata JSON file" + refute invalid_json_stderr =~ path + refute invalid_json_stderr =~ "secret-template-json" + after + File.rm(path) + end + + assert {"", unexpected_stderr, 64} = + ProductionProfileTemplate.evaluate(["--kind", "evidence_packet_template", "--file", "packet.json", "secret-extra"]) + + assert unexpected_stderr =~ "Unexpected argument count: 1" + refute unexpected_stderr =~ "secret-extra" + + assert {"", deps_stderr, 70} = ProductionProfileTemplate.evaluate([], deps: %{secret: true}) + + assert deps_stderr =~ "reason=dependency_invalid" + assert deps_stderr =~ "value_type=nil" + refute deps_stderr =~ "secret" + end + + test "command ids stay distinct" do + assert ProductionProfileTemplate.id() != ProductionProfilePlan.id() + assert ProductionProfileTemplate.id() != ProductionProfileValidate.id() + end + + defp ready_review_decision(template, shadow_run_id) do + entry_id = entry_id(template) + + assert {:ok, review_packet_template} = + ReviewPacketTemplate.build(completed_evidence_packet(template, shadow_run_id)) + + review_packet = + review_packet_template["review_packet_field_template"] + |> complete_review_packet(entry_id, template, shadow_run_id) + + assert {:ok, decision} = ReviewDecision.build(review_packet) + decision + end + + defp ready_operator_apply_plan do + %{ + "schema" => "coding_pr_delivery.production_operator_apply_plan.v1", + "status" => "ready_for_operator_apply", + "enablement_request_id" => "enablement-linear-cnb-shadow", + "profile_instance_id" => "coding-pr-delivery-production", + "review_packet_id" => "review-packet-linear-cnb-shadow", + "scope" => %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => ["linear-cnb-shadow"], + "side_effect_mode" => "shadow_no_write" + }, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "observation_window" => %{ + "duration_days" => 14, + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + }, + "activation_control" => %{ + "change_ticket" => "CHANGE-123", + "requires_operator_apply" => true, + "applies_immediately" => false + }, + "operator_steps" => [ + %{ + "id" => "confirm_change_ticket", + "title" => "Confirm change ticket and operator ownership.", + "status" => "pending_operator_apply", + "change_ticket" => "CHANGE-123" + }, + %{ + "id" => "apply_gate_values", + "title" => "Apply reviewed gate values through the production configuration path.", + "status" => "pending_operator_apply", + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + } + } + ], + "rollback_steps" => [ + %{ + "id" => "disable_transition_readiness", + "title" => "Disable the external transition readiness gate if rollback is needed.", + "status" => "available_before_apply", + "gate" => Gates.transition_readiness_required_gate_key(), + "owner" => "workflow-runtime" + }, + %{ + "id" => "disable_configured_gates", + "title" => "Disable every reviewed rollback gate.", + "status" => "available_before_apply", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ] + } + ], + "blockers" => [], + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false + } + end + + defp accepted_operator_apply_record(apply_plan) do + steps = + apply_plan + |> Map.get("operator_steps", []) + |> Enum.map( + &%{ + "id" => Map.get(&1, "id"), + "status" => "completed", + "completed_by" => "release-manager", + "completed_at" => "2026-06-25T00:00:00Z" + } + ) + + %{ + "apply_record_id" => "apply-record-linear-cnb-shadow", + "operator_apply_plan" => apply_plan, + "apply_metadata" => %{ + "applied_by" => "release-manager", + "applied_at" => "2026-06-25T00:00:00Z", + "change_ticket" => "CHANGE-123", + "operator_confirmation" => true, + "automatic_apply" => false + }, + "applied_scope" => Map.fetch!(apply_plan, "scope"), + "applied_gate_values" => Map.fetch!(apply_plan, "gate_values"), + "completed_operator_steps" => steps, + "rollback_readiness" => %{ + "owner" => "workflow-runtime", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true, + "verified_by" => "release-manager" + }, + "observation_start" => %{ + "started" => true, + "observation_window" => Map.fetch!(apply_plan, "observation_window") + } + } + end + + defp complete_review_packet(field_template, entry_id, template, shadow_run_id) do + field_template + |> Map.merge(%{ + "review_packet_id" => "review-packet-#{entry_id}", + "changed_source_specs" => [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md" + ], + "implementation_refs" => [ + "commit:0cb37c9", + "local-patch:coding-pr-delivery-template-command" + ], + "deterministic_test_matrix" => [ + %{"command" => "mise exec -- mix test test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile", "status" => "passed"} + ], + "provider_preflight_reports" => [passed_preflight_report(template, shadow_run_id)], + "owner_signoffs" => [owner_signoff()] + }) + |> put_in(["scrubbing_pipeline", "test_results"], [ + %{"name" => "scrubber evidence boundaries", "status" => "passed"} + ]) + end + + defp passed_preflight_report(:tapd_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:tapd_cnb_shadow, tapd_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp passed_preflight_report(:linear_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(template, &1)) + end) + end + + defp preflight_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-25T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp completed_evidence_packet(template, shadow_run_id) do + assert {:ok, claim} = Phase2ClaimTemplate.build(template, shadow_run_id: shadow_run_id) + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + %{ + "production_claim" => claim, + "scenario_evidence" => Enum.map(template["scenario_evidence_requirements"], &complete_scenario_evidence/1), + "non_claim_acknowledgements" => Enum.map(template["non_claim_acknowledgement_requirements"], &complete_non_claim_acknowledgement/1) + } + end + + defp complete_scenario_evidence(requirement) do + evidence = + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "scenario_id" => requirement["scenario_id"], + "status" => requirement["required_status"], + "evidence_kind" => requirement["required_evidence_kind"], + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-25T00:00:00Z", + "evidence_files" => requirement["evidence_files"] + } + + if is_map(requirement["shadow"]) do + evidence + |> Map.merge(requirement["no_write_flags"]) + |> Map.put("shadow", requirement["shadow"]) + else + evidence + end + end + + defp complete_non_claim_acknowledgement(requirement) do + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "non_claims" => requirement["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-25T00:00:00Z" + } + end + + defp owner_signoff do + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + end + + defp entry_id(:linear_cnb_shadow), do: "linear-cnb-shadow" + defp entry_id(:tapd_cnb_shadow), do: "tapd-cnb-shadow" + + defp with_json_file!(payload, fun) do + unique = System.unique_integer([:positive, :monotonic]) + path = Path.join(System.tmp_dir!(), "production-profile-template-packet-#{unique}.json") + File.write!(path, Jason.encode!(payload)) + + try do + fun.(path) + after + File.rm(path) + end + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_validate_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_validate_test.exs new file mode 100644 index 0000000..22474d5 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/operator_commands/production_profile_validate_test.exs @@ -0,0 +1,214 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.ProductionProfileValidateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.OperatorCommands.{ + ProductionProfilePlan, + ProductionProfileValidate + } + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + Phase2ClaimTemplate, + Phase2EvidencePlan + } + + test "is registered as a Coding PR Delivery operator command" do + assert ProductionProfileValidate in CodingPrDelivery.operator_commands() + end + + test "validates a production claim metadata file without returning the raw packet" do + assert {:ok, claim} = Phase2ClaimTemplate.build(:linear_cnb_shadow, shadow_run_id: "validate-shadow-1") + + with_json_file!(claim, fn path -> + assert {stdout, "", 0} = + ProductionProfileValidate.evaluate(["--kind", "claim", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["schema"] == "coding_pr_delivery.production_profile_validation_result.v1" + assert payload["kind"] == "claim" + assert payload["status"] == "valid" + assert payload["valid"] == true + assert payload["summary"]["profile_instance_id"] == claim["profile_instance_id"] + assert payload["summary"]["provider_matrix_entry_ids"] == ["linear-cnb-shadow"] + assert payload["summary"]["side_effect_modes"] == ["shadow_no_write"] + assert payload["normalized_packet_included"] == false + assert payload["raw_input_included"] == false + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + refute stdout =~ "validate-shadow-1" + end) + end + + test "projects review decisions as bounded blocked summaries" do + with_json_file!(%{"review_packet_id" => "review-packet-secret", "owner_signoffs" => []}, fn path -> + assert {stdout, "", 0} = + ProductionProfileValidate.evaluate(["--kind", "review_decision", "--file", path, "--pretty"]) + + payload = Jason.decode!(stdout) + + assert payload["kind"] == "review_decision" + assert payload["status"] == "blocked" + assert payload["valid"] == false + assert payload["projection"]["review_packet_id"] == "review-packet-secret" + assert payload["projection"]["raw_evidence_payload_included"] == false + assert payload["normalized_packet_included"] == false + assert payload["does_not_call_providers"] == true + end) + end + + test "validates preflight reports as bounded blocker metadata" do + report = blocked_preflight_report() + + with_json_file!(report, fn path -> + assert {stdout, "", 0} = + ProductionProfileValidate.evaluate(["--kind", "preflight_report", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["kind"] == "preflight_report" + assert payload["status"] == "valid" + assert payload["valid"] == true + assert payload["summary"]["status"] == "blocked" + assert payload["summary"]["phase2_plan_kind"] == "linear_cnb_shadow" + assert payload["summary"]["planned_preflight_command_count"] == 2 + assert payload["summary"]["preflight_result_count"] == 2 + assert payload["summary"]["raw_output_included"] == false + assert payload["does_not_call_providers"] == true + assert payload["does_not_enable_production"] == true + refute stdout =~ "operator-preflight-shadow" + end) + end + + test "returns validation errors without echoing raw packet fields" do + with_json_file!(%{"profile_instance_id" => "secret-profile"}, fn path -> + assert {stdout, "", 1} = + ProductionProfileValidate.evaluate(["--kind", "claim", "--file", path, "--json"]) + + payload = Jason.decode!(stdout) + + assert payload["kind"] == "claim" + assert payload["status"] == "invalid" + assert payload["valid"] == false + assert payload["errors"] != [] + refute stdout =~ "secret-profile" + end) + end + + test "rejects unknown kinds without echoing the supplied value" do + with_json_file!(%{}, fn path -> + assert {"", stderr, 64} = + ProductionProfileValidate.evaluate(["--kind", "secret-kind", "--file", path]) + + assert stderr =~ "Unsupported packet kind" + refute stderr =~ "secret-kind" + end) + end + + test "rejects unreadable or invalid JSON without echoing the file path or contents" do + unique = System.unique_integer([:positive, :monotonic]) + path = Path.join(System.tmp_dir!(), "secret-packet-#{unique}.json") + File.write!(path, "{secret-json") + + try do + assert {"", stderr, 64} = + ProductionProfileValidate.evaluate(["--kind", "claim", "--file", path]) + + assert stderr =~ "Unable to read or parse metadata JSON file" + refute stderr =~ path + refute stderr =~ "secret-json" + after + File.rm(path) + end + end + + test "does not echo unexpected command arguments or invalid options" do + assert {"", unexpected_stderr, 64} = + ProductionProfileValidate.evaluate(["--kind", "claim", "--file", "packet.json", "secret-extra"]) + + assert unexpected_stderr =~ "Unexpected argument count: 1" + refute unexpected_stderr =~ "secret-extra" + + assert {"", invalid_stderr, 64} = + ProductionProfileValidate.evaluate(["--secret-option", "secret-value"]) + + assert invalid_stderr =~ "Invalid option count:" + refute invalid_stderr =~ "--secret-option" + refute invalid_stderr =~ "secret-value" + end + + test "rejects invalid deps without leaking raw deps" do + assert {"", stderr, 70} = ProductionProfileValidate.evaluate([], deps: %{secret: true}) + + assert stderr =~ "reason=dependency_invalid" + assert stderr =~ "value_type=nil" + refute stderr =~ "secret" + end + + test "command ids stay distinct" do + assert ProductionProfileValidate.id() != ProductionProfilePlan.id() + end + + defp with_json_file!(payload, fun) do + unique = System.unique_integer([:positive, :monotonic]) + path = Path.join(System.tmp_dir!(), "production-profile-packet-#{unique}.json") + File.write!(path, Jason.encode!(payload)) + + try do + fun.(path) + after + File.rm(path) + end + end + + defp blocked_preflight_report do + assert {:ok, phase2_plan} = + Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: "operator-preflight-shadow") + + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(Map.fetch!(provider_plan, "template"), &1)) + end), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "blocked", + "blocker_code" => "missing_preflight_prerequisite", + "missing_prerequisites" => [first_prerequisite(command)], + "ran_at" => "2026-06-26T03:20:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp first_prerequisite(command) do + command + |> Map.take(["required_env", "required_auth", "required_targets"]) + |> Map.values() + |> Enum.flat_map(fn + values when is_list(values) -> values + _value -> [] + end) + |> List.first() + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/claim_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/claim_test.exs new file mode 100644 index 0000000..da07a24 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/claim_test.exs @@ -0,0 +1,282 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ClaimTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Claim + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "accepts complete Phase 2 production claim packets" do + claim = + production_claim( + [ready_to_land_entry(), shadow_entry("tapd-cnb-shadow", "tapd", "cnb")], + typed_tool_exceptions: [typed_tool_exception()] + ) + + assert {:ok, normalized} = Claim.validate(claim) + + assert normalized["profile_instance_id"] == "coding_pr_delivery.production.claim" + assert Enum.map(normalized["provider_matrix"], & &1["id"]) == ["linear-github-ready", "tapd-cnb-shadow"] + assert length(normalized["production_governance"]) == 2 + assert [%{"exception_id" => "typed-tool-exception-linear-github-1"}] = normalized["typed_tool_exceptions"] + end + + test "accepts Linear + CNB shadow claim packets with matching governance" do + claim = production_claim([shadow_entry("linear-cnb-shadow", "linear", "cnb")]) + + assert {:ok, normalized} = Claim.validate(claim) + + assert normalized["profile_instance_id"] == "coding_pr_delivery.production.claim" + assert [%{"id" => "linear-cnb-shadow"} = entry] = normalized["provider_matrix"] + assert entry["tracker"]["kind"] == "linear" + assert entry["repo_provider"]["kind"] == "cnb" + assert entry["structured_plan_gates"][Gates.transition_readiness_required_gate_key()] == false + assert [%{"provider_matrix_entry_id" => "linear-cnb-shadow"}] = normalized["production_governance"] + assert normalized["typed_tool_exceptions"] == [] + end + + test "rejects provider entries without matching governance packets" do + claim = + production_claim([ready_to_land_entry(), shadow_entry("tapd-cnb-shadow", "tapd", "cnb")]) + |> put_in(["production_governance"], [governance_packet("linear-github-ready")]) + + assert {:error, %{code: "coding_pr_delivery_production_claim_invalid", errors: errors}} = Claim.validate(claim) + + assert Enum.any?(errors, &(&1.code == "missing_governance_packet" and &1.path == ["production_governance"])) + end + + test "rejects invalid governance packets with prefixed errors" do + governance = + governance_packet("linear-github-ready") + |> put_in(["rollback", "deletes_records"], true) + + claim = + production_claim([ready_to_land_entry()]) + |> put_in(["production_governance"], [governance]) + + assert {:error, %{errors: errors}} = Claim.validate(claim) + + assert Enum.any?( + errors, + &(&1.code == "invalid_destructive_rollback" and &1.path == ["production_governance", 0, "rollback", "deletes_records"]) + ) + end + + test "rejects invalid provider matrix entries with prefixed errors" do + entry = + ready_to_land_entry() + |> put_in(["structured_plan_gates", Gates.transition_readiness_required_gate_key()], false) + + claim = production_claim([entry]) + + assert {:error, %{errors: errors}} = Claim.validate(claim) + + assert Enum.any?( + errors, + &(&1.code == "transition_readiness_required" and + &1.path == ["provider_matrix", 0, "structured_plan_gates", Gates.transition_readiness_required_gate_key()]) + ) + end + + test "rejects typed-tool exceptions outside the provider matrix scope" do + exception = + typed_tool_exception() + |> put_in(["repo_provider", "kind"], "cnb") + + claim = production_claim([ready_to_land_entry()], typed_tool_exceptions: [exception]) + + assert {:error, %{errors: errors}} = Claim.validate(claim) + + assert Enum.any?(errors, &(&1.code == "exception_provider_scope_unmatched" and &1.path == ["typed_tool_exceptions", 0])) + end + + test "rejects malformed typed-tool exceptions through nested admission checks" do + exception = + typed_tool_exception() + |> Map.put("raw_provider_passthrough", true) + + claim = production_claim([ready_to_land_entry()], typed_tool_exceptions: [exception]) + + assert {:error, %{errors: errors}} = Claim.validate(claim) + + assert Enum.any?( + errors, + &(&1.code == "raw_provider_passthrough_forbidden" and &1.path == ["typed_tool_exceptions", 0, "raw_provider_passthrough"]) + ) + end + + defp production_claim(entries, opts \\ []) do + governance_packets = Enum.map(entries, &governance_packet(&1["id"])) + + %{ + "profile_instance_id" => "coding_pr_delivery.production.claim", + "provider_matrix" => entries, + "production_governance" => governance_packets, + "typed_tool_exceptions" => Keyword.get(opts, :typed_tool_exceptions, []) + } + end + + defp ready_to_land_entry do + base_entry("linear-github-ready", "linear", "github") + |> Map.merge(%{ + "side_effect_mode" => "ready_to_land_write", + "structured_plan_gates" => gates(true), + "deployment_topology" => %{ + "mode" => "distributed_lock", + "ownership_proof" => "redis-lock:workflow:coding-pr-delivery" + } + }) + end + + defp shadow_entry(id, tracker, repo_provider) do + base_entry(id, tracker, repo_provider) + |> Map.merge(%{ + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => gates(false), + "deployment_topology" => %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + }, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => "shadow-run-1", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + }) + end + + defp base_entry(id, tracker, repo_provider) do + %{ + "id" => id, + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => tracker}, + "repo_provider" => %{"kind" => repo_provider}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "candidate_discovery" => "runtime_targeted", + "typed_tool_inventory" => %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + }, + "evidence_files" => ["evidence/provider-matrix/#{id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp governance_packet(provider_matrix_entry_id) do + %{ + "provider_matrix_entry_id" => provider_matrix_entry_id, + "profile_instance_id" => "coding_pr_delivery.production.claim", + "durability_profile" => %{ + "storage_backend" => "sqlite", + "restart_behavior" => "survives process and worker restarts", + "backup_restore" => "daily backup with documented restore drill", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "bounded_payload_summaries_only", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "cleanup_schedule" => "daily", + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Gates.enabled_gate_key(), + "disable_rendering_gate" => Gates.render_workpad_gate_key(), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Gates.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => [ + "evidence/structured-plan/governance/#{provider_matrix_entry_id}.md" + ] + } + end + + defp typed_tool_exception do + %{ + "exception_id" => "typed-tool-exception-linear-github-1", + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => "linear"}, + "repo_provider" => %{"kind" => "github"}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "workspace_class" => "staging_workspace", + "route_set" => ["developing", "review"], + "operation_set" => ["repo_provider.review.read"], + "fallback_authority" => %{ + "owner" => "workflow-runtime", + "authority_kind" => "temporary_backend_adapter", + "accepted_by_profile_owners" => true + }, + "compensating_controls" => ["read_only_operation", "bounded_schema_allowlist"], + "input_schema_allowlist" => %{ + "schema_ids" => ["repo_provider.review.read.v1"], + "rejects_unknown_fields" => true + }, + "limits" => %{"max_calls_per_run" => 3, "max_concurrency" => 1}, + "audit_logging" => %{ + "event_name" => "coding_pr_delivery_typed_tool_exception_used", + "retention_class" => "workflow_audit_staging" + }, + "deterministic_tests" => [ + "test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception_test.exs" + ], + "real_integration_evidence" => ["evidence/typed-tool-exceptions/linear-github-review-read.md"], + "operator_observability" => %{ + "metrics" => ["typed_tool_exception_used_total"], + "alerts" => ["typed_tool_exception_usage_outside_scope"], + "runbook" => "runbooks/coding-pr-delivery/typed-tool-exception.md" + }, + "rollback" => %{ + "owner" => "workflow-runtime", + "instructions" => "Remove exception id from production enablement packet and require typed tool inventory.", + "disables_exception" => true, + "restores_typed_tool_requirement" => true + }, + "expires_at" => "2026-07-25T00:00:00Z" + } + end + + defp gates(transition_readiness_required) do + %{ + Gates.enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => transition_readiness_required, + Gates.provider_adapters_enabled_gate_key() => false + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_template_test.exs new file mode 100644 index 0000000..aa978d5 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_template_test.exs @@ -0,0 +1,307 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequestTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequest + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequestTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacketTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2ClaimTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewDecision + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacketTemplate + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "builds an enablement request template from a ready review decision" do + decision = ready_review_decision() + + assert {:ok, template} = + EnablementRequestTemplate.build(decision, + provider_matrix_entry_ids: ["tapd-cnb-shadow"], + repositories: ["acme/widgets"] + ) + + assert template["schema"] == "coding_pr_delivery.production_enablement_request_template.v1" + assert template["completed_packet_schema"] == "coding_pr_delivery.production_enablement_request.v1" + assert template["template_authority"] == "enablement_request_shape_only" + assert template["does_not_enable_production"] == true + + field_template = template["enablement_request_field_template"] + + assert field_template["scope"] == %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => ["tapd-cnb-shadow"], + "side_effect_mode" => "shadow_no_write" + } + + assert field_template["gate_values"][Gates.transition_readiness_required_gate_key()] == false + assert field_template["activation_control"]["requires_operator_apply"] == true + assert field_template["activation_control"]["applies_immediately"] == false + assert "multi_node_ownership" in field_template["acknowledged_non_claims"] + end + + test "field template can be completed into a valid enablement request" do + decision = ready_review_decision() + + assert {:ok, template} = + EnablementRequestTemplate.build(decision, + provider_matrix_entry_ids: ["tapd-cnb-shadow"], + repositories: ["acme/widgets"] + ) + + enablement_request = + template["enablement_request_field_template"] + |> Map.merge(%{ + "enablement_request_id" => "enablement-tapd-cnb-shadow", + "requested_by" => "release-manager", + "requested_at" => "2026-06-25T00:00:00Z", + "approvals" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ] + }) + |> put_in(["activation_control", "change_ticket"], "CHANGE-123") + + assert {:ok, %{"schema" => "coding_pr_delivery.production_enablement_request.v1"}} = + EnablementRequest.validate(enablement_request) + end + + test "builds a Linear + CNB shadow enablement request template from a ready review decision" do + decision = ready_review_decision(:linear_cnb_shadow, "shadow-run-linear-cnb-42") + + assert {:ok, template} = + EnablementRequestTemplate.build(decision, + provider_matrix_entry_ids: ["linear-cnb-shadow"], + repositories: ["acme/widgets"] + ) + + field_template = template["enablement_request_field_template"] + + assert template["does_not_enable_production"] == true + + assert template["selected_provider_entries"] == [ + %{ + "entry_id" => "linear-cnb-shadow", + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => "linear"}, + "repo_provider" => %{"kind" => "cnb"}, + "agent_provider" => %{"kind" => "configured_agent_provider"}, + "side_effect_mode" => "shadow_no_write", + "topology_mode" => "singleton", + "non_claims" => [ + "multi_node_ownership", + "automatic_durable_replay", + "automatic_cold_start_provider_rebuild" + ] + } + ] + + assert field_template["scope"] == %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => ["linear-cnb-shadow"], + "side_effect_mode" => "shadow_no_write" + } + + assert field_template["gate_values"][Gates.transition_readiness_required_gate_key()] == false + assert field_template["activation_control"]["requires_operator_apply"] == true + assert field_template["activation_control"]["applies_immediately"] == false + assert "multi_node_ownership" in field_template["acknowledged_non_claims"] + end + + test "marks ready-to-land templates with transition readiness required" do + decision = + ready_review_decision() + |> Map.put("provider_entries", [ + %{ + "entry_id" => "linear-github-ready", + "tracker" => %{"kind" => "linear"}, + "repo_provider" => %{"kind" => "github"}, + "agent_provider" => %{"kind" => "codex"}, + "side_effect_mode" => "ready_to_land_write", + "topology_mode" => "singleton", + "non_claims" => ["multi_node_ownership"] + } + ]) + + assert {:ok, template} = + EnablementRequestTemplate.build(decision, provider_matrix_entry_ids: ["linear-github-ready"]) + + field_template = template["enablement_request_field_template"] + + assert field_template["scope"]["side_effect_mode"] == "ready_to_land_write" + assert field_template["gate_values"][Gates.transition_readiness_required_gate_key()] == true + end + + test "rejects blocked review decisions and mixed provider modes" do + blocked = + ready_review_decision() + |> Map.put("status", "blocked") + |> Map.put("blockers", [%{"code" => "missing_evidence"}]) + + assert {:error, %{errors: errors}} = EnablementRequestTemplate.build(blocked) + + assert Enum.any?(errors, &(&1.code == "review_decision_not_ready")) + assert Enum.any?(errors, &(&1.code == "review_decision_blocked")) + + mixed = + ready_review_decision() + |> Map.put("provider_entries", [ + %{"entry_id" => "tapd-cnb-shadow", "side_effect_mode" => "shadow_no_write", "non_claims" => []}, + %{"entry_id" => "linear-github-ready", "side_effect_mode" => "ready_to_land_write", "non_claims" => []} + ]) + + assert {:error, %{errors: mixed_errors}} = + EnablementRequestTemplate.build(mixed, side_effect_mode: "shadow_no_write") + + assert Enum.any?(mixed_errors, &(&1.code == "mixed_side_effect_modes")) + end + + test "exposes enablement templates through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_enablement_request_template.v1"}} = + ProductionProfile.enablement_request_template(ready_review_decision(), + provider_matrix_entry_ids: ["tapd-cnb-shadow"], + repositories: ["acme/widgets"] + ) + end + + defp ready_review_decision(template \\ :tapd_cnb_shadow, shadow_run_id \\ "shadow-run-cnb-42") do + entry_id = entry_id(template) + + assert {:ok, review_packet_template} = + ReviewPacketTemplate.build(completed_evidence_packet(template, shadow_run_id)) + + review_packet = + complete_review_packet(review_packet_template["review_packet_field_template"], entry_id, template, shadow_run_id) + + assert {:ok, decision} = ReviewDecision.build(review_packet) + decision + end + + defp complete_review_packet(field_template, entry_id, template, shadow_run_id) do + field_template + |> Map.merge(%{ + "review_packet_id" => "review-packet-#{entry_id}", + "changed_source_specs" => [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md" + ], + "implementation_refs" => [ + "commit:27fad4c", + "local-patch:coding-pr-delivery-enable-template" + ], + "deterministic_test_matrix" => [ + %{"command" => "mise exec -- mix test test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile", "status" => "passed"} + ], + "provider_preflight_reports" => [passed_preflight_report(template, shadow_run_id)], + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ] + }) + |> put_in(["scrubbing_pipeline", "test_results"], [ + %{"name" => "scrubber evidence boundaries", "status" => "passed"} + ]) + end + + defp passed_preflight_report(:tapd_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:tapd_cnb_shadow, tapd_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp passed_preflight_report(:linear_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(template, &1)) + end) + end + + defp preflight_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-25T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp completed_evidence_packet(template, shadow_run_id) do + assert {:ok, claim} = Phase2ClaimTemplate.build(template, shadow_run_id: shadow_run_id) + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + %{ + "production_claim" => claim, + "scenario_evidence" => Enum.map(template["scenario_evidence_requirements"], &complete_scenario_evidence/1), + "non_claim_acknowledgements" => Enum.map(template["non_claim_acknowledgement_requirements"], &complete_non_claim_acknowledgement/1) + } + end + + defp complete_scenario_evidence(requirement) do + evidence = + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "scenario_id" => requirement["scenario_id"], + "status" => requirement["required_status"], + "evidence_kind" => requirement["required_evidence_kind"], + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-25T00:00:00Z", + "evidence_files" => requirement["evidence_files"] + } + + if is_map(requirement["shadow"]) do + evidence + |> Map.merge(requirement["no_write_flags"]) + |> Map.put("shadow", requirement["shadow"]) + else + evidence + end + end + + defp complete_non_claim_acknowledgement(requirement) do + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "non_claims" => requirement["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-25T00:00:00Z" + } + end + + defp entry_id(:tapd_cnb_shadow), do: "tapd-cnb-shadow" + defp entry_id(:linear_cnb_shadow), do: "linear-cnb-shadow" +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_test.exs new file mode 100644 index 0000000..2e0a53b --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/enablement_request_test.exs @@ -0,0 +1,181 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequestTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequest + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "accepts scoped enablement requests over ready review decisions" do + assert {:ok, request} = EnablementRequest.validate(valid_request()) + + assert request["schema"] == "coding_pr_delivery.production_enablement_request.v1" + assert request["enablement_request_id"] == "enablement-tapd-cnb-shadow" + assert request["profile_instance_id"] == "coding-pr-delivery-production" + assert request["review_packet_id"] == "review-packet-tapd-cnb-shadow" + assert request["does_not_enable_production"] == true + assert request["scope"]["side_effect_mode"] == "shadow_no_write" + end + + test "accepts Linear + CNB shadow enablement requests over ready review decisions" do + assert {:ok, request} = EnablementRequest.validate(valid_request("linear-cnb-shadow", "linear")) + + assert request["schema"] == "coding_pr_delivery.production_enablement_request.v1" + assert request["enablement_request_id"] == "enablement-linear-cnb-shadow" + assert request["profile_instance_id"] == "coding-pr-delivery-production" + assert request["review_packet_id"] == "review-packet-linear-cnb-shadow" + assert request["does_not_enable_production"] == true + + assert request["scope"] == %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => ["linear-cnb-shadow"], + "side_effect_mode" => "shadow_no_write" + } + + assert request["gate_values"][Gates.transition_readiness_required_gate_key()] == false + assert "multi_node_ownership" in request["acknowledged_non_claims"] + assert request["activation_control"]["requires_operator_apply"] == true + assert request["activation_control"]["applies_immediately"] == false + end + + test "rejects blocked review decisions" do + request = + valid_request() + |> put_in(["review_decision", "status"], "blocked") + |> put_in(["review_decision", "blockers"], [%{"code" => "missing_evidence"}]) + + assert {:error, %{code: "coding_pr_delivery_enablement_request_invalid", errors: errors}} = + EnablementRequest.validate(request) + + assert Enum.any?(errors, &(&1.code == "review_decision_not_ready")) + assert Enum.any?(errors, &(&1.code == "review_decision_blocked")) + end + + test "rejects scope that is outside the reviewed provider entry or side-effect mode" do + request = + valid_request() + |> put_in(["scope", "provider_matrix_entry_ids"], ["unknown-entry", "tapd-cnb-shadow"]) + |> put_in(["scope", "side_effect_mode"], "ready_to_land_write") + + assert {:error, %{errors: errors}} = EnablementRequest.validate(request) + + assert Enum.any?(errors, &(&1.code == "unknown_provider_matrix_entry" and &1.provider_matrix_entry_id == "unknown-entry")) + assert Enum.any?(errors, &(&1.code == "side_effect_mode_escalation" and &1.provider_matrix_entry_id == "tapd-cnb-shadow")) + end + + test "rejects legacy gates and ready-to-land requests without transition readiness" do + request = + valid_request() + |> put_in(["review_decision", "provider_entries", Access.at(0), "side_effect_mode"], "ready_to_land_write") + |> put_in(["scope", "side_effect_mode"], "ready_to_land_write") + |> put_in(["gate_values", Gates.transition_readiness_required_gate_key()], false) + |> put_in(["gate_values", "review_handoff_required"], true) + + assert {:error, %{errors: errors}} = EnablementRequest.validate(request) + + assert Enum.any?(errors, &(&1.code == "legacy_gate_name")) + assert Enum.any?(errors, &(&1.code == "ready_to_land_without_transition_gate")) + end + + test "requires acknowledged non-claims, verified rollback, approvals, and operator apply" do + request = + valid_request() + |> Map.put("acknowledged_non_claims", ["automatic_durable_replay"]) + |> put_in(["rollback", "verified"], false) + |> put_in(["approvals", Access.at(0), "decision"], "pending") + |> put_in(["activation_control", "applies_immediately"], true) + + assert {:error, %{errors: errors}} = EnablementRequest.validate(request) + + assert Enum.any?(errors, &(&1.code == "missing_non_claim_acknowledgement" and &1.non_claim == "multi_node_ownership")) + assert Enum.any?(errors, &(&1.code == "rollback_not_verified")) + assert Enum.any?(errors, &(&1.code == "approval_not_approved")) + assert Enum.any?(errors, &(&1.code == "immediate_activation")) + end + + defp valid_request(entry_id \\ "tapd-cnb-shadow", tracker_kind \\ "tapd") do + %{ + "enablement_request_id" => "enablement-#{entry_id}", + "requested_by" => "release-manager", + "requested_at" => "2026-06-25T00:00:00Z", + "review_decision" => ready_review_decision(entry_id, tracker_kind), + "scope" => %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => [entry_id], + "side_effect_mode" => "shadow_no_write" + }, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "observation_window" => %{ + "duration_days" => 14, + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + }, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true + }, + "acknowledged_non_claims" => [ + "multi_node_ownership", + "automatic_durable_replay", + "automatic_cold_start_provider_rebuild" + ], + "approvals" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ], + "activation_control" => %{ + "change_ticket" => "CHANGE-123", + "requires_operator_apply" => true, + "applies_immediately" => false + } + } + end + + defp ready_review_decision(entry_id, tracker_kind) do + %{ + "schema" => "coding_pr_delivery.production_review_decision.v1", + "status" => "ready_for_approval", + "review_packet_id" => "review-packet-#{entry_id}", + "profile_instance_id" => "coding-pr-delivery-production", + "provider_entries" => [ + %{ + "entry_id" => entry_id, + "tracker" => %{"kind" => tracker_kind}, + "repo_provider" => %{"kind" => "cnb"}, + "agent_provider" => %{"kind" => "codex"}, + "side_effect_mode" => "shadow_no_write", + "topology_mode" => "singleton", + "non_claims" => [ + "multi_node_ownership", + "automatic_durable_replay", + "automatic_cold_start_provider_rebuild" + ] + } + ], + "evidence_summary" => %{ + "scenario_evidence_count" => 9, + "non_claim_acknowledgement_count" => 1 + }, + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved" + } + ], + "blockers" => [], + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/end_to_end_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/end_to_end_test.exs new file mode 100644 index 0000000..f59a043 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/end_to_end_test.exs @@ -0,0 +1,291 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EndToEndTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @timestamp "2026-06-25T00:00:00Z" + + test "TAPD and CNB shadow template chain reaches observation decision without production writes" do + assert_shadow_chain(:tapd_cnb_shadow, "tapd-cnb-shadow", "tapd", "shadow-run-cnb-42") + end + + test "Linear and CNB shadow template chain reaches observation decision without production writes" do + assert_shadow_chain(:linear_cnb_shadow, "linear-cnb-shadow", "linear", "shadow-run-linear-cnb-42") + end + + defp assert_shadow_chain(template, entry_id, tracker_kind, shadow_run_id) do + assert {:ok, claim} = + ProductionProfile.phase2_claim_template(template, shadow_run_id: shadow_run_id) + + assert [ + %{ + "id" => ^entry_id, + "tracker" => %{"kind" => ^tracker_kind}, + "repo_provider" => %{"kind" => "cnb"}, + "side_effect_mode" => "shadow_no_write", + "shadow" => shadow + } + ] = claim["provider_matrix"] + + assert shadow["prefix"] == OneShotContract.shadow_prefix() + assert shadow["authority"] == OneShotContract.shadow_authority() + assert shadow["canonical_authority"] == false + assert shadow["run_id"] == shadow_run_id + + assert {:ok, evidence_template} = ProductionProfile.phase2_evidence_packet_template(claim) + assert evidence_template["does_not_collect_live_evidence"] == true + assert Enum.all?(evidence_template["scenario_evidence_requirements"], &shadow_requirement?/1) + + evidence_packet = complete_evidence_packet(evidence_template) + assert {:ok, evidence_packet} = ProductionProfile.validate_evidence_packet(evidence_packet) + + assert {:ok, review_template} = ProductionProfile.phase4_review_packet_template(evidence_packet) + assert review_template["does_not_read_evidence_files"] == true + + review_packet = complete_review_packet(review_template, entry_id, template, shadow_run_id) + assert {:ok, review_packet} = ProductionProfile.validate_review_packet(review_packet) + assert review_packet["scrubbing_pipeline"]["failure_behavior"] == "fail_closed" + assert review_packet["retention_policy"]["tombstone_preserving"] == true + + assert {:ok, review_decision} = ProductionProfile.review_decision(review_packet) + assert review_decision["status"] == "ready_for_approval" + assert review_decision["does_not_enable_production"] == true + assert review_decision["raw_evidence_payload_included"] == false + + assert {:ok, enablement_template} = + ProductionProfile.enablement_request_template(review_decision, + provider_matrix_entry_ids: [entry_id], + repositories: ["acme/widgets"] + ) + + assert enablement_template["does_not_enable_production"] == true + + enablement_request = complete_enablement_request(enablement_template, entry_id) + + assert {:ok, normalized_enablement_request} = + ProductionProfile.validate_enablement_request(enablement_request) + + assert normalized_enablement_request["gate_values"][Gates.transition_readiness_required_gate_key()] == + false + + assert {:ok, apply_plan} = ProductionProfile.operator_apply_plan(enablement_request) + assert apply_plan["status"] == "ready_for_operator_apply" + assert apply_plan["does_not_apply_settings"] == true + assert apply_plan["can_apply_automatically"] == false + + assert {:ok, apply_record_template} = ProductionProfile.operator_apply_record_template(apply_plan) + assert apply_record_template["does_not_apply_settings"] == true + + apply_record = complete_apply_record(apply_record_template, entry_id) + + assert {:ok, normalized_apply_record} = + ProductionProfile.validate_operator_apply_record(apply_record) + + assert normalized_apply_record["records_external_operator_apply"] == true + + assert {:ok, observation_template} = ProductionProfile.observation_status_template(apply_record) + assert observation_template["records_observation_only"] == true + assert observation_template["does_not_enable_production"] == true + + observation_status = complete_observation_status(observation_template, entry_id) + + assert {:ok, normalized_observation_status} = + ProductionProfile.validate_observation_status(observation_status) + + assert normalized_observation_status["no_write_observation"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + + assert {:ok, observation_decision} = ProductionProfile.observation_decision(observation_status) + assert observation_decision["status"] == "observation_passed" + assert observation_decision["records_observation_only"] == true + assert observation_decision["does_not_enable_production"] == true + assert observation_decision["raw_evidence_payload_included"] == false + end + + defp shadow_requirement?(requirement) do + no_write_flags = requirement["no_write_flags"] + shadow = requirement["shadow"] + + requirement["required_evidence_kind"] == "shadow_integration" and + no_write_flags["production_write_performed"] == false and + no_write_flags["canonical_surface_mutated"] == false and + shadow["prefix"] == OneShotContract.shadow_prefix() and + shadow["authority"] == OneShotContract.shadow_authority() and + shadow["canonical_authority"] == false and + shadow["allowed_destinations"] == OneShotContract.shadow_allowed_destinations() + end + + defp complete_evidence_packet(template) do + %{ + "production_claim" => template["production_claim"], + "scenario_evidence" => Enum.map(template["scenario_evidence_requirements"], &complete_scenario_evidence/1), + "non_claim_acknowledgements" => + Enum.map( + template["non_claim_acknowledgement_requirements"], + &complete_non_claim_acknowledgement/1 + ) + } + end + + defp complete_scenario_evidence(requirement) do + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "scenario_id" => requirement["scenario_id"], + "status" => requirement["required_status"], + "evidence_kind" => requirement["required_evidence_kind"], + "collector" => "provider-integration-owner", + "collected_at" => @timestamp, + "evidence_files" => requirement["evidence_files"], + "shadow" => requirement["shadow"], + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + end + + defp complete_non_claim_acknowledgement(requirement) do + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "non_claims" => requirement["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => @timestamp + } + end + + defp complete_review_packet(template, entry_id, plan_template, shadow_run_id) do + template["review_packet_field_template"] + |> Map.merge(%{ + "review_packet_id" => "review-packet-#{entry_id}", + "changed_source_specs" => [ + "specs/workflow/profiles/coding_pr_delivery/production_hardening_plan.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md" + ], + "implementation_refs" => ["commit:production-profile-#{entry_id}-shadow-chain"], + "deterministic_test_matrix" => [ + %{ + "command" => "mise exec -- mix test test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile", + "status" => "passed" + } + ], + "provider_preflight_reports" => [passed_preflight_report(plan_template, shadow_run_id)], + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => @timestamp + } + ] + }) + |> put_in(["scrubbing_pipeline", "test_results"], [ + %{"name" => "scrubber evidence write and render boundaries", "status" => "passed"} + ]) + end + + defp passed_preflight_report(:tapd_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:tapd_cnb_shadow, tapd_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp passed_preflight_report(:linear_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(template, &1)) + end) + end + + defp preflight_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => @timestamp, + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp complete_enablement_request(template, entry_id) do + template["enablement_request_field_template"] + |> Map.merge(%{ + "enablement_request_id" => "enablement-#{entry_id}", + "requested_by" => "release-manager", + "requested_at" => @timestamp, + "approvals" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => @timestamp + } + ] + }) + |> put_in(["activation_control", "change_ticket"], "CHANGE-123") + end + + defp complete_apply_record(template, entry_id) do + template["apply_record_field_template"] + |> Map.merge(%{"apply_record_id" => "apply-record-#{entry_id}"}) + |> put_in(["apply_metadata", "applied_by"], "release-operator") + |> put_in(["apply_metadata", "applied_at"], @timestamp) + |> put_in(["rollback_readiness", "verified"], true) + |> put_in(["observation_start", "started"], true) + |> Map.update!("completed_operator_steps", fn steps -> + Enum.map(steps, fn step -> + step + |> Map.put("completed_by", "release-operator") + |> Map.put("completed_at", @timestamp) + end) + end) + end + + defp complete_observation_status(template, entry_id) do + template["observation_status_field_template"] + |> Map.merge(%{ + "observation_status_id" => "observation-#{entry_id}", + "observed_by" => "release-operator", + "observed_at" => @timestamp, + "status" => "passed" + }) + |> Map.update!("criteria_results", fn results -> + Enum.map(results, fn result -> + result + |> Map.put("status", "passed") + |> Map.put("observed_at", @timestamp) + |> Map.put("evidence_files", [ + "evidence/observation/#{String.replace(result["criterion"], " ", "-")}.md" + ]) + end) + end) + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_bundle_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_bundle_test.exs new file mode 100644 index 0000000..c25d007 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_bundle_test.exs @@ -0,0 +1,261 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceBundleTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceBundle + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + test "marks a single-provider Phase 2 bundle ready when preflight and evidence pass" do + phase2_plan = phase2_plan(:linear_cnb_shadow) + preflight_report = passed_preflight_report(phase2_plan) + evidence_packet = completed_evidence_packet(List.first(phase2_plan["provider_plans"])) + + assert {:ok, bundle} = + EvidenceBundle.build(phase2_plan, + preflight_report: preflight_report, + evidence_packets: [evidence_packet] + ) + + assert bundle["schema"] == "coding_pr_delivery.production_evidence_bundle.v1" + assert bundle["status"] == "ready_for_phase4_review" + assert bundle["phase4_ready"] == true + assert bundle["preflight"]["status"] == "passed" + assert bundle["evidence_packets"]["valid_count"] == 1 + assert bundle["evidence_packets"]["invalid_count"] == 0 + assert bundle["blockers"] == [] + assert bundle["does_not_read_evidence_files"] == true + assert bundle["does_not_call_providers"] == true + assert bundle["does_not_enable_production"] == true + + assert [ + %{ + "template" => "linear_cnb_shadow", + "evidence_packet_status" => "valid", + "review_packet_template_ready" => true + } + ] = bundle["provider_bundle_statuses"] + end + + test "keeps tiered plans blocked until every provider plan has evidence" do + phase2_plan = phase2_plan(:tiered_reference) + preflight_report = passed_preflight_report(phase2_plan) + linear_cnb_plan = Enum.find(phase2_plan["provider_plans"], &(&1["template"] == "linear_cnb_shadow")) + evidence_packet = completed_evidence_packet(linear_cnb_plan) + + assert {:ok, bundle} = + EvidenceBundle.build(phase2_plan, + preflight_report: preflight_report, + evidence_packets: [evidence_packet] + ) + + assert bundle["status"] == "blocked" + assert bundle["phase4_ready"] == false + assert Enum.any?(bundle["blockers"], &(&1["code"] == "provider_evidence_packet_missing" and &1["template"] == "linear_github_ready")) + assert Enum.any?(bundle["blockers"], &(&1["code"] == "provider_evidence_packet_missing" and &1["template"] == "tapd_cnb_shadow")) + end + + test "blocks invalid or blocked preflight metadata before Phase 4 review" do + phase2_plan = phase2_plan(:linear_cnb_shadow) + evidence_packet = completed_evidence_packet(List.first(phase2_plan["provider_plans"])) + + assert {:ok, missing_preflight} = EvidenceBundle.build(phase2_plan, evidence_packets: [evidence_packet]) + assert Enum.any?(missing_preflight["blockers"], &(&1["code"] == "provider_preflight_report_required")) + + assert {:ok, blocked_preflight} = + EvidenceBundle.build(phase2_plan, + preflight_report: blocked_preflight_report(phase2_plan), + evidence_packets: [evidence_packet] + ) + + assert Enum.any?(blocked_preflight["blockers"], &(&1["code"] == "provider_preflight_blocked")) + end + + test "blocks mismatched evidence requests and unmatched evidence packets" do + phase2_plan = phase2_plan(:linear_cnb_shadow) + preflight_report = passed_preflight_report(phase2_plan) + other_plan = phase2_plan(:tapd_cnb_shadow) + other_evidence_packet = completed_evidence_packet(List.first(other_plan["provider_plans"])) + + assert {:ok, request} = ProductionProfile.production_evidence_request(:tapd_cnb_shadow) + + assert {:ok, bundle} = + EvidenceBundle.build(phase2_plan, + evidence_request: request, + preflight_report: preflight_report, + evidence_packets: [other_evidence_packet] + ) + + assert bundle["status"] == "blocked" + assert Enum.any?(bundle["blockers"], &(&1["code"] == "evidence_request_invalid")) + assert Enum.any?(bundle["blockers"], &(&1["code"] == "completed_evidence_packet_unmatched")) + assert Enum.any?(bundle["blockers"], &(&1["code"] == "provider_evidence_packet_missing")) + end + + test "exposes evidence bundle readiness through the production profile facade" do + phase2_plan = phase2_plan(:linear_cnb_shadow) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_evidence_bundle.v1"}} = + ProductionProfile.production_evidence_bundle(phase2_plan) + end + + test "rejects invalid options without provider calls" do + assert {:error, %{code: "coding_pr_delivery_evidence_bundle_invalid", errors: [error]}} = + EvidenceBundle.build(:linear_cnb_shadow, :not_keyword) + + assert error.code == "invalid_options" + end + + defp phase2_plan(plan_id) do + assert {:ok, plan} = + ProductionProfile.phase2_evidence_plan(plan_id, + tapd_cnb_shadow_run_id: "bundle-tapd-cnb-shadow", + linear_cnb_shadow_run_id: "bundle-linear-cnb-shadow" + ) + + plan + end + + defp passed_preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan, "passed"), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp blocked_preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan, "blocked"), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan, status) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(provider_plan, &1, status)) + end) + end + + defp preflight_result(provider_plan, command, "passed") do + %{ + "template" => Map.fetch!(provider_plan, "template"), + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-26T04:10:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "missing_prerequisites" => [], + "evidence_files" => ["evidence/preflight/#{Map.fetch!(provider_plan, "template")}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp preflight_result(provider_plan, command, "blocked") do + %{ + "template" => Map.fetch!(provider_plan, "template"), + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "blocked", + "blocker_code" => "missing_preflight_prerequisite", + "missing_prerequisites" => [first_prerequisite(command)], + "ran_at" => "2026-06-26T04:10:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{Map.fetch!(provider_plan, "template")}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp completed_evidence_packet(provider_plan) do + runbook = Map.fetch!(provider_plan, "evidence_runbook") + + %{ + "production_claim" => Map.fetch!(provider_plan, "production_claim"), + "scenario_evidence" => scenario_evidence(runbook), + "non_claim_acknowledgements" => non_claim_acknowledgements(runbook) + } + end + + defp scenario_evidence(runbook) do + Enum.flat_map(runbook["entries"], fn entry -> + Enum.map(entry["scenario_checklist"], fn scenario -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "scenario_id" => scenario["id"], + "status" => "passed", + "evidence_kind" => evidence_kind(entry), + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-26T04:30:00Z", + "evidence_files" => ["evidence/live/#{entry["entry_id"]}/#{scenario["id"]}.md"], + "production_write_performed" => false, + "canonical_surface_mutated" => false, + "shadow" => shadow(entry) + } + end) + end) + end + + defp evidence_kind(entry) do + if entry["side_effect_mode"] == OneShotContract.shadow_mode() do + "shadow_integration" + else + "real_integration" + end + end + + defp shadow(entry) do + if entry["side_effect_mode"] == OneShotContract.shadow_mode() do + shadow_metadata(entry) + else + nil + end + end + + defp shadow_metadata(entry) do + %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => get_in(entry, ["shadow_requirements", "run_id"]) || "bundle-shadow-run", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + end + + defp non_claim_acknowledgements(runbook) do + Enum.map(runbook["entries"], fn entry -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "non_claims" => entry["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-26T04:30:00Z" + } + end) + end + + defp first_prerequisite(command) do + command + |> Map.take(["required_env", "required_auth", "required_targets", "required_runtime"]) + |> Map.values() + |> Enum.flat_map(fn + values when is_list(values) -> values + _value -> [] + end) + |> List.first() + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_handoff_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_handoff_test.exs new file mode 100644 index 0000000..d0fe90c --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_handoff_test.exs @@ -0,0 +1,178 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceHandoffTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceHandoff + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + test "builds a blocked provider-owner handoff package for missing evidence" do + assert {:ok, handoff} = EvidenceHandoff.build(:tiered_reference) + + assert handoff["schema"] == "coding_pr_delivery.production_evidence_handoff.v1" + assert handoff["status"] == "blocked_pending_external_evidence" + assert handoff["phase4_ready"] == false + assert handoff["provider_plan_count"] == 3 + assert handoff["preflight"]["status"] == "missing" + assert handoff["evidence_bundle"]["status"] == "blocked" + assert handoff["evidence_bundle"]["blocker_count"] > 0 + + assert Enum.sort(handoff["external_input_summary"]["required_env"]) == [ + "CNB_TOKEN", + "LINEAR_API_KEY", + "LINEAR_PROJECT_SLUG", + "TAPD_API_PASSWORD", + "TAPD_API_USER", + "TAPD_WORKSPACE_ID" + ] + + assert Enum.any?( + handoff["provider_handoffs"], + &(&1["template"] == "linear_cnb_shadow" and &1["evidence_packet_status"] == "missing") + ) + + assert Enum.any?( + handoff["operator_commands"], + &(&1["command_id"] == + "symphony.workflow.extension.coding_pr_delivery.production_profile_evidence_handoff") + ) + + assert handoff["does_not_collect_live_evidence"] == true + assert handoff["does_not_read_evidence_files"] == true + assert handoff["does_not_call_providers"] == true + assert handoff["does_not_enable_production"] == true + assert handoff["raw_input_included"] == false + assert handoff["normalized_artifacts_included"] == false + end + + test "marks handoff ready when preflight and evidence bundle are ready" do + phase2_plan = phase2_plan(:linear_cnb_shadow) + preflight_report = passed_preflight_report(phase2_plan) + evidence_packet = completed_evidence_packet(List.first(phase2_plan["provider_plans"])) + + assert {:ok, handoff} = + EvidenceHandoff.build(phase2_plan, + preflight_report: preflight_report, + evidence_packets: [evidence_packet] + ) + + assert handoff["status"] == "ready_for_phase4_review" + assert handoff["phase4_ready"] == true + assert handoff["required_next_step"] == "build and validate the Phase 4 review packet with owner sign-off" + assert handoff["preflight"]["status"] == "passed" + assert handoff["evidence_bundle"]["phase4_ready"] == true + assert handoff["blockers"] == [] + + assert [ + %{ + "template" => "linear_cnb_shadow", + "evidence_packet_status" => "valid", + "matching_evidence_packet_count" => 1, + "review_packet_template_ready" => true + } + ] = handoff["provider_handoffs"] + end + + test "exposes evidence handoff through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_evidence_handoff.v1"}} = + ProductionProfile.production_evidence_handoff(:linear_cnb_shadow) + end + + test "rejects invalid options without provider calls" do + assert {:error, %{code: "coding_pr_delivery_evidence_handoff_invalid", errors: [error]}} = + EvidenceHandoff.build(:linear_cnb_shadow, :not_keyword) + + assert error.code == "invalid_options" + end + + defp phase2_plan(plan_id) do + assert {:ok, plan} = + ProductionProfile.phase2_evidence_plan(plan_id, + tapd_cnb_shadow_run_id: "handoff-tapd-cnb-shadow", + linear_cnb_shadow_run_id: "handoff-linear-cnb-shadow" + ) + + plan + end + + defp passed_preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(fn command -> + %{ + "template" => Map.fetch!(provider_plan, "template"), + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-26T06:30:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "missing_prerequisites" => [], + "evidence_files" => ["evidence/preflight/#{Map.fetch!(command, "id")}.md"] + } + end) + end) + end + + defp completed_evidence_packet(provider_plan) do + runbook = Map.fetch!(provider_plan, "evidence_runbook") + + %{ + "production_claim" => Map.fetch!(provider_plan, "production_claim"), + "scenario_evidence" => scenario_evidence(runbook), + "non_claim_acknowledgements" => non_claim_acknowledgements(runbook) + } + end + + defp scenario_evidence(runbook) do + Enum.flat_map(runbook["entries"], fn entry -> + Enum.map(entry["scenario_checklist"], fn scenario -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "scenario_id" => scenario["id"], + "status" => "passed", + "evidence_kind" => "shadow_integration", + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-26T06:30:00Z", + "evidence_files" => ["evidence/live/#{entry["entry_id"]}/#{scenario["id"]}.md"], + "production_write_performed" => false, + "canonical_surface_mutated" => false, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => get_in(entry, ["shadow_requirements", "run_id"]) || "handoff-linear-cnb-shadow", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + } + end) + end) + end + + defp non_claim_acknowledgements(runbook) do + Enum.map(runbook["entries"], fn entry -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "non_claims" => entry["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-26T06:30:00Z" + } + end) + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_template_test.exs new file mode 100644 index 0000000..b4d2192 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_template_test.exs @@ -0,0 +1,121 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacketTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacketTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2ClaimTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + test "builds Phase 2 evidence packet requirements from a reference claim" do + assert {:ok, claim} = Phase2ClaimTemplate.build(:reference, shadow_run_id: "shadow-run-cnb-42") + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + assert template["schema"] == "coding_pr_delivery.production_evidence_packet_template.v1" + assert template["completed_packet_schema"] == "coding_pr_delivery.production_evidence_packet.v1" + assert template["template_authority"] == "evidence_packet_shape_only" + assert template["does_not_collect_live_evidence"] == true + + scenario_count = + template["runbook"]["entries"] + |> Enum.flat_map(& &1["scenario_checklist"]) + |> length() + + assert length(template["scenario_evidence_requirements"]) == scenario_count + assert length(template["non_claim_acknowledgement_requirements"]) == 2 + end + + test "marks Linear + GitHub requirements as real integration evidence" do + assert {:ok, claim} = Phase2ClaimTemplate.build(:linear_github_ready) + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + requirement = + Enum.find(template["scenario_evidence_requirements"], fn requirement -> + requirement["provider_matrix_entry_id"] == "linear-github-ready" and + requirement["scenario_id"] == "reconciliation_ready_to_land_write" + end) + + assert requirement["required_status"] == "passed" + assert requirement["required_evidence_kind"] == "real_integration" + assert requirement["allowed_evidence_ref_prefixes"] == ["evidence/", "https://", "http://"] + assert requirement["raw_provider_output_allowed"] == false + assert requirement["shadow"] == nil + assert requirement["no_write_flags"] == nil + end + + test "carries TAPD + CNB shadow run id and no-write requirements" do + assert {:ok, claim} = Phase2ClaimTemplate.build(:tapd_cnb_shadow, shadow_run_id: "shadow-run-cnb-42") + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + requirement = + Enum.find(template["scenario_evidence_requirements"], fn requirement -> + requirement["provider_matrix_entry_id"] == "tapd-cnb-shadow" and + requirement["scenario_id"] == "shadow_isolation" + end) + + assert requirement["required_evidence_kind"] == "shadow_integration" + assert requirement["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert requirement["shadow"]["run_id"] == "shadow-run-cnb-42" + assert requirement["shadow"]["authority"] == OneShotContract.shadow_authority() + assert requirement["shadow"]["canonical_authority"] == false + assert requirement["shadow"]["allowed_destinations"] == OneShotContract.shadow_allowed_destinations() + + assert requirement["no_write_flags"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + end + + test "carries Linear + CNB shadow run id and no-write requirements" do + assert {:ok, claim} = + Phase2ClaimTemplate.build(:linear_cnb_shadow, + shadow_run_id: "shadow-run-linear-cnb-42" + ) + + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + requirement = + Enum.find(template["scenario_evidence_requirements"], fn requirement -> + requirement["provider_matrix_entry_id"] == "linear-cnb-shadow" and + requirement["scenario_id"] == "shadow_isolation" + end) + + assert requirement["required_evidence_kind"] == "shadow_integration" + assert requirement["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert requirement["shadow"]["run_id"] == "shadow-run-linear-cnb-42" + assert requirement["shadow"]["authority"] == OneShotContract.shadow_authority() + assert requirement["shadow"]["canonical_authority"] == false + assert requirement["shadow"]["allowed_destinations"] == OneShotContract.shadow_allowed_destinations() + + assert requirement["no_write_flags"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + end + + test "requires non-claim acknowledgement fields per provider entry" do + assert {:ok, claim} = Phase2ClaimTemplate.build(:tapd_cnb_shadow) + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + assert [ + %{ + "provider_matrix_entry_id" => "tapd-cnb-shadow", + "fields_to_complete" => ["owner", "acknowledged_at"], + "non_claims" => non_claims + } + ] = template["non_claim_acknowledgement_requirements"] + + assert "multi_node_ownership" in non_claims + end + + test "rejects invalid production claims before building requirements" do + assert {:error, %{code: "coding_pr_delivery_production_claim_invalid", errors: [%{code: "invalid_type"}]}} = + EvidencePacketTemplate.build(:invalid) + end + + test "exposes evidence packet templates through the production profile facade" do + assert {:ok, claim} = ProductionProfile.phase2_claim_template(:tapd_cnb_shadow) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_evidence_packet_template.v1"}} = + ProductionProfile.phase2_evidence_packet_template(claim) + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_test.exs new file mode 100644 index 0000000..97014c2 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_packet_test.exs @@ -0,0 +1,301 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacketTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacket + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRunbook + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "accepts complete Phase 2 evidence packets for a shadow provider claim" do + assert {:ok, packet} = EvidencePacket.validate(complete_packet()) + + assert packet["schema"] == "coding_pr_delivery.production_evidence_packet.v1" + assert packet["profile_instance_id"] == "coding-pr-delivery-production" + assert packet["runbook"]["claim_authority"] == "diagnostic_runbook_only" + + expected_count = + packet["runbook"]["entries"] + |> Enum.flat_map(& &1["scenario_checklist"]) + |> length() + + assert length(packet["scenario_evidence"]) == expected_count + + assert [ + %{ + "provider_matrix_entry_id" => "tapd-cnb-shadow", + "non_claims" => non_claims + } + ] = packet["non_claim_acknowledgements"] + + assert "multi_node_ownership" in non_claims + end + + test "accepts complete Phase 2 evidence packets for a Linear + CNB shadow claim" do + assert {:ok, packet} = + EvidencePacket.validate(complete_packet("linear-cnb-shadow", "linear", "shadow-run-linear-cnb-42")) + + assert packet["schema"] == "coding_pr_delivery.production_evidence_packet.v1" + assert packet["profile_instance_id"] == "coding-pr-delivery-production" + + assert [ + %{ + "provider_matrix_entry_id" => "linear-cnb-shadow", + "non_claims" => non_claims + } + ] = packet["non_claim_acknowledgements"] + + assert "multi_node_ownership" in non_claims + + assert Enum.all?(packet["scenario_evidence"], fn evidence -> + evidence["provider_matrix_entry_id"] == "linear-cnb-shadow" and + evidence["evidence_kind"] == "shadow_integration" and + evidence["production_write_performed"] == false and + evidence["canonical_surface_mutated"] == false and + evidence["shadow"]["run_id"] == "shadow-run-linear-cnb-42" + end) + end + + test "requires evidence for every runbook scenario" do + packet = complete_packet() + [removed | remaining] = packet["scenario_evidence"] + packet = Map.put(packet, "scenario_evidence", remaining) + + assert {:error, %{code: "coding_pr_delivery_evidence_packet_invalid", errors: errors}} = + EvidencePacket.validate(packet) + + assert Enum.any?( + errors, + &(&1.code == "missing_scenario_evidence" and + &1.provider_matrix_entry_id == removed["provider_matrix_entry_id"] and + &1.scenario_id == removed["scenario_id"]) + ) + end + + test "rejects shadow evidence that claims production authority or writes" do + packet = + complete_packet() + |> update_in(["scenario_evidence", Access.at(0)], fn record -> + record + |> put_in(["shadow", "prefix"], "[NOT_SHADOW]") + |> put_in(["shadow", "canonical_authority"], true) + |> Map.put("production_write_performed", true) + |> Map.put("canonical_surface_mutated", true) + end) + + assert {:error, %{errors: errors}} = EvidencePacket.validate(packet) + + assert Enum.any?(errors, &(&1.code == "invalid_shadow_prefix")) + assert Enum.any?(errors, &(&1.code == "shadow_canonical_authority")) + assert Enum.any?(errors, &(&1.code == "shadow_production_write")) + assert Enum.any?(errors, &(&1.code == "shadow_canonical_surface_mutated")) + end + + test "rejects invalid production claims before accepting evidence" do + packet = + complete_packet() + |> put_in(["production_claim", "profile_instance_id"], "") + + assert {:error, %{errors: errors}} = EvidencePacket.validate(packet) + + assert Enum.any?( + errors, + &(&1.code == "required_field_missing" and &1.path == ["production_claim", "profile_instance_id"]) + ) + end + + test "requires explicit non-claim acknowledgements for each provider entry" do + packet = Map.put(complete_packet(), "non_claim_acknowledgements", []) + + assert {:error, %{errors: errors}} = EvidencePacket.validate(packet) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["non_claim_acknowledgements"])) + end + + test "rejects raw provider output and invalid evidence references" do + packet = + complete_packet() + |> update_in(["scenario_evidence", Access.at(0)], fn record -> + record + |> Map.put("stdout", "raw provider output") + |> Map.put("raw_provider_payload", %{"provider_output" => "raw payload sample"}) + |> Map.put("evidence_files", [ + "fill-live-evidence.md", + "/tmp/provider-output.json", + "file:///var/tmp/provider-output.txt" + ]) + end) + + assert {:error, %{errors: errors}} = EvidencePacket.validate(packet) + + assert Enum.any?(errors, &(&1.code == "raw_evidence_payload_forbidden" and List.last(&1.path) == "stdout")) + assert Enum.any?(errors, &(&1.code == "raw_evidence_payload_forbidden" and List.last(&1.path) == "raw_provider_payload")) + assert Enum.any?(errors, &(&1.code == "placeholder_evidence_ref")) + assert Enum.any?(errors, &(&1.code == "invalid_evidence_ref")) + end + + test "accepts HTTP evidence links as bounded references" do + packet = + complete_packet() + |> update_in(["scenario_evidence", Access.at(0), "evidence_files"], fn [_first | rest] -> + ["https://github.com/joosure/Maestro/actions/runs/123" | rest] + end) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_evidence_packet.v1"}} = + EvidencePacket.validate(packet) + end + + defp complete_packet( + entry_id \\ "tapd-cnb-shadow", + tracker_kind \\ "tapd", + shadow_run_id \\ "shadow-run-1" + ) do + claim = production_claim(entry_id, tracker_kind, shadow_run_id) + assert {:ok, runbook} = EvidenceRunbook.build(claim) + + %{ + "production_claim" => claim, + "scenario_evidence" => scenario_evidence(runbook), + "non_claim_acknowledgements" => non_claim_acknowledgements(runbook) + } + end + + defp scenario_evidence(runbook) do + Enum.flat_map(runbook["entries"], fn entry -> + Enum.map(entry["scenario_checklist"], fn scenario -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "scenario_id" => scenario["id"], + "status" => "passed", + "evidence_kind" => "shadow_integration", + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-25T00:00:00Z", + "evidence_files" => ["evidence/live/#{entry["entry_id"]}/#{scenario["id"]}.md"], + "production_write_performed" => false, + "canonical_surface_mutated" => false, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => get_in(entry, ["shadow_requirements", "run_id"]) || "shadow-run-1", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + } + end) + end) + end + + defp non_claim_acknowledgements(runbook) do + Enum.map(runbook["entries"], fn entry -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "non_claims" => entry["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-25T00:00:00Z" + } + end) + end + + defp production_claim(entry_id, tracker_kind, shadow_run_id) do + %{ + "profile_instance_id" => "coding-pr-delivery-production", + "provider_matrix" => [shadow_entry(entry_id, tracker_kind, shadow_run_id)], + "production_governance" => [governance_packet(entry_id)] + } + end + + defp shadow_entry(entry_id, tracker_kind, shadow_run_id) do + %{ + "id" => entry_id, + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => tracker_kind}, + "repo_provider" => %{"kind" => "cnb"}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "candidate_discovery" => "runtime_targeted", + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => %{ + Gates.enabled_gate_key() => true, + Gates.provider_adapters_enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => false + }, + "typed_tool_inventory" => %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + }, + "deployment_topology" => %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + }, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => shadow_run_id, + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + }, + "evidence_files" => ["evidence/provider-matrix/#{entry_id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp governance_packet(provider_matrix_entry_id) do + %{ + "provider_matrix_entry_id" => provider_matrix_entry_id, + "profile_instance_id" => "coding-pr-delivery-production", + "durability_profile" => %{ + "storage_backend" => "sqlite", + "restart_behavior" => "survives process and worker restarts", + "backup_restore" => "daily backup with documented restore drill", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "bounded_payload_summaries_only", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "cleanup_schedule" => "daily", + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Gates.enabled_gate_key(), + "disable_rendering_gate" => Gates.render_workpad_gate_key(), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Gates.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => ["evidence/structured-plan/governance/#{provider_matrix_entry_id}.md"] + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_request_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_request_test.exs new file mode 100644 index 0000000..d8b415d --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_request_test.exs @@ -0,0 +1,72 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRequestTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRequest + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + test "summarizes provider access and target requirements for the tiered plan" do + assert {:ok, request} = EvidenceRequest.build(:tiered_reference) + + assert request["schema"] == "coding_pr_delivery.production_evidence_request.v1" + assert request["status"] == "blocked_pending_external_evidence" + assert request["provider_request_count"] == 3 + assert request["does_not_call_providers"] == true + assert request["does_not_read_evidence_files"] == true + assert request["does_not_enable_production"] == true + assert request["raw_input_included"] == false + assert request["normalized_plan_included"] == false + + summary = request["external_input_summary"] + assert "LINEAR_API_KEY" in summary["required_env"] + assert "TAPD_API_USER" in summary["required_env"] + assert "CNB_TOKEN" in summary["required_env"] + assert "gh auth status" in summary["required_auth"] + assert "repo_slug" in summary["required_targets"] + assert "change_proposal_number" in summary["required_targets"] + assert "provider_read_only_smoke" in summary["required_runtime"] + end + + test "keeps Linear + CNB shadow evidence requests diagnostic-only" do + assert {:ok, request} = + EvidenceRequest.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: "linear-shadow-evidence-request") + + assert [provider_request] = request["provider_requests"] + + assert provider_request["template"] == "linear_cnb_shadow" + assert provider_request["tracker_kinds"] == ["linear"] + assert provider_request["repo_provider_kinds"] == ["cnb"] + assert provider_request["side_effect_modes"] == [OneShotContract.shadow_mode()] + assert "collect_shadow_no_write_evidence" in provider_request["owner_actions"] + assert provider_request["does_not_authorize_production_writes"] == true + + assert ["LINEAR_API_KEY", "LINEAR_PROJECT_SLUG", "CNB_TOKEN"] = + provider_request["required_access"]["required_env"] + + assert Enum.any?(provider_request["read_only_preflight_commands"], &(&1["id"] == "linear-tracker-read-only-smoke")) + assert Enum.any?(provider_request["read_only_preflight_commands"], &(&1["id"] == "cnb-repo-provider-read-only-smoke")) + + assert [%{"shadow" => shadow} | _rest] = provider_request["evidence_requirements"] + assert shadow["prefix"] == OneShotContract.shadow_prefix() + assert shadow["run_id"] == "linear-shadow-evidence-request" + assert shadow["authority"] == OneShotContract.shadow_authority() + assert shadow["canonical_authority"] == false + assert OneShotContract.shadow_allowed_destinations() == shadow["allowed_destinations"] + + assert [%{"non_claims" => non_claims}] = provider_request["non_claim_acknowledgements"] + assert "multi_node_ownership" in non_claims + assert "automatic_durable_replay" in non_claims + end + + test "exposes evidence requests through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_evidence_request.v1"}} = + ProductionProfile.production_evidence_request(:linear_github_ready) + end + + test "rejects invalid options without provider calls" do + assert {:error, %{code: "coding_pr_delivery_evidence_request_invalid", errors: [error]}} = + EvidenceRequest.build(:tiered_reference, :not_keyword) + + assert error.code == "invalid_options" + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_runbook_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_runbook_test.exs new file mode 100644 index 0000000..761fd75 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/evidence_runbook_test.exs @@ -0,0 +1,261 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRunbookTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRunbook + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "builds provider-specific Phase 2 evidence runbooks from accepted claims" do + assert {:ok, runbook} = EvidenceRunbook.build(production_claim()) + + assert runbook["schema"] == "coding_pr_delivery.production_evidence_runbook.v1" + assert runbook["does_not_execute_providers"] == true + assert runbook["claim_authority"] == "diagnostic_runbook_only" + + assert [ready, shadow] = runbook["entries"] + + assert ready["entry_id"] == "linear-github-ready" + assert ready["ready_to_land_requirements"]["transition_readiness_required"] == true + assert ready["shadow_requirements"] == nil + assert "automatic_cold_start_provider_rebuild_unless_evidenced" in ready["non_claims"] + assert Enum.any?(ready["scenario_checklist"], &(&1["id"] == "reconciliation_ready_to_land_write")) + assert Enum.any?(ready["collection_steps"], &(&1["id"] == "verify_transition_readiness_gate")) + + assert shadow["entry_id"] == "tapd-cnb-shadow" + assert shadow["shadow_requirements"]["prefix"] == OneShotContract.shadow_prefix() + assert shadow["shadow_requirements"]["run_id"] == "shadow-run-1" + assert shadow["shadow_requirements"]["canonical_authority"] == false + assert "multi_node_ownership" in shadow["non_claims"] + assert Enum.any?(shadow["scenario_checklist"], &(&1["id"] == "shadow_decision_chain")) + assert Enum.any?(shadow["collection_steps"], &(&1["id"] == "verify_shadow_isolation")) + end + + test "builds Linear + CNB shadow evidence runbooks with diagnostic-only requirements" do + assert {:ok, %{"entries" => [entry]}} = + EvidenceRunbook.build(production_claim([shadow_entry("linear-cnb-shadow", "linear", "cnb")], [])) + + assert entry["entry_id"] == "linear-cnb-shadow" + assert entry["tracker"]["kind"] == "linear" + assert entry["repo_provider"]["kind"] == "cnb" + assert entry["side_effect_mode"] == OneShotContract.shadow_mode() + assert entry["ready_to_land_requirements"] == nil + assert entry["shadow_requirements"]["prefix"] == OneShotContract.shadow_prefix() + assert entry["shadow_requirements"]["run_id"] == "shadow-run-1" + assert entry["shadow_requirements"]["authority"] == OneShotContract.shadow_authority() + assert entry["shadow_requirements"]["canonical_authority"] == false + assert "multi_node_ownership" in entry["non_claims"] + assert Enum.any?(entry["scenario_checklist"], &(&1["id"] == "shadow_isolation")) + assert Enum.any?(entry["collection_steps"], &(&1["id"] == "verify_shadow_isolation")) + assert entry["evidence_files"]["provider_matrix"] == ["evidence/provider-matrix/linear-cnb-shadow.md"] + assert entry["evidence_files"]["typed_tool_exceptions"] == [] + end + + test "carries evidence destinations and matching exception evidence" do + assert {:ok, %{"entries" => [ready, shadow]}} = EvidenceRunbook.build(production_claim()) + + assert ready["evidence_files"]["provider_matrix"] == ["evidence/provider-matrix/linear-github-ready.md"] + assert ready["evidence_files"]["production_governance"] == ["evidence/structured-plan/governance/linear-github-ready.md"] + assert ready["evidence_files"]["typed_tool_exceptions"] == ["evidence/typed-tool-exceptions/linear-github-review-read.md"] + + assert shadow["evidence_files"]["provider_matrix"] == ["evidence/provider-matrix/tapd-cnb-shadow.md"] + assert shadow["evidence_files"]["production_governance"] == ["evidence/structured-plan/governance/tapd-cnb-shadow.md"] + assert shadow["evidence_files"]["typed_tool_exceptions"] == [] + end + + test "rejects invalid claims before generating a runbook" do + claim = + production_claim() + |> put_in(["provider_matrix", Access.at(0), "structured_plan_gates", Gates.transition_readiness_required_gate_key()], false) + + assert {:error, %{code: "coding_pr_delivery_production_claim_invalid", errors: errors}} = EvidenceRunbook.build(claim) + + assert Enum.any?( + errors, + &(&1.code == "transition_readiness_required" and + &1.path == ["provider_matrix", 0, "structured_plan_gates", Gates.transition_readiness_required_gate_key()]) + ) + end + + test "rejects non-object input through production claim admission" do + assert {:error, %{code: "coding_pr_delivery_production_claim_invalid", errors: [%{code: "invalid_type"}]}} = + EvidenceRunbook.build(:invalid) + end + + defp production_claim do + entries = [ready_to_land_entry(), shadow_entry("tapd-cnb-shadow", "tapd", "cnb")] + + production_claim(entries, [typed_tool_exception()]) + end + + defp production_claim(entries, typed_tool_exceptions) do + %{ + "profile_instance_id" => "coding_pr_delivery.production.claim", + "provider_matrix" => entries, + "production_governance" => Enum.map(entries, &governance_packet(&1["id"])), + "typed_tool_exceptions" => typed_tool_exceptions + } + end + + defp ready_to_land_entry do + base_entry("linear-github-ready", "linear", "github") + |> Map.merge(%{ + "side_effect_mode" => "ready_to_land_write", + "structured_plan_gates" => gates(true), + "deployment_topology" => %{ + "mode" => "distributed_lock", + "ownership_proof" => "redis-lock:workflow:coding-pr-delivery" + } + }) + end + + defp shadow_entry(id, tracker, repo_provider) do + base_entry(id, tracker, repo_provider) + |> Map.merge(%{ + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => gates(false), + "deployment_topology" => %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + }, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => "shadow-run-1", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + }) + end + + defp base_entry(id, tracker, repo_provider) do + %{ + "id" => id, + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => tracker}, + "repo_provider" => %{"kind" => repo_provider}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "candidate_discovery" => "runtime_targeted", + "typed_tool_inventory" => %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + }, + "evidence_files" => ["evidence/provider-matrix/#{id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp governance_packet(provider_matrix_entry_id) do + %{ + "provider_matrix_entry_id" => provider_matrix_entry_id, + "profile_instance_id" => "coding_pr_delivery.production.claim", + "durability_profile" => %{ + "storage_backend" => "sqlite", + "restart_behavior" => "survives process and worker restarts", + "backup_restore" => "daily backup with documented restore drill", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "bounded_payload_summaries_only", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "cleanup_schedule" => "daily", + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Gates.enabled_gate_key(), + "disable_rendering_gate" => Gates.render_workpad_gate_key(), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Gates.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => ["evidence/structured-plan/governance/#{provider_matrix_entry_id}.md"] + } + end + + defp typed_tool_exception do + %{ + "exception_id" => "typed-tool-exception-linear-github-1", + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => "linear"}, + "repo_provider" => %{"kind" => "github"}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "workspace_class" => "staging_workspace", + "route_set" => ["developing", "review"], + "operation_set" => ["repo_provider.review.read"], + "fallback_authority" => %{ + "owner" => "workflow-runtime", + "authority_kind" => "temporary_backend_adapter", + "accepted_by_profile_owners" => true + }, + "compensating_controls" => ["read_only_operation", "bounded_schema_allowlist"], + "input_schema_allowlist" => %{ + "schema_ids" => ["repo_provider.review.read.v1"], + "rejects_unknown_fields" => true + }, + "limits" => %{"max_calls_per_run" => 3, "max_concurrency" => 1}, + "audit_logging" => %{ + "event_name" => "coding_pr_delivery_typed_tool_exception_used", + "retention_class" => "workflow_audit_staging" + }, + "deterministic_tests" => [ + "test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception_test.exs" + ], + "real_integration_evidence" => ["evidence/typed-tool-exceptions/linear-github-review-read.md"], + "operator_observability" => %{ + "metrics" => ["typed_tool_exception_used_total"], + "alerts" => ["typed_tool_exception_usage_outside_scope"], + "runbook" => "runbooks/coding-pr-delivery/typed-tool-exception.md" + }, + "rollback" => %{ + "owner" => "workflow-runtime", + "instructions" => "Remove exception id from production enablement packet and require typed tool inventory.", + "disables_exception" => true, + "restores_typed_tool_requirement" => true + }, + "expires_at" => "2026-07-25T00:00:00Z" + } + end + + defp gates(transition_readiness_required) do + %{ + Gates.enabled_gate_key() => true, + Gates.provider_adapters_enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => transition_readiness_required + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_decision_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_decision_test.exs new file mode 100644 index 0000000..9f6eb15 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_decision_test.exs @@ -0,0 +1,209 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationDecisionTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationDecision + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "projects passed observation status into a bounded reviewer decision" do + assert {:ok, decision} = ObservationDecision.build(valid_status()) + + assert decision["schema"] == "coding_pr_delivery.production_observation_decision.v1" + assert decision["status"] == "observation_passed" + assert decision["observation_status_id"] == "observation-tapd-cnb-shadow" + assert decision["profile_instance_id"] == "coding-pr-delivery-production" + assert decision["criteria_summary"] == %{"passed" => 2, "failed" => 0, "in_progress" => 0, "total" => 2} + + assert decision["no_write_observation"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + + assert decision["blockers"] == [] + assert decision["records_observation_only"] == true + assert decision["does_not_enable_production"] == true + assert decision["raw_evidence_payload_included"] == false + end + + test "projects Linear + CNB shadow observation status into a bounded reviewer decision" do + assert {:ok, decision} = ObservationDecision.build(valid_status(:linear_cnb_shadow)) + + assert decision["schema"] == "coding_pr_delivery.production_observation_decision.v1" + assert decision["status"] == "observation_passed" + assert decision["observation_status_id"] == "observation-linear-cnb-shadow" + assert decision["apply_record_id"] == "apply-record-linear-cnb-shadow" + assert decision["review_packet_id"] == "review-packet-linear-cnb-shadow" + + assert decision["no_write_observation"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + + assert decision["blockers"] == [] + assert decision["records_observation_only"] == true + assert decision["does_not_enable_production"] == true + assert decision["raw_evidence_payload_included"] == false + end + + test "projects failed and in-progress observations without raw evidence payloads" do + failed_status = + valid_status() + |> Map.put("status", "failed") + |> put_in(["criteria_results", Access.at(0), "status"], "failed") + + in_progress_status = + valid_status() + |> Map.put("status", "in_progress") + |> put_in(["criteria_results", Access.at(0), "status"], "in_progress") + + assert {:ok, failed_decision} = ObservationDecision.build(failed_status) + assert failed_decision["status"] == "observation_failed" + assert failed_decision["criteria_summary"]["failed"] == 1 + assert failed_decision["raw_evidence_payload_included"] == false + + assert {:ok, in_progress_decision} = ObservationDecision.build(in_progress_status) + assert in_progress_decision["status"] == "observation_in_progress" + assert in_progress_decision["criteria_summary"]["in_progress"] == 1 + assert in_progress_decision["raw_evidence_payload_included"] == false + end + + test "returns a blocked decision with bounded blockers for invalid observation status" do + invalid_status = + valid_status() + |> put_in(["no_write_observation", "canonical_surface_mutated"], true) + + assert {:ok, decision} = ObservationDecision.build(invalid_status) + + assert decision["status"] == "blocked" + assert decision["observation_status_id"] == "observation-tapd-cnb-shadow" + assert decision["profile_instance_id"] == "coding-pr-delivery-production" + assert decision["criteria_summary"] == %{"passed" => 2, "failed" => 0, "in_progress" => 0, "total" => 2} + assert Enum.any?(decision["blockers"], &(&1["code"] == "shadow_canonical_surface_mutated")) + assert decision["raw_evidence_payload_included"] == false + end + + test "exposes observation decisions through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_observation_decision.v1"}} = + ProductionProfile.observation_decision(valid_status()) + end + + defp valid_status(template \\ :tapd_cnb_shadow) do + entry_id = entry_id(template) + record = valid_apply_record(template) + window = record["observation_start"]["observation_window"] + + %{ + "observation_status_id" => "observation-#{entry_id}", + "operator_apply_record" => record, + "observed_by" => "release-operator", + "observed_at" => "2026-06-25T01:00:00Z", + "status" => "passed", + "observation_window" => window, + "criteria_results" => + Enum.map(window["success_criteria"], fn criterion -> + %{ + "criterion" => criterion, + "status" => "passed", + "observed_at" => "2026-06-25T01:00:00Z", + "evidence_files" => ["evidence/observation/#{String.replace(criterion, " ", "-")}.md"] + } + end), + "no_write_observation" => %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + } + end + + defp valid_apply_record(template) do + entry_id = entry_id(template) + plan = ready_apply_plan(template) + + %{ + "apply_record_id" => "apply-record-#{entry_id}", + "operator_apply_plan" => plan, + "apply_metadata" => %{ + "applied_by" => "release-operator", + "applied_at" => "2026-06-25T00:30:00Z", + "change_ticket" => "CHANGE-123", + "operator_confirmation" => true, + "automatic_apply" => false + }, + "applied_scope" => plan["scope"], + "applied_gate_values" => plan["gate_values"], + "completed_operator_steps" => completed_steps(plan), + "rollback_readiness" => %{ + "owner" => "workflow-runtime", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true + }, + "observation_start" => %{ + "started" => true, + "observation_window" => plan["observation_window"] + } + } + end + + defp completed_steps(plan) do + Enum.map(plan["operator_steps"], fn step -> + %{ + "id" => step["id"], + "status" => "completed", + "completed_by" => "release-operator", + "completed_at" => "2026-06-25T00:30:00Z" + } + end) + end + + defp ready_apply_plan(template) do + entry_id = entry_id(template) + + %{ + "schema" => "coding_pr_delivery.production_operator_apply_plan.v1", + "status" => "ready_for_operator_apply", + "enablement_request_id" => "enablement-#{entry_id}", + "profile_instance_id" => "coding-pr-delivery-production", + "review_packet_id" => "review-packet-#{entry_id}", + "scope" => %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => [entry_id], + "side_effect_mode" => "shadow_no_write" + }, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "observation_window" => %{ + "duration_days" => 14, + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + }, + "activation_control" => %{ + "change_ticket" => "CHANGE-123", + "requires_operator_apply" => true, + "applies_immediately" => false + }, + "operator_steps" => [ + %{"id" => "confirm_change_ticket"}, + %{"id" => "verify_scope"}, + %{"id" => "apply_gate_values"}, + %{"id" => "start_observation_window"}, + %{"id" => "record_operator_apply"} + ], + "rollback_steps" => [ + %{"id" => "disable_transition_readiness", "gate" => Gates.transition_readiness_required_gate_key(), "owner" => "workflow-runtime"}, + %{"id" => "disable_configured_gates", "disable_gates" => [Gates.transition_readiness_required_gate_key(), Gates.enabled_gate_key()]} + ], + "blockers" => [], + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false + } + end + + defp entry_id(:tapd_cnb_shadow), do: "tapd-cnb-shadow" + defp entry_id(:linear_cnb_shadow), do: "linear-cnb-shadow" +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_template_test.exs new file mode 100644 index 0000000..61e5cba --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_template_test.exs @@ -0,0 +1,194 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatusTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatus + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatusTemplate + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "builds an observation-status template from a valid operator apply record" do + apply_record = valid_apply_record() + + assert {:ok, template} = ObservationStatusTemplate.build(apply_record) + + assert template["schema"] == "coding_pr_delivery.production_observation_status_template.v1" + assert template["completed_packet_schema"] == "coding_pr_delivery.production_observation_status.v1" + assert template["template_authority"] == "observation_status_shape_only" + assert template["records_observation_only"] == true + assert template["does_not_enable_production"] == true + assert template["apply_record_id"] == "apply-record-tapd-cnb-shadow" + assert template["status_options"] == ["in_progress", "passed", "failed"] + assert template["allowed_evidence_ref_prefixes"] == ["evidence/", "https://", "http://"] + + field_template = template["observation_status_field_template"] + + assert field_template["operator_apply_record"] == apply_record + assert field_template["status"] == "in_progress" + assert field_template["observation_window"] == apply_record["observation_start"]["observation_window"] + assert length(field_template["criteria_results"]) == 2 + assert Enum.all?(field_template["criteria_results"], &(&1["allowed_evidence_ref_prefixes"] == ["evidence/", "https://", "http://"])) + assert Enum.all?(field_template["criteria_results"], &(List.first(&1["evidence_files"]) =~ "evidence/observation/")) + + assert field_template["no_write_observation"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + + assert "no_write_observation.production_write_performed" in template["fields_to_complete"] + end + + test "builds a Linear + CNB shadow observation-status template from a valid operator apply record" do + apply_record = valid_apply_record(:linear_cnb_shadow) + + assert {:ok, template} = ObservationStatusTemplate.build(apply_record) + + field_template = template["observation_status_field_template"] + + assert template["schema"] == "coding_pr_delivery.production_observation_status_template.v1" + assert template["apply_record_id"] == "apply-record-linear-cnb-shadow" + assert template["review_packet_id"] == "review-packet-linear-cnb-shadow" + assert template["records_observation_only"] == true + assert template["does_not_enable_production"] == true + assert field_template["operator_apply_record"] == apply_record + assert field_template["observation_window"] == apply_record["observation_start"]["observation_window"] + + assert field_template["no_write_observation"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + + assert "no_write_observation.canonical_surface_mutated" in template["fields_to_complete"] + end + + test "field template can be completed into a valid observation status" do + assert {:ok, template} = ObservationStatusTemplate.build(valid_apply_record()) + + status = + template["observation_status_field_template"] + |> Map.merge(%{ + "observation_status_id" => "observation-tapd-cnb-shadow", + "observed_by" => "release-operator", + "observed_at" => "2026-06-25T01:00:00Z", + "status" => "passed" + }) + |> Map.update!("criteria_results", fn results -> + Enum.map(results, fn result -> + result + |> Map.put("status", "passed") + |> Map.put("observed_at", "2026-06-25T01:00:00Z") + |> Map.put("evidence_files", ["evidence/observation/#{String.replace(result["criterion"], " ", "-")}.md"]) + end) + end) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_observation_status.v1"}} = + ObservationStatus.validate(status) + end + + test "rejects invalid operator apply records before building a template" do + invalid_record = + valid_apply_record() + |> put_in(["apply_metadata", "automatic_apply"], true) + + assert {:error, %{code: "coding_pr_delivery_operator_apply_record_invalid", errors: errors}} = + ObservationStatusTemplate.build(invalid_record) + + assert Enum.any?(errors, &(&1.code == "automatic_apply")) + end + + test "exposes observation status templates through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_observation_status_template.v1"}} = + ProductionProfile.observation_status_template(valid_apply_record()) + end + + defp valid_apply_record(template \\ :tapd_cnb_shadow) do + entry_id = entry_id(template) + plan = ready_apply_plan(template) + + %{ + "apply_record_id" => "apply-record-#{entry_id}", + "operator_apply_plan" => plan, + "apply_metadata" => %{ + "applied_by" => "release-operator", + "applied_at" => "2026-06-25T00:30:00Z", + "change_ticket" => "CHANGE-123", + "operator_confirmation" => true, + "automatic_apply" => false + }, + "applied_scope" => plan["scope"], + "applied_gate_values" => plan["gate_values"], + "completed_operator_steps" => completed_steps(plan), + "rollback_readiness" => %{ + "owner" => "workflow-runtime", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true + }, + "observation_start" => %{ + "started" => true, + "observation_window" => plan["observation_window"] + } + } + end + + defp completed_steps(plan) do + Enum.map(plan["operator_steps"], fn step -> + %{ + "id" => step["id"], + "status" => "completed", + "completed_by" => "release-operator", + "completed_at" => "2026-06-25T00:30:00Z" + } + end) + end + + defp ready_apply_plan(template) do + entry_id = entry_id(template) + + %{ + "schema" => "coding_pr_delivery.production_operator_apply_plan.v1", + "status" => "ready_for_operator_apply", + "enablement_request_id" => "enablement-#{entry_id}", + "profile_instance_id" => "coding-pr-delivery-production", + "review_packet_id" => "review-packet-#{entry_id}", + "scope" => %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => [entry_id], + "side_effect_mode" => "shadow_no_write" + }, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "observation_window" => %{ + "duration_days" => 14, + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + }, + "activation_control" => %{ + "change_ticket" => "CHANGE-123", + "requires_operator_apply" => true, + "applies_immediately" => false + }, + "operator_steps" => [ + %{"id" => "confirm_change_ticket"}, + %{"id" => "verify_scope"}, + %{"id" => "apply_gate_values"}, + %{"id" => "start_observation_window"}, + %{"id" => "record_operator_apply"} + ], + "rollback_steps" => [ + %{"id" => "disable_transition_readiness", "gate" => Gates.transition_readiness_required_gate_key(), "owner" => "workflow-runtime"}, + %{"id" => "disable_configured_gates", "disable_gates" => [Gates.transition_readiness_required_gate_key(), Gates.enabled_gate_key()]} + ], + "blockers" => [], + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false + } + end + + defp entry_id(:tapd_cnb_shadow), do: "tapd-cnb-shadow" + defp entry_id(:linear_cnb_shadow), do: "linear-cnb-shadow" +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_test.exs new file mode 100644 index 0000000..743caa9 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/observation_status_test.exs @@ -0,0 +1,227 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatusTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ObservationStatus + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "accepts passed observation status for a valid operator apply record" do + assert {:ok, status} = ObservationStatus.validate(valid_status()) + + assert status["schema"] == "coding_pr_delivery.production_observation_status.v1" + assert status["observation_status_id"] == "observation-tapd-cnb-shadow" + assert status["status"] == "passed" + assert status["profile_instance_id"] == "coding-pr-delivery-production" + assert status["apply_record_id"] == "apply-record-tapd-cnb-shadow" + assert status["records_observation_only"] == true + assert status["does_not_enable_production"] == true + end + + test "accepts Linear + CNB shadow observation status for a valid operator apply record" do + assert {:ok, status} = ObservationStatus.validate(valid_status(:linear_cnb_shadow)) + + assert status["schema"] == "coding_pr_delivery.production_observation_status.v1" + assert status["observation_status_id"] == "observation-linear-cnb-shadow" + assert status["apply_record_id"] == "apply-record-linear-cnb-shadow" + assert status["review_packet_id"] == "review-packet-linear-cnb-shadow" + + assert status["no_write_observation"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + + assert status["records_observation_only"] == true + assert status["does_not_enable_production"] == true + end + + test "requires exact success-criteria coverage and matching summary status" do + [first | _rest] = valid_status()["criteria_results"] + + status = + valid_status() + |> Map.put("status", "passed") + |> Map.put("criteria_results", [ + %{first | "status" => "failed"}, + first, + %{ + "criterion" => "unreviewed criterion", + "status" => "passed", + "observed_at" => "2026-06-25T01:00:00Z", + "evidence_files" => ["evidence/observation/unreviewed.md"] + } + ]) + + assert {:error, %{errors: errors}} = ObservationStatus.validate(status) + + assert Enum.any?(errors, &(&1.code == "observation_status_mismatch" and &1.expected_status == "failed")) + assert Enum.any?(errors, &(&1.code == "missing_criterion_result" and &1.criterion == "zero shadow isolation violations")) + assert Enum.any?(errors, &(&1.code == "duplicate_criterion_result" and &1.criterion == "zero canonical writes")) + assert Enum.any?(errors, &(&1.code == "unexpected_criterion_result" and &1.criterion == "unreviewed criterion")) + end + + test "rejects shadow observations that mutate canonical surfaces or change the window" do + status = + valid_status() + |> put_in(["observation_window", "duration_days"], 1) + |> put_in(["no_write_observation", "production_write_performed"], true) + |> put_in(["no_write_observation", "canonical_surface_mutated"], true) + + assert {:error, %{errors: errors}} = ObservationStatus.validate(status) + + assert Enum.any?(errors, &(&1.code == "observation_window_mismatch")) + assert Enum.any?(errors, &(&1.code == "shadow_production_write")) + assert Enum.any?(errors, &(&1.code == "shadow_canonical_surface_mutated")) + end + + test "rejects unbounded observation evidence references" do + status = + valid_status() + |> put_in(["criteria_results", Access.at(0), "evidence_files"], [ + "fill-observation-evidence-file", + "file:///var/tmp/observation.txt" + ]) + + assert {:error, %{errors: errors}} = ObservationStatus.validate(status) + + assert Enum.any?(errors, &(&1.code == "invalid_evidence_ref")) + assert Enum.any?(errors, &(&1.code == "placeholder_evidence_ref")) + end + + test "rejects invalid nested operator apply records" do + status = + valid_status() + |> put_in(["operator_apply_record", "apply_metadata", "automatic_apply"], true) + + assert {:error, %{errors: errors}} = ObservationStatus.validate(status) + + assert Enum.any?( + errors, + &(&1.code == "automatic_apply" and &1.path == ["operator_apply_record", "apply_metadata", "automatic_apply"]) + ) + end + + test "exposes observation status validation through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_observation_status.v1"}} = + ProductionProfile.validate_observation_status(valid_status()) + end + + defp valid_status(template \\ :tapd_cnb_shadow) do + entry_id = entry_id(template) + record = valid_apply_record(template) + window = record["observation_start"]["observation_window"] + + %{ + "observation_status_id" => "observation-#{entry_id}", + "operator_apply_record" => record, + "observed_by" => "release-operator", + "observed_at" => "2026-06-25T01:00:00Z", + "status" => "passed", + "observation_window" => window, + "criteria_results" => + Enum.map(window["success_criteria"], fn criterion -> + %{ + "criterion" => criterion, + "status" => "passed", + "observed_at" => "2026-06-25T01:00:00Z", + "evidence_files" => ["evidence/observation/#{String.replace(criterion, " ", "-")}.md"] + } + end), + "no_write_observation" => %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + } + end + + defp valid_apply_record(template) do + entry_id = entry_id(template) + plan = ready_apply_plan(template) + + %{ + "apply_record_id" => "apply-record-#{entry_id}", + "operator_apply_plan" => plan, + "apply_metadata" => %{ + "applied_by" => "release-operator", + "applied_at" => "2026-06-25T00:30:00Z", + "change_ticket" => "CHANGE-123", + "operator_confirmation" => true, + "automatic_apply" => false + }, + "applied_scope" => plan["scope"], + "applied_gate_values" => plan["gate_values"], + "completed_operator_steps" => completed_steps(plan), + "rollback_readiness" => %{ + "owner" => "workflow-runtime", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true + }, + "observation_start" => %{ + "started" => true, + "observation_window" => plan["observation_window"] + } + } + end + + defp completed_steps(plan) do + Enum.map(plan["operator_steps"], fn step -> + %{ + "id" => step["id"], + "status" => "completed", + "completed_by" => "release-operator", + "completed_at" => "2026-06-25T00:30:00Z" + } + end) + end + + defp ready_apply_plan(template) do + entry_id = entry_id(template) + + %{ + "schema" => "coding_pr_delivery.production_operator_apply_plan.v1", + "status" => "ready_for_operator_apply", + "enablement_request_id" => "enablement-#{entry_id}", + "profile_instance_id" => "coding-pr-delivery-production", + "review_packet_id" => "review-packet-#{entry_id}", + "scope" => %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => [entry_id], + "side_effect_mode" => "shadow_no_write" + }, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "observation_window" => %{ + "duration_days" => 14, + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + }, + "activation_control" => %{ + "change_ticket" => "CHANGE-123", + "requires_operator_apply" => true, + "applies_immediately" => false + }, + "operator_steps" => [ + %{"id" => "confirm_change_ticket"}, + %{"id" => "verify_scope"}, + %{"id" => "apply_gate_values"}, + %{"id" => "start_observation_window"}, + %{"id" => "record_operator_apply"} + ], + "rollback_steps" => [ + %{"id" => "disable_transition_readiness", "gate" => Gates.transition_readiness_required_gate_key(), "owner" => "workflow-runtime"}, + %{"id" => "disable_configured_gates", "disable_gates" => [Gates.transition_readiness_required_gate_key(), Gates.enabled_gate_key()]} + ], + "blockers" => [], + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false + } + end + + defp entry_id(:tapd_cnb_shadow), do: "tapd-cnb-shadow" + defp entry_id(:linear_cnb_shadow), do: "linear-cnb-shadow" +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_plan_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_plan_test.exs new file mode 100644 index 0000000..3d1c551 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_plan_test.exs @@ -0,0 +1,165 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyPlanTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyPlan + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "builds bounded operator apply plans from accepted enablement requests" do + assert {:ok, plan} = OperatorApplyPlan.build(valid_request()) + + assert plan["schema"] == "coding_pr_delivery.production_operator_apply_plan.v1" + assert plan["status"] == "ready_for_operator_apply" + assert plan["enablement_request_id"] == "enablement-tapd-cnb-shadow" + assert plan["profile_instance_id"] == "coding-pr-delivery-production" + assert plan["review_packet_id"] == "review-packet-tapd-cnb-shadow" + assert plan["does_not_apply_settings"] == true + assert plan["requires_operator_confirmation"] == true + assert plan["can_apply_automatically"] == false + assert plan["blockers"] == [] + + assert Enum.any?(plan["operator_steps"], &(&1["id"] == "apply_gate_values")) + assert Enum.any?(plan["operator_steps"], &(&1["id"] == "record_operator_apply" and &1["requires_operator_confirmation"] == true)) + assert Enum.any?(plan["rollback_steps"], &(&1["gate"] == Gates.transition_readiness_required_gate_key())) + end + + test "builds Linear + CNB shadow operator apply plans from accepted enablement requests" do + assert {:ok, plan} = OperatorApplyPlan.build(valid_request("linear-cnb-shadow", "linear")) + + assert plan["schema"] == "coding_pr_delivery.production_operator_apply_plan.v1" + assert plan["status"] == "ready_for_operator_apply" + assert plan["enablement_request_id"] == "enablement-linear-cnb-shadow" + assert plan["profile_instance_id"] == "coding-pr-delivery-production" + assert plan["review_packet_id"] == "review-packet-linear-cnb-shadow" + assert plan["does_not_apply_settings"] == true + assert plan["requires_operator_confirmation"] == true + assert plan["can_apply_automatically"] == false + assert plan["blockers"] == [] + + assert plan["scope"] == %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => ["linear-cnb-shadow"], + "side_effect_mode" => "shadow_no_write" + } + + assert plan["gate_values"][Gates.transition_readiness_required_gate_key()] == false + assert plan["activation_control"]["applies_immediately"] == false + assert Enum.any?(plan["operator_steps"], &(&1["id"] == "verify_scope" and &1["scope"] == plan["scope"])) + assert Enum.any?(plan["operator_steps"], &(&1["id"] == "apply_gate_values" and &1["gate_values"] == plan["gate_values"])) + assert Enum.any?(plan["rollback_steps"], &(&1["gate"] == Gates.transition_readiness_required_gate_key())) + end + + test "projects invalid enablement requests into blocked apply plans" do + request = + valid_request() + |> put_in(["review_decision", "status"], "blocked") + |> put_in(["review_decision", "blockers"], [%{"code" => "missing_evidence"}]) + + assert {:ok, plan} = OperatorApplyPlan.build(request) + + assert plan["status"] == "blocked" + assert plan["enablement_request_id"] == "enablement-tapd-cnb-shadow" + assert plan["operator_steps"] == [] + assert plan["rollback_steps"] == [] + assert plan["does_not_apply_settings"] == true + assert plan["can_apply_automatically"] == false + + assert Enum.any?(plan["blockers"], &(&1["code"] == "review_decision_not_ready")) + end + + test "projects non-object input into a blocked apply plan" do + assert {:ok, plan} = OperatorApplyPlan.build(:invalid) + + assert plan["status"] == "blocked" + assert plan["enablement_request_id"] == nil + assert plan["operator_steps"] == [] + assert [%{"code" => "invalid_type"}] = plan["blockers"] + end + + defp valid_request(entry_id \\ "tapd-cnb-shadow", tracker_kind \\ "tapd") do + %{ + "enablement_request_id" => "enablement-#{entry_id}", + "requested_by" => "release-manager", + "requested_at" => "2026-06-25T00:00:00Z", + "review_decision" => ready_review_decision(entry_id, tracker_kind), + "scope" => %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => [entry_id], + "side_effect_mode" => "shadow_no_write" + }, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "observation_window" => %{ + "duration_days" => 14, + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + }, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true + }, + "acknowledged_non_claims" => [ + "multi_node_ownership", + "automatic_durable_replay", + "automatic_cold_start_provider_rebuild" + ], + "approvals" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ], + "activation_control" => %{ + "change_ticket" => "CHANGE-123", + "requires_operator_apply" => true, + "applies_immediately" => false + } + } + end + + defp ready_review_decision(entry_id, tracker_kind) do + %{ + "schema" => "coding_pr_delivery.production_review_decision.v1", + "status" => "ready_for_approval", + "review_packet_id" => "review-packet-#{entry_id}", + "profile_instance_id" => "coding-pr-delivery-production", + "provider_entries" => [ + %{ + "entry_id" => entry_id, + "tracker" => %{"kind" => tracker_kind}, + "repo_provider" => %{"kind" => "cnb"}, + "agent_provider" => %{"kind" => "codex"}, + "side_effect_mode" => "shadow_no_write", + "topology_mode" => "singleton", + "non_claims" => [ + "multi_node_ownership", + "automatic_durable_replay", + "automatic_cold_start_provider_rebuild" + ] + } + ], + "evidence_summary" => %{ + "scenario_evidence_count" => 9, + "non_claim_acknowledgement_count" => 1 + }, + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved" + } + ], + "blockers" => [], + "does_not_enable_production" => true, + "raw_evidence_payload_included" => false + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_template_test.exs new file mode 100644 index 0000000..391fadf --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_template_test.exs @@ -0,0 +1,268 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecordTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EnablementRequestTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacketTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyPlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecord + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecordTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2ClaimTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewDecision + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacketTemplate + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "builds an operator apply-record template from a ready apply plan" do + assert {:ok, apply_plan} = ready_apply_plan() + assert {:ok, template} = OperatorApplyRecordTemplate.build(apply_plan) + + assert template["schema"] == "coding_pr_delivery.production_operator_apply_record_template.v1" + assert template["completed_packet_schema"] == "coding_pr_delivery.production_operator_apply_record.v1" + assert template["template_authority"] == "operator_apply_record_shape_only" + assert template["does_not_apply_settings"] == true + + field_template = template["apply_record_field_template"] + + assert field_template["operator_apply_plan"] == apply_plan + assert field_template["apply_metadata"]["change_ticket"] == "CHANGE-123" + assert field_template["apply_metadata"]["operator_confirmation"] == true + assert field_template["apply_metadata"]["automatic_apply"] == false + assert field_template["applied_scope"] == apply_plan["scope"] + assert field_template["applied_gate_values"] == apply_plan["gate_values"] + assert field_template["rollback_readiness"]["verified"] == false + assert field_template["observation_start"]["started"] == false + assert Gates.transition_readiness_required_gate_key() in field_template["rollback_readiness"]["disable_gates"] + end + + test "field template can be completed into a valid operator apply record" do + assert {:ok, apply_plan} = ready_apply_plan() + assert {:ok, template} = OperatorApplyRecordTemplate.build(apply_plan) + + record = + template["apply_record_field_template"] + |> Map.merge(%{"apply_record_id" => "apply-record-tapd-cnb-shadow"}) + |> put_in(["apply_metadata", "applied_by"], "release-operator") + |> put_in(["apply_metadata", "applied_at"], "2026-06-25T00:30:00Z") + |> put_in(["rollback_readiness", "verified"], true) + |> put_in(["observation_start", "started"], true) + |> Map.update!("completed_operator_steps", fn steps -> + Enum.map(steps, fn step -> + step + |> Map.put("completed_by", "release-operator") + |> Map.put("completed_at", "2026-06-25T00:30:00Z") + end) + end) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_operator_apply_record.v1"}} = + OperatorApplyRecord.validate(record) + end + + test "builds a Linear + CNB shadow operator apply-record template from a ready apply plan" do + assert {:ok, apply_plan} = ready_apply_plan(:linear_cnb_shadow, "shadow-run-linear-cnb-42") + assert {:ok, template} = OperatorApplyRecordTemplate.build(apply_plan) + + field_template = template["apply_record_field_template"] + + assert template["schema"] == "coding_pr_delivery.production_operator_apply_record_template.v1" + assert template["does_not_apply_settings"] == true + assert field_template["operator_apply_plan"] == apply_plan + + assert field_template["applied_scope"] == %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => ["linear-cnb-shadow"], + "side_effect_mode" => "shadow_no_write" + } + + assert field_template["applied_gate_values"][Gates.transition_readiness_required_gate_key()] == false + assert field_template["apply_metadata"]["operator_confirmation"] == true + assert field_template["apply_metadata"]["automatic_apply"] == false + assert field_template["rollback_readiness"]["verified"] == false + assert Gates.transition_readiness_required_gate_key() in field_template["rollback_readiness"]["disable_gates"] + assert field_template["observation_start"]["started"] == false + assert field_template["observation_start"]["observation_window"] == apply_plan["observation_window"] + end + + test "rejects blocked apply plans before building a record template" do + {:ok, blocked_plan} = OperatorApplyPlan.build(%{}) + + assert {:error, %{code: "coding_pr_delivery_operator_apply_record_template_invalid", errors: errors}} = + OperatorApplyRecordTemplate.build(blocked_plan) + + assert Enum.any?(errors, &(&1.code == "operator_apply_plan_not_ready")) + assert Enum.any?(errors, &(&1.code == "operator_apply_plan_blocked")) + end + + test "exposes operator apply-record templates through the production profile facade" do + assert {:ok, apply_plan} = ready_apply_plan() + + assert {:ok, %{"schema" => "coding_pr_delivery.production_operator_apply_record_template.v1"}} = + ProductionProfile.operator_apply_record_template(apply_plan) + end + + defp ready_apply_plan(template \\ :tapd_cnb_shadow, shadow_run_id \\ "shadow-run-cnb-42") do + entry_id = entry_id(template) + + assert {:ok, enablement_template} = + EnablementRequestTemplate.build(ready_review_decision(template, shadow_run_id), + provider_matrix_entry_ids: [entry_id], + repositories: ["acme/widgets"] + ) + + enablement_request = + enablement_template["enablement_request_field_template"] + |> Map.merge(%{ + "enablement_request_id" => "enablement-#{entry_id}", + "requested_by" => "release-manager", + "requested_at" => "2026-06-25T00:00:00Z", + "approvals" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ] + }) + |> put_in(["activation_control", "change_ticket"], "CHANGE-123") + + OperatorApplyPlan.build(enablement_request) + end + + defp ready_review_decision(template, shadow_run_id) do + entry_id = entry_id(template) + + assert {:ok, review_packet_template} = + ReviewPacketTemplate.build(completed_evidence_packet(template, shadow_run_id)) + + review_packet = + complete_review_packet(review_packet_template["review_packet_field_template"], entry_id, template, shadow_run_id) + + assert {:ok, decision} = ReviewDecision.build(review_packet) + decision + end + + defp complete_review_packet(field_template, entry_id, template, shadow_run_id) do + field_template + |> Map.merge(%{ + "review_packet_id" => "review-packet-#{entry_id}", + "changed_source_specs" => [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md" + ], + "implementation_refs" => [ + "commit:27fad4c", + "local-patch:coding-pr-delivery-apply-record-template" + ], + "deterministic_test_matrix" => [ + %{"command" => "mise exec -- mix test test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile", "status" => "passed"} + ], + "provider_preflight_reports" => [passed_preflight_report(template, shadow_run_id)], + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ] + }) + |> put_in(["scrubbing_pipeline", "test_results"], [ + %{"name" => "scrubber evidence boundaries", "status" => "passed"} + ]) + end + + defp passed_preflight_report(:tapd_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:tapd_cnb_shadow, tapd_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp passed_preflight_report(:linear_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(template, &1)) + end) + end + + defp preflight_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-25T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp completed_evidence_packet(template, shadow_run_id) do + assert {:ok, claim} = Phase2ClaimTemplate.build(template, shadow_run_id: shadow_run_id) + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + %{ + "production_claim" => claim, + "scenario_evidence" => Enum.map(template["scenario_evidence_requirements"], &complete_scenario_evidence/1), + "non_claim_acknowledgements" => Enum.map(template["non_claim_acknowledgement_requirements"], &complete_non_claim_acknowledgement/1) + } + end + + defp complete_scenario_evidence(requirement) do + evidence = + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "scenario_id" => requirement["scenario_id"], + "status" => requirement["required_status"], + "evidence_kind" => requirement["required_evidence_kind"], + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-25T00:00:00Z", + "evidence_files" => requirement["evidence_files"] + } + + if is_map(requirement["shadow"]) do + evidence + |> Map.merge(requirement["no_write_flags"]) + |> Map.put("shadow", requirement["shadow"]) + else + evidence + end + end + + defp complete_non_claim_acknowledgement(requirement) do + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "non_claims" => requirement["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-25T00:00:00Z" + } + end + + defp entry_id(:tapd_cnb_shadow), do: "tapd-cnb-shadow" + defp entry_id(:linear_cnb_shadow), do: "linear-cnb-shadow" +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_test.exs new file mode 100644 index 0000000..eed3cc1 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/operator_apply_record_test.exs @@ -0,0 +1,209 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecordTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.OperatorApplyRecord + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "accepts records that match an externally applied operator plan" do + assert {:ok, record} = OperatorApplyRecord.validate(valid_record()) + + assert record["schema"] == "coding_pr_delivery.production_operator_apply_record.v1" + assert record["apply_record_id"] == "apply-record-tapd-cnb-shadow" + assert record["enablement_request_id"] == "enablement-tapd-cnb-shadow" + assert record["profile_instance_id"] == "coding-pr-delivery-production" + assert record["records_external_operator_apply"] == true + assert record["does_not_apply_settings"] == true + assert "apply_gate_values" in record["completed_operator_step_ids"] + end + + test "accepts Linear + CNB shadow records that match an externally applied operator plan" do + assert {:ok, record} = OperatorApplyRecord.validate(valid_record(:linear_cnb_shadow)) + + assert record["schema"] == "coding_pr_delivery.production_operator_apply_record.v1" + assert record["apply_record_id"] == "apply-record-linear-cnb-shadow" + assert record["enablement_request_id"] == "enablement-linear-cnb-shadow" + assert record["review_packet_id"] == "review-packet-linear-cnb-shadow" + + assert record["applied_scope"] == %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => ["linear-cnb-shadow"], + "side_effect_mode" => "shadow_no_write" + } + + assert record["applied_gate_values"][Gates.transition_readiness_required_gate_key()] == false + assert record["does_not_apply_settings"] == true + assert record["records_external_operator_apply"] == true + assert "record_operator_apply" in record["completed_operator_step_ids"] + end + + test "rejects blocked or automatic apply plans" do + record = + valid_record() + |> put_in(["operator_apply_plan", "status"], "blocked") + |> put_in(["operator_apply_plan", "can_apply_automatically"], true) + + assert {:error, %{code: "coding_pr_delivery_operator_apply_record_invalid", errors: errors}} = + OperatorApplyRecord.validate(record) + + assert Enum.any?(errors, &(&1.code == "operator_apply_plan_not_ready")) + assert Enum.any?(errors, &(&1.code == "automatic_apply_allowed")) + end + + test "rejects automatic apply metadata and gate drift" do + record = + valid_record() + |> put_in(["apply_metadata", "automatic_apply"], true) + |> put_in(["applied_gate_values", Gates.transition_readiness_required_gate_key()], true) + |> put_in(["applied_gate_values", "review_handoff_required"], true) + + assert {:error, %{errors: errors}} = OperatorApplyRecord.validate(record) + + assert Enum.any?(errors, &(&1.code == "automatic_apply")) + assert Enum.any?(errors, &(&1.code == "legacy_gate_name")) + assert Enum.any?(errors, &(&1.code == "applied_gate_values_mismatch")) + end + + test "requires every plan step and rollback readiness" do + [_removed | remaining] = valid_record()["completed_operator_steps"] + + record = + valid_record() + |> Map.put("completed_operator_steps", remaining) + |> put_in(["rollback_readiness", "verified"], false) + |> put_in(["rollback_readiness", "disable_gates"], [Gates.enabled_gate_key()]) + + assert {:error, %{errors: errors}} = OperatorApplyRecord.validate(record) + + assert Enum.any?(errors, &(&1.code == "missing_operator_step" and &1.step_id == "confirm_change_ticket")) + assert Enum.any?(errors, &(&1.code == "rollback_not_ready")) + assert Enum.any?(errors, &(&1.code == "missing_rollback_gate" and &1.gate == Gates.transition_readiness_required_gate_key())) + end + + test "rejects duplicate and plan-external completed steps" do + [first | _rest] = steps = valid_record()["completed_operator_steps"] + + record = + valid_record() + |> Map.put("completed_operator_steps", [ + first, + first, + %{ + "id" => "apply_unreviewed_gate", + "status" => "completed", + "completed_by" => "release-operator", + "completed_at" => "2026-06-25T00:30:00Z" + } + | steps + ]) + + assert {:error, %{errors: errors}} = OperatorApplyRecord.validate(record) + + assert Enum.any?(errors, &(&1.code == "duplicate_operator_step" and &1.step_id == first["id"])) + assert Enum.any?(errors, &(&1.code == "unexpected_operator_step" and &1.step_id == "apply_unreviewed_gate")) + end + + test "requires observation start to match the apply plan" do + record = + valid_record() + |> put_in(["observation_start", "started"], false) + |> put_in(["observation_start", "observation_window", "duration_days"], 1) + + assert {:error, %{errors: errors}} = OperatorApplyRecord.validate(record) + + assert Enum.any?(errors, &(&1.code == "observation_not_started")) + assert Enum.any?(errors, &(&1.code == "observation_window_mismatch")) + end + + defp valid_record(template \\ :tapd_cnb_shadow) do + entry_id = entry_id(template) + plan = ready_apply_plan(template) + + %{ + "apply_record_id" => "apply-record-#{entry_id}", + "operator_apply_plan" => plan, + "apply_metadata" => %{ + "applied_by" => "release-operator", + "applied_at" => "2026-06-25T00:30:00Z", + "change_ticket" => "CHANGE-123", + "operator_confirmation" => true, + "automatic_apply" => false + }, + "applied_scope" => plan["scope"], + "applied_gate_values" => plan["gate_values"], + "completed_operator_steps" => completed_steps(plan), + "rollback_readiness" => %{ + "owner" => "workflow-runtime", + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ], + "verified" => true + }, + "observation_start" => %{ + "started" => true, + "observation_window" => plan["observation_window"] + } + } + end + + defp completed_steps(plan) do + Enum.map(plan["operator_steps"], fn step -> + %{ + "id" => step["id"], + "status" => "completed", + "completed_by" => "release-operator", + "completed_at" => "2026-06-25T00:30:00Z" + } + end) + end + + defp ready_apply_plan(template) do + entry_id = entry_id(template) + + %{ + "schema" => "coding_pr_delivery.production_operator_apply_plan.v1", + "status" => "ready_for_operator_apply", + "enablement_request_id" => "enablement-#{entry_id}", + "profile_instance_id" => "coding-pr-delivery-production", + "review_packet_id" => "review-packet-#{entry_id}", + "scope" => %{ + "environment" => "production", + "repositories" => ["acme/widgets"], + "provider_matrix_entry_ids" => [entry_id], + "side_effect_mode" => "shadow_no_write" + }, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "observation_window" => %{ + "duration_days" => 14, + "success_criteria" => ["zero canonical writes", "zero shadow isolation violations"] + }, + "activation_control" => %{ + "change_ticket" => "CHANGE-123", + "requires_operator_apply" => true, + "applies_immediately" => false + }, + "operator_steps" => [ + %{"id" => "confirm_change_ticket"}, + %{"id" => "verify_scope"}, + %{"id" => "apply_gate_values"}, + %{"id" => "start_observation_window"}, + %{"id" => "record_operator_apply"} + ], + "rollback_steps" => [ + %{"id" => "disable_transition_readiness", "gate" => Gates.transition_readiness_required_gate_key(), "owner" => "workflow-runtime"}, + %{"id" => "disable_configured_gates", "disable_gates" => [Gates.transition_readiness_required_gate_key(), Gates.enabled_gate_key()]} + ], + "blockers" => [], + "does_not_apply_settings" => true, + "requires_operator_confirmation" => true, + "can_apply_automatically" => false + } + end + + defp entry_id(:tapd_cnb_shadow), do: "tapd-cnb-shadow" + defp entry_id(:linear_cnb_shadow), do: "linear-cnb-shadow" +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_claim_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_claim_template_test.exs new file mode 100644 index 0000000..d9ce1e2 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_claim_template_test.exs @@ -0,0 +1,106 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2ClaimTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Claim + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRunbook + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2ClaimTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "builds a reference Phase 2 claim for Linear + GitHub and TAPD + CNB" do + assert {:ok, claim} = + Phase2ClaimTemplate.build(:reference, + profile_instance_id: "coding_pr_delivery.production.phase2.reference", + include_linear_github_review_read_exception?: true + ) + + assert {:ok, _normalized_claim} = Claim.validate(claim) + assert {:ok, runbook} = EvidenceRunbook.build(claim) + + assert ["linear-github-ready", "tapd-cnb-shadow"] == + Enum.map(claim["provider_matrix"], & &1["id"]) + + assert [%{"exception_id" => "typed-tool-exception-linear-github-review-read"}] = + claim["typed_tool_exceptions"] + + assert ["linear-github-ready", "tapd-cnb-shadow"] == + runbook["entries"] |> Enum.map(& &1["entry_id"]) + end + + test "builds a Linear + GitHub ready-to-land template with transition readiness required" do + assert {:ok, %{"provider_matrix" => [entry]} = claim} = + Phase2ClaimTemplate.build("linear_github_ready") + + assert {:ok, _normalized_claim} = Claim.validate(claim) + + assert entry["tracker"] == %{"kind" => "linear"} + assert entry["repo_provider"] == %{"kind" => "github"} + assert entry["side_effect_mode"] == "ready_to_land_write" + assert entry["structured_plan_gates"][Gates.transition_readiness_required_gate_key()] == true + assert entry["deployment_topology"]["mode"] == "singleton" + assert claim["typed_tool_exceptions"] == [] + end + + test "builds a TAPD + CNB shadow template with diagnostic-only isolation metadata" do + assert {:ok, %{"provider_matrix" => [entry]} = claim} = + Phase2ClaimTemplate.build(:tapd_cnb_shadow, shadow_run_id: "shadow-run-cnb-42") + + assert {:ok, runbook} = EvidenceRunbook.build(claim) + + assert entry["tracker"] == %{"kind" => "tapd"} + assert entry["repo_provider"] == %{"kind" => "cnb"} + assert entry["side_effect_mode"] == OneShotContract.shadow_mode() + assert entry["structured_plan_gates"][Gates.transition_readiness_required_gate_key()] == false + assert entry["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert entry["shadow"]["run_id"] == "shadow-run-cnb-42" + assert entry["shadow"]["canonical_authority"] == false + + assert [%{"shadow_requirements" => shadow_requirements}] = runbook["entries"] + assert shadow_requirements["allowed_destinations"] == OneShotContract.shadow_allowed_destinations() + end + + test "builds a Linear + CNB shadow template without ready-to-land authority" do + assert {:ok, %{"provider_matrix" => [entry]} = claim} = + Phase2ClaimTemplate.build(:linear_cnb_shadow) + + assert {:ok, _normalized_claim} = Claim.validate(claim) + assert {:ok, runbook} = EvidenceRunbook.build(claim) + + assert entry["id"] == "linear-cnb-shadow" + assert entry["tracker"] == %{"kind" => "linear"} + assert entry["repo_provider"] == %{"kind" => "cnb"} + assert entry["side_effect_mode"] == OneShotContract.shadow_mode() + assert entry["structured_plan_gates"][Gates.transition_readiness_required_gate_key()] == false + assert entry["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert entry["shadow"]["run_id"] == "linear-cnb-shadow-run-1" + assert entry["shadow"]["canonical_authority"] == false + + assert [%{"entry_id" => "linear-cnb-shadow", "shadow_requirements" => shadow_requirements}] = + runbook["entries"] + + assert shadow_requirements["authority"] == OneShotContract.shadow_authority() + assert shadow_requirements["allowed_destinations"] == OneShotContract.shadow_allowed_destinations() + end + + test "rejects unknown templates before building a production claim" do + assert {:error, %{code: "coding_pr_delivery_phase2_claim_template_invalid", errors: [error]}} = + Phase2ClaimTemplate.build(:github_only) + + assert error.code == "unknown_template" + assert "reference" in error.allowed_values + assert "tapd_cnb_shadow" in error.allowed_values + assert "linear_cnb_shadow" in error.allowed_values + end + + test "exposes templates through the production profile facade" do + assert "reference" in ProductionProfile.phase2_claim_templates() + assert "linear_cnb_shadow" in ProductionProfile.phase2_claim_templates() + + assert {:ok, %{"provider_matrix" => [%{"id" => "tapd-cnb-shadow"}]}} = + ProductionProfile.phase2_claim_template(:tapd_cnb_shadow) + + assert {:ok, %{"provider_matrix" => [%{"id" => "linear-cnb-shadow"}]}} = + ProductionProfile.phase2_claim_template(:linear_cnb_shadow) + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_evidence_plan_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_evidence_plan_test.exs new file mode 100644 index 0000000..5cf9ab1 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase2_evidence_plan_test.exs @@ -0,0 +1,154 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlanTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + + test "builds a tiered Phase 2 evidence plan without collecting live evidence" do + assert {:ok, plan} = + Phase2EvidencePlan.build(:tiered_reference, + tapd_cnb_shadow_run_id: "shadow-run-tapd-cnb-42", + linear_cnb_shadow_run_id: "shadow-run-linear-cnb-42" + ) + + assert plan["schema"] == "coding_pr_delivery.phase2_evidence_plan.v1" + assert plan["plan_kind"] == "tiered_reference" + assert plan["plan_authority"] == "phase2_evidence_planning_only" + assert plan["does_not_collect_live_evidence"] == true + assert plan["does_not_call_providers"] == true + assert plan["does_not_enable_production"] == true + assert plan["live_evidence_status"] == "not_collected" + + assert [ready, tapd_cnb, linear_cnb] = plan["provider_plans"] + + assert ready["tier"] == "tier_1_reference" + assert ready["template"] == "linear_github_ready" + assert ready["provider_matrix_entry_ids"] == ["linear-github-ready"] + assert ready["tracker_kinds"] == ["linear"] + assert ready["repo_provider_kinds"] == ["github"] + assert ready["side_effect_modes"] == ["ready_to_land_write"] + assert ready["live_evidence_status"] == "not_collected" + assert ready["evidence_packet_required_before_review"] == true + assert ready["scenario_count"] > 0 + assert_read_only_preflight(ready, "linear", "github") + + assert tapd_cnb["tier"] == "tier_2_cnb_shadow" + assert tapd_cnb["template"] == "tapd_cnb_shadow" + assert tapd_cnb["provider_matrix_entry_ids"] == ["tapd-cnb-shadow"] + assert tapd_cnb["tracker_kinds"] == ["tapd"] + assert tapd_cnb["repo_provider_kinds"] == ["cnb"] + assert tapd_cnb["side_effect_modes"] == [OneShotContract.shadow_mode()] + assert_read_only_preflight(tapd_cnb, "tapd", "cnb") + + assert linear_cnb["tier"] == "tier_2_cnb_shadow" + assert linear_cnb["template"] == "linear_cnb_shadow" + assert linear_cnb["provider_matrix_entry_ids"] == ["linear-cnb-shadow"] + assert linear_cnb["tracker_kinds"] == ["linear"] + assert linear_cnb["repo_provider_kinds"] == ["cnb"] + assert linear_cnb["side_effect_modes"] == [OneShotContract.shadow_mode()] + assert_read_only_preflight(linear_cnb, "linear", "cnb") + + assert_shadow_plan(tapd_cnb, "tapd-cnb-shadow", "shadow-run-tapd-cnb-42") + assert_shadow_plan(linear_cnb, "linear-cnb-shadow", "shadow-run-linear-cnb-42") + end + + test "builds a single provider Phase 2 evidence plan" do + assert {:ok, plan} = Phase2EvidencePlan.build("linear_github_ready", plan_id: "phase2-linear-github") + + assert plan["plan_id"] == "phase2-linear-github" + + assert [%{"tier" => "tier_1_reference", "provider_matrix_entry_ids" => ["linear-github-ready"]}] = + plan["provider_plans"] + end + + test "rejects unknown plans before building provider evidence templates" do + assert {:error, %{code: "coding_pr_delivery_phase2_evidence_plan_invalid", errors: [error]}} = + Phase2EvidencePlan.build(:github_only) + + assert error.code == "unknown_plan" + assert "tiered_reference" in error.allowed_values + end + + test "exposes Phase 2 evidence plans through the production profile facade" do + assert "tiered_reference" in ProductionProfile.phase2_evidence_plans() + + assert {:ok, %{"schema" => "coding_pr_delivery.phase2_evidence_plan.v1"}} = + ProductionProfile.phase2_evidence_plan(:tiered_reference) + end + + defp assert_shadow_plan(plan, entry_id, shadow_run_id) do + shadow_entry = + plan["production_claim"]["provider_matrix"] + |> Enum.find(&(&1["id"] == entry_id)) + + assert shadow_entry["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert shadow_entry["shadow"]["run_id"] == shadow_run_id + assert shadow_entry["shadow"]["authority"] == OneShotContract.shadow_authority() + assert shadow_entry["shadow"]["canonical_authority"] == false + + shadow_requirement = + plan["evidence_packet_template"]["scenario_evidence_requirements"] + |> Enum.find(&(&1["scenario_id"] == "shadow_isolation")) + + assert shadow_requirement["provider_matrix_entry_id"] == entry_id + assert shadow_requirement["required_evidence_kind"] == "shadow_integration" + assert shadow_requirement["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert shadow_requirement["shadow"]["run_id"] == shadow_run_id + assert shadow_requirement["shadow"]["authority"] == OneShotContract.shadow_authority() + assert shadow_requirement["shadow"]["canonical_authority"] == false + + assert shadow_requirement["no_write_flags"] == %{ + "production_write_performed" => false, + "canonical_surface_mutated" => false + } + end + + defp assert_read_only_preflight(plan, tracker_kind, repo_provider_kind) do + assert %{ + "status" => "not_run", + "does_not_collect_live_evidence" => true, + "does_not_mutate_workflow_state" => true, + "does_not_enable_production" => true, + "commands" => commands + } = plan["read_only_preflight"] + + assert [%{"target" => "tracker"} = tracker, %{"target" => "repo_provider"} = repo_provider] = commands + + assert tracker["provider_kind"] == tracker_kind + assert tracker["side_effect_mode"] == "read_only" + assert tracker["requires_write_confirmation"] == false + assert tracker["does_not_write"] == true + assert tracker["command"] =~ "mix tracker.smoke" + assert tracker["required_runtime"] == ["provider_read_only_smoke"] + assert_required_tracker_env(tracker_kind, tracker["required_env"]) + + assert repo_provider["provider_kind"] == repo_provider_kind + assert repo_provider["side_effect_mode"] == "read_only" + assert repo_provider["requires_destructive_flag"] == false + assert repo_provider["does_not_write"] == true + assert repo_provider["command"] =~ "mix repo_provider.smoke" + assert repo_provider["required_runtime"] == ["provider_read_only_smoke"] + assert_required_repo_provider_auth(repo_provider_kind, repo_provider) + end + + defp assert_required_tracker_env("linear", required_env) do + assert required_env == ["LINEAR_API_KEY", "LINEAR_PROJECT_SLUG"] + end + + defp assert_required_tracker_env("tapd", required_env) do + assert required_env == ["TAPD_API_USER", "TAPD_API_PASSWORD", "TAPD_WORKSPACE_ID"] + end + + defp assert_required_repo_provider_auth("github", repo_provider) do + assert repo_provider["required_env"] == [] + assert repo_provider["required_auth"] == ["gh auth status"] + assert repo_provider["required_targets"] == ["repo_slug", "change_proposal_number"] + end + + defp assert_required_repo_provider_auth("cnb", repo_provider) do + assert repo_provider["required_env"] == ["CNB_TOKEN"] + assert repo_provider["required_auth"] == [] + assert repo_provider["required_targets"] == ["repo_slug", "change_proposal_number"] + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase4_review_plan_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase4_review_plan_test.exs new file mode 100644 index 0000000..f3d7cc7 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/phase4_review_plan_test.exs @@ -0,0 +1,145 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase4ReviewPlanTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase4ReviewPlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + @owning_source_specs [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md", + "specs/workflow/typed_workflow_tools/conformance_spec.md", + "specs/workflow/profiles/coding_pr_delivery/review_handoff_readiness_policy/conformance_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/conformance_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md", + "specs/workflow/execution_plan_adoption/readiness_spec.md", + "specs/workflow/execution_plan_adoption/production_profile_spec.md" + ] + + test "builds a blocked Phase 4 review plan from tiered Phase 2 evidence plans" do + assert {:ok, plan} = + Phase4ReviewPlan.build(:tiered_reference, + tapd_cnb_shadow_run_id: "shadow-run-tapd-cnb-42", + linear_cnb_shadow_run_id: "shadow-run-linear-cnb-42" + ) + + assert plan["schema"] == "coding_pr_delivery.phase4_review_plan.v1" + assert plan["phase2_plan_kind"] == "tiered_reference" + assert plan["review_authority"] == "phase4_review_planning_only" + assert plan["phase4_ready"] == false + assert plan["review_decision_status"] == "blocked" + assert plan["does_not_collect_live_evidence"] == true + assert plan["does_not_read_evidence_files"] == true + assert plan["does_not_call_providers"] == true + assert plan["does_not_approve_production"] == true + assert plan["does_not_enable_production"] == true + + assert [ready, tapd_cnb, linear_cnb] = plan["provider_review_plans"] + + assert ready["template"] == "linear_github_ready" + assert ready["required_evidence_kinds"] == ["real_integration"] + assert ready["review_packet_blocked_until_completed_evidence"] == true + + assert_shadow_review_plan(tapd_cnb, "tapd_cnb_shadow", "shadow-run-tapd-cnb-42") + assert_shadow_review_plan(linear_cnb, "linear_cnb_shadow", "shadow-run-linear-cnb-42") + + blockers = plan["blocking_requirements"] + assert Enum.count(blockers, &(&1["code"] == "provider_preflight_report_required")) == 3 + assert Enum.count(blockers, &(&1["code"] == "completed_evidence_packet_required")) == 3 + assert Enum.any?(blockers, &(&1["code"] == "scrubbing_test_results_required")) + assert Enum.any?(blockers, &(&1["code"] == "owner_signoffs_required")) + end + + test "projects Phase 4 packet requirements without approving production" do + assert {:ok, plan} = Phase4ReviewPlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: "linear-shadow-99") + + requirements = plan["review_packet_requirements"] + + assert "specs/workflow/profiles/coding_pr_delivery/profile_spec.md" in requirements["changed_source_specs"] + assert "fill-implementation-pr-or-local-patch-ref" in requirements["implementation_refs"] + assert "fill-deterministic-test-matrix" in requirements["deterministic_test_matrix"] + + assert [%{"required_command_ids" => preflight_command_ids, "raw_output_included" => false}] = + requirements["provider_preflight_reports"] + + assert "linear-tracker-read-only-smoke" in preflight_command_ids + assert "cnb-repo-provider-read-only-smoke" in preflight_command_ids + + assert requirements["rollback_instructions"]["external_transition_readiness_gate"] == + Gates.transition_readiness_required_gate_key() + + assert requirements["rollback_instructions"]["legacy_review_handoff_required_mapping"] == true + assert "review_packet_render" in requirements["scrubbing_pipeline"]["required_boundaries"] + assert requirements["scrubbing_pipeline"]["failure_behavior"] == "fail_closed" + assert requirements["operator_inspection"]["contains_raw_evidence_payload"] == false + assert requirements["operator_inspection"]["workpad_markdown_authoritative"] == false + assert requirements["authority_boundaries"]["raw_provider_passthrough_authorized"] == false + assert "fill-owner-signoffs" in requirements["owner_signoffs"] + + assert [evidence_packet] = requirements["completed_evidence_packets"] + assert evidence_packet["template"] == "linear_cnb_shadow" + assert evidence_packet["live_evidence_status"] == "not_collected" + assert evidence_packet["required_evidence_kinds"] == ["shadow_integration"] + end + + test "cites the owning specs required by the hardening review packet" do + assert {:ok, plan} = Phase4ReviewPlan.build(:tiered_reference) + + source_specs = plan["review_packet_requirements"]["changed_source_specs"] + + assert source_specs == @owning_source_specs + assert "specs/workflow/typed_workflow_tools/conformance_spec.md" in source_specs + + assert "specs/workflow/profiles/coding_pr_delivery/review_handoff_readiness_policy/conformance_spec.md" in source_specs + + refute "specs/workflow/extensions/coding_pr_delivery/typed_workflow_tools/conformance_spec.md" in source_specs + + refute "specs/workflow/extensions/coding_pr_delivery/review_handoff_readiness_policy/conformance_spec.md" in source_specs + end + + test "accepts a prebuilt Phase 2 evidence plan" do + assert {:ok, phase2_plan} = + Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: "linear-shadow-from-plan") + + assert {:ok, review_plan} = Phase4ReviewPlan.build(phase2_plan) + + assert review_plan["phase2_plan_id"] == phase2_plan["plan_id"] + assert [provider_plan] = review_plan["provider_review_plans"] + assert provider_plan["shadow"]["run_id"] == "linear-shadow-from-plan" + end + + test "rejects malformed Phase 2 review inputs" do + assert {:error, %{code: "coding_pr_delivery_phase2_evidence_plan_invalid"}} = + Phase4ReviewPlan.build(:github_only) + + assert {:error, %{code: "coding_pr_delivery_phase4_review_plan_invalid", errors: [error]}} = + Phase4ReviewPlan.build(%{ + "schema" => "coding_pr_delivery.phase2_evidence_plan.v1", + "provider_plans" => [] + }) + + assert error.code == "missing_provider_plans" + end + + test "exposes Phase 4 review plans through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.phase4_review_plan.v1"}} = + ProductionProfile.phase4_review_plan(:tiered_reference) + end + + defp assert_shadow_review_plan(plan, template, shadow_run_id) do + assert plan["template"] == template + assert plan["required_evidence_kinds"] == ["shadow_integration"] + assert plan["preflight_report_required_before_evidence"] == true + assert plan["review_packet_blocked_until_completed_evidence"] == true + assert plan["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert plan["shadow"]["run_id"] == shadow_run_id + assert plan["shadow"]["authority"] == OneShotContract.shadow_authority() + assert plan["shadow"]["canonical_authority"] == false + assert OneShotContract.shadow_allowed_destinations() == plan["shadow"]["allowed_destinations"] + assert Enum.any?(plan["required_evidence_files"], &String.contains?(&1, template_id(template))) + end + + defp template_id("tapd_cnb_shadow"), do: "tapd-cnb-shadow" + defp template_id("linear_cnb_shadow"), do: "linear-cnb-shadow" +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_collector_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_collector_test.exs new file mode 100644 index 0000000..9661e4e --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_collector_test.exs @@ -0,0 +1,112 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReportCollectorTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReportCollector + + @fixed_now ~U[2026-06-26 05:45:00Z] + + test "records missing prerequisites without calling providers" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:tiered_reference) + + deps = %{ + env: fn -> %{} end, + utc_now: fn -> @fixed_now end, + tracker_smoke: fn _argv -> flunk("tracker smoke must not run when prerequisites are missing") end, + repo_provider_smoke: fn _argv -> flunk("repo-provider smoke must not run when prerequisites are missing") end + } + + assert {:ok, report} = PreflightReportCollector.collect(phase2_plan, [], deps) + + assert report["schema"] == "coding_pr_delivery.provider_preflight_report.v1" + assert report["status"] == "blocked" + assert report["planned_preflight_command_count"] == 6 + assert report["preflight_result_count"] == 6 + assert report["raw_output_included"] == false + assert report["does_not_enable_production"] == true + + assert Enum.all?(report["provider_preflight_results"], &(&1["status"] == "blocked")) + assert Enum.all?(report["provider_preflight_results"], &(&1["side_effect_mode"] == "read_only")) + assert Enum.all?(report["provider_preflight_results"], &(&1["write_performed"] == false)) + assert Enum.all?(report["provider_preflight_results"], &(&1["production_enabled"] == false)) + end + + test "runs only read-only smoke commands when prerequisites are present" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow) + + test_pid = self() + + deps = %{ + env: fn -> + %{ + env_key(["LINEAR", "API", "KEY"]) => "linear-config-present", + env_key(["LINEAR", "PROJECT", "SLUG"]) => "linear-project-present", + env_key(["CNB", "TOKEN"]) => "cnb-config-present" + } + end, + utc_now: fn -> @fixed_now end, + tracker_smoke: fn argv -> + send(test_pid, {:tracker_smoke, argv}) + {Jason.encode!(%{"ok" => true}), "", 0} + end, + repo_provider_smoke: fn argv -> + send(test_pid, {:repo_provider_smoke, argv}) + {Jason.encode!(%{"ok" => true}), "", 0} + end + } + + assert {:ok, report} = + PreflightReportCollector.collect( + phase2_plan, + [ + repo_slug: "acme/widgets", + change_proposal_number: "7", + evidence_prefix: "evidence/preflight-smoke" + ], + deps + ) + + assert report["status"] == "passed" + assert Enum.all?(report["provider_preflight_results"], &(&1["status"] == "passed")) + assert Enum.all?(report["provider_preflight_results"], &(List.first(&1["evidence_files"]) =~ "evidence/preflight-smoke/")) + + assert_received {:tracker_smoke, ["--template", "linear/github/opencode", "--json"]} + assert_received {:repo_provider_smoke, ["--provider", "cnb", "--repo", "acme/widgets", "--pr", "7", "--json"]} + end + + test "blocks failed smoke without storing raw provider output" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_github_ready) + + deps = %{ + env: fn -> + %{ + env_key(["LINEAR", "API", "KEY"]) => "linear-config-present", + env_key(["LINEAR", "PROJECT", "SLUG"]) => "linear-project-present" + } + end, + utc_now: fn -> @fixed_now end, + tracker_smoke: fn _argv -> {Jason.encode!(%{"ok" => true}), "", 0} end, + repo_provider_smoke: fn _argv -> {"secret raw stdout", "secret raw stderr", 1} end + } + + assert {:ok, report} = + PreflightReportCollector.collect( + phase2_plan, + [repo_slug: "joosure/Maestro", change_proposal_number: "18"], + deps + ) + + assert report["status"] == "blocked" + + github_result = + Enum.find(report["provider_preflight_results"], &(&1["command_id"] == "github-repo-provider-read-only-smoke")) + + assert github_result["status"] == "blocked" + assert github_result["blocker_code"] == "preflight_smoke_failed" + assert "gh auth status" in github_result["missing_prerequisites"] + refute inspect(report) =~ "secret raw stdout" + refute inspect(report) =~ "secret raw stderr" + end + + defp env_key(parts), do: Enum.join(parts, "_") +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_template_test.exs new file mode 100644 index 0000000..e956c81 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_template_test.exs @@ -0,0 +1,93 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReportTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.{ + Phase2EvidencePlan, + PreflightReport, + PreflightReportTemplate + } + + test "builds bounded preflight report templates from Phase 2 evidence plans" do + assert {:ok, phase2_plan} = + Phase2EvidencePlan.build(:tiered_reference, + tapd_cnb_shadow_run_id: "template-tapd-cnb", + linear_cnb_shadow_run_id: "template-linear-cnb" + ) + + phase2_plan = Map.put(phase2_plan, "raw_payload", "raw phase2 input") + + assert {:ok, template} = PreflightReportTemplate.build(phase2_plan) + + assert template["schema"] == "coding_pr_delivery.provider_preflight_report_template.v1" + assert template["completed_packet_schema"] == "coding_pr_delivery.provider_preflight_report.v1" + assert template["template_authority"] == "preflight_report_shape_only" + assert template["does_not_collect_live_evidence"] == true + assert template["does_not_call_providers"] == true + assert template["does_not_enable_production"] == true + + requirements = template["preflight_result_requirements"] + assert length(requirements) == 6 + assert Enum.all?(requirements, &(&1["raw_output_allowed"] == false)) + assert Enum.all?(requirements, &(&1["required_side_effect_mode"] == "read_only")) + assert Enum.all?(requirements, &(&1["write_performed"] == false)) + assert Enum.all?(requirements, &(&1["production_enabled"] == false)) + assert Enum.all?(requirements, &(&1["allowed_evidence_ref_prefixes"] == ["evidence/", "https://", "http://"])) + assert Enum.all?(requirements, &(List.first(&1["evidence_files"]) =~ "evidence/preflight/")) + + field_template = template["preflight_report_field_template"] + refute Map.has_key?(field_template["phase2_evidence_plan"], "raw_payload") + assert length(field_template["provider_preflight_results"]) == 6 + end + + test "generated field templates can be completed into valid preflight reports" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow) + assert {:ok, template} = PreflightReportTemplate.build(phase2_plan) + + report = + template["preflight_report_field_template"] + |> Map.put("provider_preflight_results", completed_results(template)) + + assert {:ok, normalized} = PreflightReport.validate(report) + + assert normalized["schema"] == "coding_pr_delivery.provider_preflight_report.v1" + assert normalized["status"] == "passed" + assert normalized["planned_preflight_command_count"] == 2 + assert normalized["raw_output_included"] == false + assert normalized["does_not_call_providers"] == true + assert normalized["does_not_enable_production"] == true + end + + test "rejects invalid Phase 2 evidence plans" do + assert {:error, %{code: "coding_pr_delivery_preflight_report_template_invalid", errors: errors}} = + PreflightReportTemplate.build(%{"schema" => "wrong"}) + + assert Enum.any?(errors, &(&1.code == "invalid_phase2_schema")) + assert Enum.any?(errors, &(&1.code == "missing_provider_plans")) + end + + test "exposes preflight report templates through the production profile facade" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:tapd_cnb_shadow) + + assert {:ok, %{"schema" => "coding_pr_delivery.provider_preflight_report_template.v1"}} = + ProductionProfile.phase2_preflight_report_template(phase2_plan) + end + + defp completed_results(template) do + Enum.map(template["preflight_result_requirements"], fn requirement -> + %{ + "template" => requirement["template"], + "command_id" => requirement["command_id"], + "target" => requirement["target"], + "provider_kind" => requirement["provider_kind"], + "status" => "passed", + "ran_at" => "2026-06-26T04:30:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => requirement["evidence_files"] + } + end) + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_test.exs new file mode 100644 index 0000000..e05638f --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/preflight_report_test.exs @@ -0,0 +1,182 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReportTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.PreflightReport + + test "validates a blocked read-only preflight report without raw output" do + report = blocked_report() + + assert {:ok, normalized} = PreflightReport.validate(report) + + assert normalized["schema"] == "coding_pr_delivery.provider_preflight_report.v1" + assert normalized["status"] == "blocked" + assert normalized["planned_preflight_command_count"] == 6 + assert normalized["preflight_result_count"] == 6 + assert normalized["raw_output_included"] == false + assert normalized["does_not_call_providers"] == true + assert normalized["does_not_enable_production"] == true + end + + test "requires every planned read-only preflight command exactly once" do + report = + blocked_report() + |> update_in(["provider_preflight_results"], &tl/1) + + assert {:error, %{errors: errors}} = PreflightReport.validate(report) + + assert Enum.any?(errors, &(&1.code == "missing_preflight_result")) + end + + test "requires bounded evidence refs for passed preflight results" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow) + + report = + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => passed_results_without_evidence(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + + assert {:error, %{errors: missing_errors}} = PreflightReport.validate(report) + assert Enum.any?(missing_errors, &(&1.code == "invalid_string_list" and List.last(&1.path) == "evidence_files")) + + invalid_report = + put_in(report, ["provider_preflight_results", Access.at(0), "evidence_files"], [ + "fill-preflight-evidence.md", + "file:///var/tmp/preflight.txt" + ]) + + assert {:error, %{errors: invalid_errors}} = PreflightReport.validate(invalid_report) + assert Enum.any?(invalid_errors, &(&1.code == "invalid_evidence_ref")) + assert Enum.any?(invalid_errors, &(&1.code == "placeholder_evidence_ref")) + end + + test "requires the preflight report schema" do + report = Map.delete(blocked_report(), "schema") + + assert {:error, %{errors: errors}} = PreflightReport.validate(report) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["schema"])) + end + + test "rejects unknown missing prerequisites and raw preflight output" do + report = + blocked_report() + |> update_in(["provider_preflight_results", Access.at(0)], fn result -> + result + |> Map.put("missing_prerequisites", ["UNDECLARED_PREREQUISITE"]) + |> Map.put("stdout", "raw provider output") + end) + + assert {:error, %{errors: errors}} = PreflightReport.validate(report) + + assert Enum.any?(errors, &(&1.code == "unknown_preflight_prerequisite")) + assert Enum.any?(errors, &(&1.code == "raw_preflight_output_forbidden" and &1.path == ["provider_preflight_results", 0, "stdout"])) + end + + test "rejects preflight reports that claim writes or production enablement" do + report = + blocked_report() + |> put_in(["provider_preflight_results", Access.at(0), "write_performed"], true) + |> put_in(["provider_preflight_results", Access.at(1), "production_enabled"], true) + + assert {:error, %{errors: errors}} = PreflightReport.validate(report) + + assert Enum.any?(errors, &(&1.code == "preflight_write_performed")) + assert Enum.any?(errors, &(&1.code == "preflight_enabled_production")) + end + + test "exposes preflight report validation through the production profile facade" do + assert {:ok, %{"status" => "blocked"}} = ProductionProfile.validate_preflight_report(blocked_report()) + end + + defp blocked_report do + assert {:ok, phase2_plan} = + Phase2EvidencePlan.build(:tiered_reference, + tapd_cnb_shadow_run_id: "preflight-tapd-cnb", + linear_cnb_shadow_run_id: "preflight-linear-cnb" + ) + + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&blocked_result(template, &1)) + end) + end + + defp blocked_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "blocked", + "blocker_code" => "missing_preflight_prerequisite", + "missing_prerequisites" => [first_prerequisite(command)], + "ran_at" => "2026-06-26T03:10:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp passed_results_without_evidence(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&passed_result_without_evidence(template, &1)) + end) + end + + defp passed_result_without_evidence(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-26T03:10:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false + } + end + + defp first_prerequisite(command) do + command + |> Map.take(["required_env", "required_auth", "required_targets"]) + |> Map.values() + |> Enum.flat_map(fn + values when is_list(values) -> values + _value -> [] + end) + |> List.first() + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/provider_matrix_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/provider_matrix_test.exs new file mode 100644 index 0000000..07fac66 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/provider_matrix_test.exs @@ -0,0 +1,196 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ProviderMatrixTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ProviderMatrix + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + + test "accepts TAPD + CNB shadow/no-write evidence entries with diagnostic-only metadata" do + claim = production_claim([shadow_entry("tapd-cnb-shadow", "tapd", "cnb")]) + + assert {:ok, %{"provider_matrix" => [entry]}} = ProviderMatrix.validate_claim(claim) + + assert entry["id"] == "tapd-cnb-shadow" + assert entry["tracker"]["kind"] == "tapd" + assert entry["repo_provider"]["kind"] == "cnb" + assert entry["side_effect_mode"] == OneShotContract.shadow_mode() + assert entry["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert entry["shadow"]["canonical_authority"] == false + end + + test "accepts Linear + CNB shadow/no-write evidence entries with diagnostic-only metadata" do + claim = production_claim([shadow_entry("linear-cnb-shadow", "linear", "cnb")]) + + assert {:ok, %{"provider_matrix" => [entry]}} = ProviderMatrix.validate_claim(claim) + + assert entry["id"] == "linear-cnb-shadow" + assert entry["tracker"]["kind"] == "linear" + assert entry["repo_provider"]["kind"] == "cnb" + assert entry["side_effect_mode"] == OneShotContract.shadow_mode() + assert entry["structured_plan_gates"][Gates.transition_readiness_required_gate_key()] == false + assert entry["deployment_topology"]["mode"] == "singleton" + assert entry["shadow"]["prefix"] == OneShotContract.shadow_prefix() + assert entry["shadow"]["authority"] == OneShotContract.shadow_authority() + assert entry["shadow"]["canonical_authority"] == false + end + + test "accepts ready-to-land entries only when production gates and ownership are explicit" do + claim = production_claim([ready_to_land_entry()]) + + assert {:ok, %{"provider_matrix" => [entry]}} = ProviderMatrix.validate_claim(claim) + + assert entry["side_effect_mode"] == "ready_to_land_write" + assert entry["structured_plan_gates"][Gates.transition_readiness_required_gate_key()] == true + assert entry["deployment_topology"]["mode"] == "distributed_lock" + assert entry["deployment_topology"]["ownership_proof"] == "redis-lock:workflow:coding-pr-delivery" + end + + test "rejects claims without provider matrix entries" do + assert {:error, %{code: "coding_pr_delivery_provider_matrix_invalid", errors: errors}} = + ProviderMatrix.validate_claim(%{"profile_instance_id" => "claim-1", "provider_matrix" => []}) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["provider_matrix"])) + end + + test "rejects entries without an explicit side-effect mode" do + entry = Map.delete(shadow_entry("missing-mode", "linear", "github"), "side_effect_mode") + + assert {:error, %{errors: errors}} = ProviderMatrix.validate_claim(production_claim([entry])) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["provider_matrix", 0, "side_effect_mode"])) + end + + test "rejects legacy gate names in provider matrix entries" do + entry = + shadow_entry("legacy-gate", "linear", "cnb") + |> put_in(["structured_plan_gates", "review_handoff_required"], true) + + assert {:error, %{errors: errors}} = ProviderMatrix.validate_claim(production_claim([entry])) + + assert Enum.any?(errors, &(&1.code == "unknown_gate_key" and &1.path == ["provider_matrix", 0, "structured_plan_gates", "review_handoff_required"])) + end + + test "rejects shadow entries that claim canonical transition authority" do + entry = + shadow_entry("shadow-authority", "tapd", "cnb") + |> put_in(["shadow", "canonical_authority"], true) + |> put_in(["structured_plan_gates", Gates.transition_readiness_required_gate_key()], true) + + assert {:error, %{errors: errors}} = ProviderMatrix.validate_claim(production_claim([entry])) + + assert Enum.any?(errors, &(&1.code == "invalid_shadow_metadata" and &1.path == ["provider_matrix", 0, "shadow", "canonical_authority"])) + + assert Enum.any?( + errors, + &(&1.code == "shadow_not_authoritative" and &1.path == ["provider_matrix", 0, "structured_plan_gates", Gates.transition_readiness_required_gate_key()]) + ) + end + + test "rejects ready-to-land entries without transition readiness enforcement" do + entry = + ready_to_land_entry() + |> put_in(["structured_plan_gates", Gates.transition_readiness_required_gate_key()], false) + + assert {:error, %{errors: errors}} = ProviderMatrix.validate_claim(production_claim([entry])) + + assert Enum.any?( + errors, + &(&1.code == "transition_readiness_required" and &1.path == ["provider_matrix", 0, "structured_plan_gates", Gates.transition_readiness_required_gate_key()]) + ) + end + + test "rejects unbounded provider-matrix evidence references" do + entry = + shadow_entry("unbounded-evidence", "linear", "cnb") + |> Map.put("evidence_files", [ + "fill-provider-matrix-evidence.md", + "file:///var/tmp/provider-matrix.txt" + ]) + + assert {:error, %{errors: errors}} = ProviderMatrix.validate_claim(production_claim([entry])) + + assert Enum.any?(errors, &(&1.code == "invalid_evidence_ref")) + assert Enum.any?(errors, &(&1.code == "placeholder_evidence_ref")) + end + + test "rejects singleton runtime-targeted entries without readiness proof and does not leak raw values" do + entry = + shadow_entry("secret-token=ghp_secret", "tapd", "cnb") + |> update_in(["deployment_topology"], &Map.delete(&1, "readiness_check")) + + assert {:error, %{errors: errors} = error} = ProviderMatrix.validate_claim(production_claim([entry])) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["provider_matrix", 0, "deployment_topology", "readiness_check"])) + refute inspect(error) =~ "ghp_secret" + end + + defp production_claim(entries) do + %{ + "profile_instance_id" => "coding_pr_delivery.production.claim", + "provider_matrix" => entries + } + end + + defp shadow_entry(id, tracker, repo_provider) do + base_entry(id, tracker, repo_provider) + |> Map.merge(%{ + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => gates(false), + "deployment_topology" => %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + }, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => "shadow-run-1", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + }) + end + + defp ready_to_land_entry do + base_entry("linear-github-ready", "linear", "github") + |> Map.merge(%{ + "side_effect_mode" => "ready_to_land_write", + "structured_plan_gates" => gates(true), + "deployment_topology" => %{ + "mode" => "distributed_lock", + "ownership_proof" => "redis-lock:workflow:coding-pr-delivery" + } + }) + end + + defp base_entry(id, tracker, repo_provider) do + %{ + "id" => id, + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => tracker}, + "repo_provider" => %{"kind" => repo_provider}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "candidate_discovery" => "runtime_targeted", + "typed_tool_inventory" => %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + }, + "evidence_files" => ["evidence/provider-matrix/#{id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp gates(transition_readiness_required) do + %{ + Gates.enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => transition_readiness_required, + Gates.provider_adapters_enabled_gate_key() => false + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_decision_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_decision_test.exs new file mode 100644 index 0000000..6a0a6dd --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_decision_test.exs @@ -0,0 +1,358 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewDecisionTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRunbook + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewDecision + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "projects accepted review packets into bounded ready decisions" do + assert {:ok, decision} = ReviewDecision.build(complete_review_packet()) + + assert decision["schema"] == "coding_pr_delivery.production_review_decision.v1" + assert decision["status"] == "ready_for_approval" + assert decision["review_packet_id"] == "review-packet-tapd-cnb-shadow" + assert decision["profile_instance_id"] == "coding-pr-delivery-production" + assert decision["does_not_enable_production"] == true + assert decision["raw_evidence_payload_included"] == false + assert decision["blockers"] == [] + + assert [ + %{ + "entry_id" => "tapd-cnb-shadow", + "tracker" => %{"kind" => "tapd"}, + "repo_provider" => %{"kind" => "cnb"}, + "side_effect_mode" => "shadow_no_write", + "non_claims" => non_claims + } + ] = decision["provider_entries"] + + assert "multi_node_ownership" in non_claims + assert decision["evidence_summary"]["scenario_evidence_count"] > 0 + assert decision["evidence_summary"]["non_claim_acknowledgement_count"] == 1 + end + + test "projects Linear + CNB shadow review packets into bounded ready decisions" do + assert {:ok, decision} = + ReviewDecision.build(complete_review_packet("linear-cnb-shadow", "linear", "shadow-run-linear-cnb-42")) + + assert decision["schema"] == "coding_pr_delivery.production_review_decision.v1" + assert decision["status"] == "ready_for_approval" + assert decision["review_packet_id"] == "review-packet-linear-cnb-shadow" + assert decision["profile_instance_id"] == "coding-pr-delivery-production" + assert decision["does_not_enable_production"] == true + assert decision["raw_evidence_payload_included"] == false + assert decision["blockers"] == [] + + assert [ + %{ + "entry_id" => "linear-cnb-shadow", + "tracker" => %{"kind" => "linear"}, + "repo_provider" => %{"kind" => "cnb"}, + "side_effect_mode" => "shadow_no_write", + "topology_mode" => "singleton", + "non_claims" => non_claims + } + ] = decision["provider_entries"] + + assert "multi_node_ownership" in non_claims + assert decision["evidence_summary"]["scenario_evidence_count"] > 0 + assert decision["evidence_summary"]["non_claim_acknowledgement_count"] == 1 + end + + test "projects invalid review packets into bounded blocked decisions" do + packet = + complete_review_packet() + |> put_in(["owner_signoffs", Access.at(0), "decision"], "pending") + + assert {:ok, decision} = ReviewDecision.build(packet) + + assert decision["status"] == "blocked" + assert decision["review_packet_id"] == "review-packet-tapd-cnb-shadow" + assert decision["profile_instance_id"] == "coding-pr-delivery-production" + assert decision["does_not_enable_production"] == true + assert decision["raw_evidence_payload_included"] == false + + assert Enum.any?( + decision["blockers"], + &(&1["code"] == "signoff_not_approved" and &1["path"] == ["owner_signoffs", 0, "decision"]) + ) + end + + test "projects non-object input into a blocked decision" do + assert {:ok, decision} = ReviewDecision.build(:invalid) + + assert decision["status"] == "blocked" + assert decision["review_packet_id"] == nil + assert decision["provider_entries"] == [] + assert [%{"code" => "invalid_type"}] = decision["blockers"] + end + + defp complete_review_packet( + entry_id \\ "tapd-cnb-shadow", + tracker_kind \\ "tapd", + shadow_run_id \\ "shadow-run-1" + ) do + %{ + "review_packet_id" => "review-packet-#{entry_id}", + "changed_source_specs" => [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md" + ], + "implementation_refs" => ["commit:6505ae0"], + "deterministic_test_matrix" => [%{"command" => "mise exec -- mix test", "status" => "passed"}], + "evidence_packet" => complete_evidence_packet(entry_id, tracker_kind, shadow_run_id), + "provider_preflight_reports" => [passed_preflight_report(entry_id, shadow_run_id)], + "rollback_instructions" => %{ + "owner" => "workflow-runtime", + "external_transition_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "legacy_review_handoff_required_mapping" => true, + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key() + ] + }, + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ], + "test_results" => [%{"name" => "scrubber evidence boundaries", "status" => "passed"}] + }, + "operator_inspection" => %{ + "schema" => "workflow.execution_plan.operator_inspection.v1", + "gate_values" => %{Gates.transition_readiness_required_gate_key() => false}, + "contains_raw_evidence_payload" => false, + "workpad_markdown_authoritative" => false + }, + "retention_policy" => %{ + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "tombstone_preserving" => true + }, + "authority_boundaries" => %{ + "prompt_wording_authoritative" => false, + "workpad_markdown_authoritative" => false, + "raw_provider_passthrough_authorized" => false, + "schema_support_alone_sufficient" => false + }, + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ] + } + end + + defp complete_evidence_packet(entry_id, tracker_kind, shadow_run_id) do + claim = production_claim(entry_id, tracker_kind, shadow_run_id) + assert {:ok, runbook} = EvidenceRunbook.build(claim) + + %{ + "production_claim" => claim, + "scenario_evidence" => scenario_evidence(runbook), + "non_claim_acknowledgements" => non_claim_acknowledgements(runbook) + } + end + + defp passed_preflight_report(entry_id, shadow_run_id) do + assert {:ok, phase2_plan} = build_phase2_plan(phase2_template(entry_id), shadow_run_id) + + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp build_phase2_plan(:tapd_cnb_shadow, shadow_run_id) do + Phase2EvidencePlan.build(:tapd_cnb_shadow, tapd_cnb_shadow_run_id: shadow_run_id) + end + + defp build_phase2_plan(:linear_cnb_shadow, shadow_run_id) do + Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: shadow_run_id) + end + + defp phase2_template("tapd-cnb-shadow"), do: :tapd_cnb_shadow + defp phase2_template("linear-cnb-shadow"), do: :linear_cnb_shadow + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(template, &1)) + end) + end + + defp preflight_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-25T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp scenario_evidence(runbook) do + Enum.flat_map(runbook["entries"], fn entry -> + Enum.map(entry["scenario_checklist"], fn scenario -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "scenario_id" => scenario["id"], + "status" => "passed", + "evidence_kind" => "shadow_integration", + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-25T00:00:00Z", + "evidence_files" => ["evidence/live/#{entry["entry_id"]}/#{scenario["id"]}.md"], + "production_write_performed" => false, + "canonical_surface_mutated" => false, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => get_in(entry, ["shadow_requirements", "run_id"]) || "shadow-run-1", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + } + end) + end) + end + + defp non_claim_acknowledgements(runbook) do + Enum.map(runbook["entries"], fn entry -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "non_claims" => entry["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-25T00:00:00Z" + } + end) + end + + defp production_claim(entry_id, tracker_kind, shadow_run_id) do + %{ + "profile_instance_id" => "coding-pr-delivery-production", + "provider_matrix" => [shadow_entry(entry_id, tracker_kind, shadow_run_id)], + "production_governance" => [governance_packet(entry_id)] + } + end + + defp shadow_entry(entry_id, tracker_kind, shadow_run_id) do + %{ + "id" => entry_id, + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => tracker_kind}, + "repo_provider" => %{"kind" => "cnb"}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "candidate_discovery" => "runtime_targeted", + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => %{ + Gates.enabled_gate_key() => true, + Gates.provider_adapters_enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => false + }, + "typed_tool_inventory" => %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + }, + "deployment_topology" => %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + }, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => shadow_run_id, + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + }, + "evidence_files" => ["evidence/provider-matrix/#{entry_id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp governance_packet(provider_matrix_entry_id) do + %{ + "provider_matrix_entry_id" => provider_matrix_entry_id, + "profile_instance_id" => "coding-pr-delivery-production", + "durability_profile" => %{ + "storage_backend" => "sqlite", + "restart_behavior" => "survives process and worker restarts", + "backup_restore" => "daily backup with documented restore drill", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "bounded_payload_summaries_only", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "cleanup_schedule" => "daily", + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Gates.enabled_gate_key(), + "disable_rendering_gate" => Gates.render_workpad_gate_key(), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Gates.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => ["evidence/structured-plan/governance/#{provider_matrix_entry_id}.md"] + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_template_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_template_test.exs new file mode 100644 index 0000000..9d5d979 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_template_test.exs @@ -0,0 +1,193 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacketTemplateTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidencePacketTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2ClaimTemplate + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacket + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacketTemplate + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "builds a Phase 4 review-packet fill template from completed evidence" do + assert {:ok, template} = ReviewPacketTemplate.build(completed_evidence_packet()) + + assert template["schema"] == "coding_pr_delivery.production_review_packet_template.v1" + assert template["completed_packet_schema"] == "coding_pr_delivery.production_review_packet.v1" + assert template["template_authority"] == "review_packet_shape_only" + assert template["does_not_read_evidence_files"] == true + + field_template = template["review_packet_field_template"] + + assert field_template["rollback_instructions"]["external_transition_readiness_gate"] == + Gates.transition_readiness_required_gate_key() + + assert field_template["rollback_instructions"]["legacy_review_handoff_required_mapping"] == true + assert "review_packet_render" in field_template["scrubbing_pipeline"]["enforced_boundaries"] + assert field_template["scrubbing_pipeline"]["pattern_catalog_rules"] == Governance.required_scrubbing_pattern_rules() + assert field_template["operator_inspection"]["contains_raw_evidence_payload"] == false + assert field_template["provider_preflight_reports"] == [] + assert field_template["authority_boundaries"]["raw_provider_passthrough_authorized"] == false + assert "provider_preflight_reports" in template["fields_to_complete"] + assert "owner_signoffs" in template["fields_to_complete"] + end + + test "field template can be completed into a valid review packet" do + assert {:ok, template} = ReviewPacketTemplate.build(completed_evidence_packet()) + + review_packet = + template["review_packet_field_template"] + |> Map.merge(%{ + "review_packet_id" => "review-packet-tapd-cnb-shadow", + "changed_source_specs" => [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md" + ], + "implementation_refs" => [ + "commit:27fad4c", + "local-patch:coding-pr-delivery-review-packet-template" + ], + "deterministic_test_matrix" => [ + %{"command" => "mise exec -- mix test test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile", "status" => "passed"} + ], + "provider_preflight_reports" => [passed_preflight_report(:tapd_cnb_shadow, "shadow-run-cnb-42")], + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ] + }) + |> put_in(["scrubbing_pipeline", "test_results"], [ + %{"name" => "scrubber evidence boundaries", "status" => "passed"} + ]) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_review_packet.v1"}} = + ReviewPacket.validate(review_packet) + end + + test "builds a Linear + CNB shadow review template with bounded operator inspection" do + assert {:ok, template} = + ReviewPacketTemplate.build(completed_evidence_packet(:linear_cnb_shadow, "shadow-run-linear-cnb-42")) + + field_template = template["review_packet_field_template"] + inspection = field_template["operator_inspection"] + + assert template["does_not_read_evidence_files"] == true + assert inspection["contains_raw_evidence_payload"] == false + assert inspection["workpad_markdown_authoritative"] == false + assert inspection["gate_values"][Gates.transition_readiness_required_gate_key()] == false + + assert [ + %{ + "provider_matrix_entry_id" => "linear-cnb-shadow", + "gate_values" => linear_cnb_gates + } + ] = inspection["candidate_gate_values_by_entry"] + + assert linear_cnb_gates[Gates.transition_readiness_required_gate_key()] == false + assert field_template["scrubbing_pipeline"]["source_provider_matrix_entry_ids"] == ["linear-cnb-shadow"] + assert field_template["retention_policy"]["source_provider_matrix_entry_ids"] == ["linear-cnb-shadow"] + assert field_template["authority_boundaries"]["raw_provider_passthrough_authorized"] == false + end + + test "rejects invalid evidence packets before building a review template" do + assert {:error, %{code: "coding_pr_delivery_evidence_packet_invalid", errors: errors}} = + ReviewPacketTemplate.build(%{}) + + assert Enum.any?(errors, &(&1.code == "invalid_type" and &1.path == ["production_claim"])) + end + + test "exposes review packet templates through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_review_packet_template.v1"}} = + ProductionProfile.phase4_review_packet_template(completed_evidence_packet()) + end + + defp completed_evidence_packet(template \\ :tapd_cnb_shadow, shadow_run_id \\ "shadow-run-cnb-42") do + assert {:ok, claim} = Phase2ClaimTemplate.build(template, shadow_run_id: shadow_run_id) + assert {:ok, template} = EvidencePacketTemplate.build(claim) + + %{ + "production_claim" => claim, + "scenario_evidence" => Enum.map(template["scenario_evidence_requirements"], &complete_scenario_evidence/1), + "non_claim_acknowledgements" => Enum.map(template["non_claim_acknowledgement_requirements"], &complete_non_claim_acknowledgement/1) + } + end + + defp passed_preflight_report(:tapd_cnb_shadow, shadow_run_id) do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:tapd_cnb_shadow, tapd_cnb_shadow_run_id: shadow_run_id) + preflight_report(phase2_plan) + end + + defp preflight_report(phase2_plan) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp preflight_results(phase2_plan) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(template, &1)) + end) + end + + defp preflight_result(template, command) do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-25T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp complete_scenario_evidence(requirement) do + evidence = + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "scenario_id" => requirement["scenario_id"], + "status" => requirement["required_status"], + "evidence_kind" => requirement["required_evidence_kind"], + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-25T00:00:00Z", + "evidence_files" => requirement["evidence_files"] + } + + if is_map(requirement["shadow"]) do + evidence + |> Map.merge(requirement["no_write_flags"]) + |> Map.put("shadow", requirement["shadow"]) + else + evidence + end + end + + defp complete_non_claim_acknowledgement(requirement) do + %{ + "provider_matrix_entry_id" => requirement["provider_matrix_entry_id"], + "non_claims" => requirement["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-25T00:00:00Z" + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_test.exs new file mode 100644 index 0000000..e89662e --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/review_packet_test.exs @@ -0,0 +1,461 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacketTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.EvidenceRunbook + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.ReviewPacket + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "accepts complete Phase 4 production review packets" do + assert {:ok, packet} = ReviewPacket.validate(complete_review_packet()) + + assert packet["schema"] == "coding_pr_delivery.production_review_packet.v1" + assert packet["review_packet_id"] == "review-packet-tapd-cnb-shadow" + assert packet["profile_instance_id"] == "coding-pr-delivery-production" + assert packet["evidence_packet"]["schema"] == "coding_pr_delivery.production_evidence_packet.v1" + assert [%{"schema" => "coding_pr_delivery.provider_preflight_report.v1", "status" => "passed"}] = packet["provider_preflight_reports"] + end + + test "accepts complete Phase 4 production review packets for Linear + CNB shadow evidence" do + assert {:ok, packet} = + ReviewPacket.validate(complete_review_packet("linear-cnb-shadow", "linear", "shadow-run-linear-cnb-42")) + + assert packet["schema"] == "coding_pr_delivery.production_review_packet.v1" + assert packet["review_packet_id"] == "review-packet-linear-cnb-shadow" + assert packet["profile_instance_id"] == "coding-pr-delivery-production" + assert packet["operator_inspection"]["contains_raw_evidence_payload"] == false + assert packet["operator_inspection"]["gate_values"][Gates.transition_readiness_required_gate_key()] == false + assert packet["authority_boundaries"]["raw_provider_passthrough_authorized"] == false + + assert Enum.all?(packet["evidence_packet"]["scenario_evidence"], fn evidence -> + evidence["provider_matrix_entry_id"] == "linear-cnb-shadow" and + evidence["evidence_kind"] == "shadow_integration" and + evidence["production_write_performed"] == false and + evidence["canonical_surface_mutated"] == false and + evidence["shadow"]["run_id"] == "shadow-run-linear-cnb-42" + end) + end + + test "rejects packets whose nested evidence packet is invalid" do + packet = Map.put(complete_review_packet(), "evidence_packet", %{}) + + assert {:error, %{code: "coding_pr_delivery_review_packet_invalid", errors: errors}} = + ReviewPacket.validate(packet) + + assert Enum.any?( + errors, + &(&1.code == "invalid_type" and &1.path == ["evidence_packet", "production_claim"]) + ) + end + + test "requires rollback instructions to use the external transition gate mapping" do + packet = + complete_review_packet() + |> put_in(["rollback_instructions", "external_transition_readiness_gate"], "review_handoff_required") + |> put_in(["rollback_instructions", "legacy_review_handoff_required_mapping"], false) + |> put_in(["rollback_instructions", "disable_gates"], ["review_handoff_required"]) + + assert {:error, %{errors: errors}} = ReviewPacket.validate(packet) + + assert Enum.any?(errors, &(&1.code == "invalid_transition_readiness_gate")) + assert Enum.any?(errors, &(&1.code == "missing_legacy_gate_mapping")) + assert Enum.any?(errors, &(&1.code == "missing_rollback_gate")) + end + + test "rejects non-canonical authority boundaries and legacy operator gates" do + packet = + complete_review_packet() + |> put_in(["authority_boundaries", "workpad_markdown_authoritative"], true) + |> put_in(["operator_inspection", "gate_values", "review_handoff_required"], true) + |> put_in(["operator_inspection", "contains_raw_evidence_payload"], true) + + assert {:error, %{errors: errors}} = ReviewPacket.validate(packet) + + assert Enum.any?(errors, &(&1.code == "invalid_authority_boundary" and &1.flag == "workpad_markdown_authoritative")) + assert Enum.any?(errors, &(&1.code == "legacy_gate_name")) + assert Enum.any?(errors, &(&1.code == "raw_evidence_payload_present")) + end + + test "requires passing tests and approved owner sign-offs" do + packet = + complete_review_packet() + |> put_in(["deterministic_test_matrix", Access.at(0), "status"], "failed") + |> put_in(["owner_signoffs", Access.at(0), "decision"], "pending") + + assert {:error, %{errors: errors}} = ReviewPacket.validate(packet) + + assert Enum.any?(errors, &(&1.code == "test_not_passed" and &1.path == ["deterministic_test_matrix", 0, "status"])) + assert Enum.any?(errors, &(&1.code == "signoff_not_approved")) + end + + test "requires scrubbing pattern catalog rules in review packets" do + packet = + complete_review_packet() + |> update_in(["scrubbing_pipeline", "pattern_catalog_rules"], &List.delete(&1, "private_keys")) + + assert {:error, %{errors: errors}} = ReviewPacket.validate(packet) + + assert Enum.any?( + errors, + &(&1.code == "missing_scrubbing_pattern_rule" and + &1.path == ["scrubbing_pipeline", "pattern_catalog_rules", "private_keys"]) + ) + end + + test "requires passed provider preflight reports before final review" do + missing = Map.delete(complete_review_packet(), "provider_preflight_reports") + + assert {:error, %{errors: missing_errors}} = ReviewPacket.validate(missing) + + assert Enum.any?( + missing_errors, + &(&1.code == "required_field_missing" and &1.path == ["provider_preflight_reports"]) + ) + + blocked = + complete_review_packet() + |> Map.put("provider_preflight_reports", [blocked_preflight_report("tapd-cnb-shadow", "shadow-run-1")]) + + assert {:error, %{errors: blocked_errors}} = ReviewPacket.validate(blocked) + + assert Enum.any?(blocked_errors, &(&1.code == "preflight_report_not_passed")) + end + + test "requires provider preflight reports to cover evidence packet entries" do + packet = + complete_review_packet() + |> Map.put("provider_preflight_reports", [passed_preflight_report(:linear_cnb_shadow, "shadow-run-linear-cnb-42")]) + + assert {:error, %{errors: errors}} = ReviewPacket.validate(packet) + + assert Enum.any?( + errors, + &(&1.code == "missing_provider_preflight_report" and &1.provider_matrix_entry_id == "tapd-cnb-shadow") + ) + end + + defp complete_review_packet( + entry_id \\ "tapd-cnb-shadow", + tracker_kind \\ "tapd", + shadow_run_id \\ "shadow-run-1" + ) do + %{ + "review_packet_id" => "review-packet-#{entry_id}", + "changed_source_specs" => [ + "specs/workflow/profiles/coding_pr_delivery/profile_spec.md", + "specs/workflow/extensions/coding_pr_delivery/reconciliation/production_profile_spec.md" + ], + "implementation_refs" => [ + "commit:6505ae0", + "local-patch:coding-pr-delivery-production-profile" + ], + "deterministic_test_matrix" => [ + %{ + "command" => "mise exec -- mix test", + "status" => "passed" + } + ], + "evidence_packet" => complete_evidence_packet(entry_id, tracker_kind, shadow_run_id), + "provider_preflight_reports" => [passed_preflight_report(entry_id, shadow_run_id)], + "rollback_instructions" => %{ + "owner" => "workflow-runtime", + "external_transition_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "legacy_review_handoff_required_mapping" => true, + "disable_gates" => [ + Gates.transition_readiness_required_gate_key(), + Gates.enabled_gate_key(), + Gates.render_workpad_gate_key(), + Gates.provider_adapters_enabled_gate_key() + ] + }, + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ], + "test_results" => [ + %{"name" => "scrubber evidence boundaries", "status" => "passed"} + ] + }, + "operator_inspection" => %{ + "schema" => "workflow.execution_plan.operator_inspection.v1", + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + }, + "candidate_gate_values_by_entry" => [ + %{ + "provider_matrix_entry_id" => entry_id, + "gate_values" => %{ + Gates.transition_readiness_required_gate_key() => false, + Gates.enabled_gate_key() => true + } + } + ], + "contains_raw_evidence_payload" => false, + "workpad_markdown_authoritative" => false + }, + "retention_policy" => %{ + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "tombstone_preserving" => true + }, + "authority_boundaries" => %{ + "prompt_wording_authoritative" => false, + "workpad_markdown_authoritative" => false, + "raw_provider_passthrough_authorized" => false, + "schema_support_alone_sufficient" => false + }, + "owner_signoffs" => [ + %{ + "role" => "workflow-runtime-owner", + "owner" => "workflow-runtime", + "decision" => "approved", + "approved_at" => "2026-06-25T00:00:00Z" + } + ] + } + end + + defp complete_evidence_packet(entry_id, tracker_kind, shadow_run_id) do + claim = production_claim(entry_id, tracker_kind, shadow_run_id) + assert {:ok, runbook} = EvidenceRunbook.build(claim) + + %{ + "production_claim" => claim, + "scenario_evidence" => scenario_evidence(runbook), + "non_claim_acknowledgements" => non_claim_acknowledgements(runbook) + } + end + + defp passed_preflight_report(entry_id, shadow_run_id) do + preflight_report(entry_id, shadow_run_id, "passed") + end + + defp blocked_preflight_report(entry_id, shadow_run_id) do + preflight_report(entry_id, shadow_run_id, "blocked") + end + + defp preflight_report(entry_id, shadow_run_id, status) do + template = phase2_template(entry_id) + assert {:ok, phase2_plan} = build_phase2_plan(template, shadow_run_id) + + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan, status), + "explicit_non_claims" => [ + "preflight_report_does_not_collect_live_provider_evidence", + "preflight_report_does_not_enable_production" + ] + } + end + + defp build_phase2_plan(:tapd_cnb_shadow, shadow_run_id) do + Phase2EvidencePlan.build(:tapd_cnb_shadow, tapd_cnb_shadow_run_id: shadow_run_id) + end + + defp build_phase2_plan(:linear_cnb_shadow, shadow_run_id) do + Phase2EvidencePlan.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: shadow_run_id) + end + + defp phase2_template("tapd-cnb-shadow"), do: :tapd_cnb_shadow + defp phase2_template("linear-cnb-shadow"), do: :linear_cnb_shadow + defp phase2_template(:tapd_cnb_shadow), do: :tapd_cnb_shadow + defp phase2_template(:linear_cnb_shadow), do: :linear_cnb_shadow + + defp preflight_results(phase2_plan, status) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + template = Map.fetch!(provider_plan, "template") + + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(template, &1, status)) + end) + end + + defp preflight_result(template, command, "passed") do + %{ + "template" => template, + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "status" => "passed", + "ran_at" => "2026-06-25T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false, + "evidence_files" => ["evidence/preflight/#{template}/#{Map.fetch!(command, "id")}.md"] + } + end + + defp preflight_result(template, command, "blocked") do + template + |> preflight_result(command, "passed") + |> Map.merge(%{ + "status" => "blocked", + "blocker_code" => "missing_preflight_prerequisite", + "missing_prerequisites" => [first_prerequisite(command)] + }) + end + + defp first_prerequisite(command) do + command + |> Map.take(["required_env", "required_auth", "required_targets"]) + |> Map.values() + |> Enum.flat_map(fn + values when is_list(values) -> values + _value -> [] + end) + |> List.first() + end + + defp scenario_evidence(runbook) do + Enum.flat_map(runbook["entries"], fn entry -> + Enum.map(entry["scenario_checklist"], fn scenario -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "scenario_id" => scenario["id"], + "status" => "passed", + "evidence_kind" => "shadow_integration", + "collector" => "provider-integration-owner", + "collected_at" => "2026-06-25T00:00:00Z", + "evidence_files" => ["evidence/live/#{entry["entry_id"]}/#{scenario["id"]}.md"], + "production_write_performed" => false, + "canonical_surface_mutated" => false, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => get_in(entry, ["shadow_requirements", "run_id"]) || "shadow-run-1", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + } + } + end) + end) + end + + defp non_claim_acknowledgements(runbook) do + Enum.map(runbook["entries"], fn entry -> + %{ + "provider_matrix_entry_id" => entry["entry_id"], + "non_claims" => entry["non_claims"], + "owner" => "provider-integration-owner", + "acknowledged_at" => "2026-06-25T00:00:00Z" + } + end) + end + + defp production_claim(entry_id, tracker_kind, shadow_run_id) do + %{ + "profile_instance_id" => "coding-pr-delivery-production", + "provider_matrix" => [shadow_entry(entry_id, tracker_kind, shadow_run_id)], + "production_governance" => [governance_packet(entry_id)] + } + end + + defp shadow_entry(entry_id, tracker_kind, shadow_run_id) do + %{ + "id" => entry_id, + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => tracker_kind}, + "repo_provider" => %{"kind" => "cnb"}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "candidate_discovery" => "runtime_targeted", + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => %{ + Gates.enabled_gate_key() => true, + Gates.provider_adapters_enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => false + }, + "typed_tool_inventory" => %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + }, + "deployment_topology" => %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + }, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => shadow_run_id, + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + }, + "evidence_files" => ["evidence/provider-matrix/#{entry_id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp governance_packet(provider_matrix_entry_id) do + %{ + "provider_matrix_entry_id" => provider_matrix_entry_id, + "profile_instance_id" => "coding-pr-delivery-production", + "durability_profile" => %{ + "storage_backend" => "sqlite", + "restart_behavior" => "survives process and worker restarts", + "backup_restore" => "daily backup with documented restore drill", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "bounded_payload_summaries_only", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "cleanup_schedule" => "daily", + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Gates.enabled_gate_key(), + "disable_rendering_gate" => Gates.render_workpad_gate_key(), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Gates.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => ["evidence/structured-plan/governance/#{provider_matrix_entry_id}.md"] + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/status_report_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/status_report_test.exs new file mode 100644 index 0000000..728fda1 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/status_report_test.exs @@ -0,0 +1,142 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.StatusReportTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.Phase2EvidencePlan + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.StatusReport + + test "summarizes missing preflight reports as bounded Phase 4 blockers" do + assert {:ok, report} = StatusReport.build(:linear_cnb_shadow, linear_cnb_shadow_run_id: "status-shadow-1") + + assert report["schema"] == "coding_pr_delivery.production_profile_status.v1" + assert report["status"] == "blocked" + assert report["phase4_ready"] == false + assert report["preflight"]["provided"] == false + assert report["does_not_call_providers"] == true + assert report["does_not_enable_production"] == true + assert report["raw_input_included"] == false + assert [%{"template" => "linear_cnb_shadow"}] = report["provider_entries"] + + assert Enum.any?(report["blockers"], &(&1["code"] == "provider_preflight_report_required")) + assert Enum.any?(report["blockers"], &(&1["code"] == "completed_evidence_packet_required")) + end + + test "replaces preflight-required blockers with concrete blocked preflight prerequisites" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow) + blocked_preflight = preflight_report(phase2_plan, "blocked") + + assert {:ok, report} = StatusReport.build(phase2_plan, preflight_report: blocked_preflight) + + assert report["status"] == "blocked" + assert report["preflight"]["provided"] == true + assert report["preflight"]["valid"] == true + assert report["preflight"]["status"] == "blocked" + assert report["preflight"]["blocked_count"] == 2 + assert Enum.all?(report["preflight"]["blocked_results"], &(&1["missing_prerequisites"] != [])) + + refute Enum.any?(report["blockers"], &(&1["code"] == "provider_preflight_report_required")) + assert Enum.count(report["blockers"], &(&1["code"] == "provider_preflight_blocked")) == 2 + assert Enum.any?(report["blockers"], &(&1["provider_kind"] == "linear")) + assert Enum.any?(report["blockers"], &(&1["provider_kind"] == "cnb")) + end + + test "removes preflight blockers when a passed preflight report covers the plan" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow) + passed_preflight = preflight_report(phase2_plan, "passed") + + assert {:ok, report} = StatusReport.build(phase2_plan, preflight_report: passed_preflight) + + assert report["status"] == "blocked" + assert report["preflight"]["valid"] == true + assert report["preflight"]["status"] == "passed" + assert report["preflight"]["passed_count"] == 2 + assert report["preflight"]["blocked_count"] == 0 + + refute Enum.any?(report["blockers"], &(&1["code"] == "provider_preflight_report_required")) + refute Enum.any?(report["blockers"], &(&1["code"] == "provider_preflight_blocked")) + assert Enum.any?(report["blockers"], &(&1["code"] == "completed_evidence_packet_required")) + end + + test "reports invalid preflight metadata without echoing normalized artifacts" do + assert {:ok, phase2_plan} = Phase2EvidencePlan.build(:linear_cnb_shadow) + invalid_preflight = Map.put(preflight_report(phase2_plan, "passed"), "provider_preflight_results", []) + + assert {:ok, report} = StatusReport.build(phase2_plan, preflight_report: invalid_preflight) + + assert report["status"] == "blocked" + assert report["preflight"]["provided"] == true + assert report["preflight"]["valid"] == false + assert report["normalized_artifacts_included"] == false + + assert [%{"code" => "provider_preflight_report_invalid", "error_count" => error_count}] = + Enum.filter(report["blockers"], &(&1["code"] == "provider_preflight_report_invalid")) + + assert error_count > 0 + refute Enum.any?(report["blockers"], &(&1["code"] == "provider_preflight_report_required")) + end + + test "exposes status reports through the production profile facade" do + assert {:ok, %{"schema" => "coding_pr_delivery.production_profile_status.v1"}} = + ProductionProfile.production_status(:linear_cnb_shadow) + end + + defp preflight_report(phase2_plan, status) do + %{ + "schema" => "coding_pr_delivery.provider_preflight_report.v1", + "phase2_evidence_plan" => phase2_plan, + "provider_preflight_results" => preflight_results(phase2_plan, status), + "explicit_non_claims" => ["preflight_report_does_not_enable_production"] + } + end + + defp preflight_results(phase2_plan, status) do + phase2_plan + |> Map.fetch!("provider_plans") + |> Enum.flat_map(fn provider_plan -> + provider_plan + |> get_in(["read_only_preflight", "commands"]) + |> Enum.map(&preflight_result(provider_plan, &1, status)) + end) + end + + defp preflight_result(provider_plan, command, "passed") do + base_result(provider_plan, command) + |> Map.merge(%{ + "status" => "passed", + "evidence_files" => ["evidence/preflight/#{Map.fetch!(command, "id")}.md"], + "missing_prerequisites" => [] + }) + end + + defp preflight_result(provider_plan, command, "blocked") do + base_result(provider_plan, command) + |> Map.merge(%{ + "status" => "blocked", + "blocker_code" => "missing_prerequisites", + "missing_prerequisites" => [first_prerequisite(command)] + }) + end + + defp base_result(provider_plan, command) do + %{ + "template" => Map.fetch!(provider_plan, "template"), + "command_id" => Map.fetch!(command, "id"), + "target" => Map.fetch!(command, "target"), + "provider_kind" => Map.fetch!(command, "provider_kind"), + "ran_at" => "2026-06-26T00:00:00Z", + "side_effect_mode" => "read_only", + "write_performed" => false, + "production_enabled" => false + } + end + + defp first_prerequisite(command) do + command + |> Map.take(["required_env", "required_auth", "required_targets", "required_runtime"]) + |> Map.values() + |> Enum.find_value(fn + [value | _rest] -> value + _empty -> nil + end) + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception_test.exs new file mode 100644 index 0000000..b978320 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception_test.exs @@ -0,0 +1,181 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.TypedToolExceptionTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile.TypedToolException + + test "accepts a tightly scoped non-typed tool production exception record" do + assert {:ok, record} = TypedToolException.validate_record(valid_record()) + + assert record["exception_id"] == "typed-tool-exception-linear-github-1" + assert record["workflow_profile"] == %{"kind" => "coding_pr_delivery", "version" => 1} + assert record["route_set"] == ["developing", "review"] + assert record["operation_set"] == ["repo_provider.review.read"] + assert record["fallback_authority"]["accepted_by_profile_owners"] == true + assert record["input_schema_allowlist"]["rejects_unknown_fields"] == true + assert record["rollback"]["restores_typed_tool_requirement"] == true + end + + test "rejects broad raw provider passthrough and wildcard operation scopes" do + record = + valid_record() + |> Map.put("raw_provider_passthrough", true) + |> Map.put("provider_native_prompt_snippets", true) + |> Map.put("operation_set", ["*", "provider_native_cli", "repo_provider.review.read"]) + + assert {:error, %{code: "coding_pr_delivery_typed_tool_exception_invalid", errors: errors}} = + TypedToolException.validate_record(record) + + assert Enum.any?(errors, &(&1.code == "raw_provider_passthrough_forbidden" and &1.path == ["raw_provider_passthrough"])) + assert Enum.any?(errors, &(&1.code == "provider_native_prompt_snippets_forbidden" and &1.path == ["provider_native_prompt_snippets"])) + assert Enum.any?(errors, &(&1.code == "overbroad_operation_scope" and &1.path == ["operation_set", "*"])) + assert Enum.any?(errors, &(&1.code == "overbroad_operation_scope" and &1.path == ["operation_set", "provider_native_cli"])) + end + + test "rejects Linear + CNB shadow exceptions that attempt raw provider authority" do + record = + valid_record() + |> Map.put("exception_id", "typed-tool-exception-linear-cnb-shadow-raw") + |> put_in(["repo_provider", "kind"], "cnb") + |> Map.put("raw_provider_passthrough", true) + |> Map.put("provider_native_prompt_snippets", true) + |> Map.put("operation_set", ["provider_native_api", "repo_provider.change_proposal_snapshot"]) + |> Map.put("real_integration_evidence", [ + "evidence/typed-tool-exceptions/linear-cnb-shadow-raw.md" + ]) + |> put_in(["rollback", "instructions"], "Remove linear-cnb-shadow exception and require typed CNB repo-provider tools.") + + assert {:error, %{code: "coding_pr_delivery_typed_tool_exception_invalid", errors: errors}} = + TypedToolException.validate_record(record) + + assert Enum.any?(errors, &(&1.code == "raw_provider_passthrough_forbidden" and &1.path == ["raw_provider_passthrough"])) + assert Enum.any?(errors, &(&1.code == "provider_native_prompt_snippets_forbidden" and &1.path == ["provider_native_prompt_snippets"])) + assert Enum.any?(errors, &(&1.code == "overbroad_operation_scope" and &1.path == ["operation_set", "provider_native_api"])) + end + + test "rejects missing evidence and missing expiry or re-review trigger" do + record = + valid_record() + |> Map.delete("expires_at") + |> Map.put("deterministic_tests", []) + |> Map.put("real_integration_evidence", []) + + assert {:error, %{errors: errors}} = TypedToolException.validate_record(record) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["deterministic_tests"])) + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["real_integration_evidence"])) + assert Enum.any?(errors, &(&1.code == "expiry_or_re_review_required" and &1.path == [])) + end + + test "rejects unbounded real integration evidence references" do + record = + valid_record() + |> Map.put("real_integration_evidence", [ + "fill-real-integration-evidence.md", + "file:///var/tmp/typed-tool-exception.txt" + ]) + + assert {:error, %{errors: errors}} = TypedToolException.validate_record(record) + + assert Enum.any?(errors, &(&1.code == "invalid_evidence_ref")) + assert Enum.any?(errors, &(&1.code == "placeholder_evidence_ref")) + end + + test "accepts a re-review trigger instead of a fixed expiry" do + record = + valid_record() + |> Map.delete("expires_at") + |> Map.put("re_review_trigger", %{ + "condition" => "typed repo-provider review tool ships for this provider", + "owner" => "workflow-tools" + }) + + assert {:ok, normalized} = TypedToolException.validate_record(record) + assert normalized["re_review_trigger"]["owner"] == "workflow-tools" + end + + test "rejects invalid profile and route scope" do + record = + valid_record() + |> put_in(["workflow_profile", "kind"], "other_profile") + |> Map.put("route_set", ["developing", "terminal"]) + + assert {:error, %{errors: errors}} = TypedToolException.validate_record(record) + + assert Enum.any?(errors, &(&1.code == "invalid_workflow_profile" and &1.path == ["workflow_profile", "kind"])) + assert Enum.any?(errors, &(&1.code == "invalid_route_key" and &1.path == ["route_set", "terminal"])) + end + + test "rejects weak schema limits observability approval and rollback controls without leaking raw ids" do + record = + valid_record() + |> put_in(["fallback_authority", "accepted_by_profile_owners"], false) + |> put_in(["input_schema_allowlist", "rejects_unknown_fields"], false) + |> put_in(["limits", "max_calls_per_run"], 0) + |> put_in(["operator_observability", "metrics"], []) + |> put_in(["rollback", "disables_exception"], false) + |> Map.put("exception_id", "exception-token=ghp_secret") + + assert {:error, %{errors: errors} = error} = TypedToolException.validate_record(record) + + assert Enum.any?(errors, &(&1.code == "owner_approval_required" and &1.path == ["fallback_authority", "accepted_by_profile_owners"])) + assert Enum.any?(errors, &(&1.code == "strict_schema_required" and &1.path == ["input_schema_allowlist", "rejects_unknown_fields"])) + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["limits", "max_calls_per_run"])) + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["operator_observability", "metrics"])) + assert Enum.any?(errors, &(&1.code == "rollback_must_disable_exception" and &1.path == ["rollback", "disables_exception"])) + refute inspect(error) =~ "ghp_secret" + end + + defp valid_record do + %{ + "exception_id" => "typed-tool-exception-linear-github-1", + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => "linear"}, + "repo_provider" => %{"kind" => "github"}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "workspace_class" => "staging_workspace", + "route_set" => ["developing", "review"], + "operation_set" => ["repo_provider.review.read"], + "fallback_authority" => %{ + "owner" => "workflow-runtime", + "authority_kind" => "temporary_backend_adapter", + "accepted_by_profile_owners" => true + }, + "compensating_controls" => [ + "read_only_operation", + "bounded_schema_allowlist", + "operator_review_packet" + ], + "input_schema_allowlist" => %{ + "schema_ids" => ["repo_provider.review.read.v1"], + "rejects_unknown_fields" => true + }, + "limits" => %{ + "max_calls_per_run" => 3, + "max_concurrency" => 1 + }, + "audit_logging" => %{ + "event_name" => "coding_pr_delivery_typed_tool_exception_used", + "retention_class" => "workflow_audit_staging" + }, + "deterministic_tests" => [ + "test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile/typed_tool_exception_test.exs" + ], + "real_integration_evidence" => [ + "evidence/typed-tool-exceptions/linear-github-review-read.md" + ], + "operator_observability" => %{ + "metrics" => ["typed_tool_exception_used_total"], + "alerts" => ["typed_tool_exception_usage_outside_scope"], + "runbook" => "runbooks/coding-pr-delivery/typed-tool-exception.md" + }, + "rollback" => %{ + "owner" => "workflow-runtime", + "instructions" => "Remove exception id from production enablement packet and require typed tool inventory.", + "disables_exception" => true, + "restores_typed_tool_requirement" => true + }, + "expires_at" => "2026-07-25T00:00:00Z" + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile_test.exs new file mode 100644 index 0000000..5faf52a --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/production_profile_test.exs @@ -0,0 +1,208 @@ +defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfileTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ProductionProfile + alias SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.OneShot.Contract, as: OneShotContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract.Gates + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "exposes stable pure admission entrypoints" do + assert "shadow_no_write" in ProductionProfile.side_effect_modes() + assert {:ok, %{"id" => "tapd-cnb-shadow"}} = ProductionProfile.validate_provider_matrix_entry(shadow_entry()) + + assert {:ok, %{"provider_matrix" => [%{"id" => "tapd-cnb-shadow"}]}} = + ProductionProfile.validate_provider_matrix(provider_matrix_claim()) + + assert {:error, %{code: "coding_pr_delivery_typed_tool_exception_invalid", errors: errors}} = + ProductionProfile.validate_typed_tool_exception(%{}) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["exception_id"])) + + assert {:error, %{code: "coding_pr_delivery_evidence_packet_invalid"}} = + ProductionProfile.validate_evidence_packet(%{}) + + assert {:error, %{code: "coding_pr_delivery_review_packet_invalid"}} = + ProductionProfile.validate_review_packet(%{}) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_review_decision.v1", "status" => "blocked"}} = + ProductionProfile.review_decision(%{}) + + assert {:error, %{code: "coding_pr_delivery_enablement_request_invalid"}} = + ProductionProfile.validate_enablement_request(%{}) + + assert {:ok, %{"schema" => "coding_pr_delivery.production_operator_apply_plan.v1", "status" => "blocked"}} = + ProductionProfile.operator_apply_plan(%{}) + + assert {:error, %{code: "coding_pr_delivery_operator_apply_record_invalid"}} = + ProductionProfile.validate_operator_apply_record(%{}) + end + + test "builds diagnostic runbooks through the facade after claim admission" do + assert {:ok, %{"profile_instance_id" => "coding-pr-delivery-production"}} = + ProductionProfile.validate_claim(production_claim()) + + assert {:ok, runbook} = ProductionProfile.build_evidence_runbook(production_claim()) + + assert runbook["schema"] == "coding_pr_delivery.production_evidence_runbook.v1" + assert runbook["claim_authority"] == "diagnostic_runbook_only" + assert runbook["does_not_execute_providers"] == true + + assert [ + %{ + "entry_id" => "tapd-cnb-shadow", + "tracker" => %{"kind" => "tapd"}, + "repo_provider" => %{"kind" => "cnb"}, + "shadow_requirements" => shadow_requirements + } + ] = runbook["entries"] + + assert shadow_requirements["prefix"] == OneShotContract.shadow_prefix() + assert shadow_requirements["canonical_authority"] == false + end + + test "exposes Linear + CNB shadow admission and runbooks through the facade" do + entry = shadow_entry("linear-cnb-shadow", "linear") + + assert {:ok, %{"id" => "linear-cnb-shadow"} = normalized_entry} = + ProductionProfile.validate_provider_matrix_entry(entry) + + assert normalized_entry["tracker"]["kind"] == "linear" + assert normalized_entry["repo_provider"]["kind"] == "cnb" + assert normalized_entry["structured_plan_gates"][Gates.transition_readiness_required_gate_key()] == false + assert normalized_entry["shadow"]["canonical_authority"] == false + + assert {:ok, %{"provider_matrix" => [%{"id" => "linear-cnb-shadow"}]}} = + ProductionProfile.validate_provider_matrix(provider_matrix_claim(entry)) + + assert {:ok, %{"provider_matrix" => [%{"id" => "linear-cnb-shadow"}]}} = + ProductionProfile.validate_claim(production_claim(entry)) + + assert {:ok, %{"entries" => [runbook_entry]}} = + ProductionProfile.build_evidence_runbook(production_claim(entry)) + + assert runbook_entry["entry_id"] == "linear-cnb-shadow" + assert runbook_entry["tracker"]["kind"] == "linear" + assert runbook_entry["repo_provider"]["kind"] == "cnb" + assert runbook_entry["shadow_requirements"]["prefix"] == OneShotContract.shadow_prefix() + assert runbook_entry["shadow_requirements"]["canonical_authority"] == false + assert Enum.any?(runbook_entry["collection_steps"], &(&1["id"] == "verify_shadow_isolation")) + assert runbook_entry["evidence_files"]["typed_tool_exceptions"] == [] + end + + test "does not build runbooks for invalid production claims" do + assert {:error, %{code: "coding_pr_delivery_production_claim_invalid", errors: errors}} = + ProductionProfile.build_evidence_runbook(%{}) + + assert Enum.any?(errors, &(&1.code == "required_field_missing" and &1.path == ["profile_instance_id"])) + end + + defp production_claim(entry \\ shadow_entry()) do + %{ + "profile_instance_id" => "coding-pr-delivery-production", + "provider_matrix" => [entry], + "production_governance" => [governance_packet(entry["id"])] + } + end + + defp provider_matrix_claim(entry \\ shadow_entry()) do + %{ + "profile_instance_id" => "coding-pr-delivery-production", + "provider_matrix" => [entry] + } + end + + defp shadow_entry(id \\ "tapd-cnb-shadow", tracker \\ "tapd") do + %{ + "id" => id, + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "tracker" => %{"kind" => tracker}, + "repo_provider" => %{"kind" => "cnb"}, + "agent_provider" => %{"kind" => "codex"}, + "repository_class" => "single_repo_change_proposal", + "candidate_discovery" => "runtime_targeted", + "side_effect_mode" => OneShotContract.shadow_mode(), + "structured_plan_gates" => %{ + Gates.enabled_gate_key() => true, + Gates.provider_adapters_enabled_gate_key() => true, + Gates.render_workpad_gate_key() => true, + Gates.transition_readiness_required_gate_key() => false + }, + "typed_tool_inventory" => %{ + "tracker" => ["tracker.issue_snapshot", "tracker.move_issue"], + "repo_core" => ["repo.diff", "repo.head_sha"], + "repo_provider" => ["repo_provider.change_proposal_snapshot", "repo_provider.change_proposal_checks"] + }, + "deployment_topology" => %{ + "mode" => "singleton", + "readiness_check" => "Reconciliation.runtime_topology_readiness/1" + }, + "shadow" => %{ + "prefix" => OneShotContract.shadow_prefix(), + "run_id" => "shadow-run-1", + "authority" => OneShotContract.shadow_authority(), + "canonical_authority" => false, + "allowed_destinations" => OneShotContract.shadow_allowed_destinations() + }, + "evidence_files" => ["evidence/provider-matrix/#{id}.md"], + "recovery" => %{"model" => "operator_one_shot"}, + "rollback" => %{ + "owner" => "workflow-runtime", + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key() + } + } + end + + defp governance_packet(provider_matrix_entry_id) do + %{ + "provider_matrix_entry_id" => provider_matrix_entry_id, + "profile_instance_id" => "coding-pr-delivery-production", + "durability_profile" => %{ + "storage_backend" => "sqlite", + "restart_behavior" => "survives process and worker restarts", + "backup_restore" => "daily backup with documented restore drill", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "bounded_payload_summaries_only", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "cleanup_schedule" => "daily", + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Gates.enabled_gate_key(), + "disable_rendering_gate" => Gates.render_workpad_gate_key(), + "disable_readiness_gate" => Gates.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Gates.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => ["evidence/structured-plan/governance/#{provider_matrix_entry_id}.md"] + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store_test.exs index e2c3ffd..3efa46a 100644 --- a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store_test.exs +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/readiness/evidence_store_test.exs @@ -41,6 +41,50 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Readiness.Evidence assert_received {:recorded_evidence, ["issue-1"], %{"observations" => %{}}, [tag: "demo"]} end + test "scrubs evidence before backend record" do + opts = [readiness_evidence_store_backend: FakeBackend] + + evidence = %{ + "observations" => %{ + "feedback" => %{ + "summary" => "token=ghp_secret123 Authorization: Bearer bearer-secret", + "authorization" => "Bearer header-secret" + } + } + } + + assert :ok = EvidenceStore.record(["issue-1"], evidence, opts) + + assert_received {:recorded_evidence, ["issue-1"], scrubbed, []} + assert scrubbed["observations"]["feedback"]["summary"] =~ "token=[REDACTED]" + assert scrubbed["observations"]["feedback"]["summary"] =~ "Authorization: [REDACTED]" + assert scrubbed["observations"]["feedback"]["authorization"] == "[REDACTED]" + refute inspect(scrubbed) =~ "ghp_secret123" + refute inspect(scrubbed) =~ "bearer-secret" + refute inspect(scrubbed) =~ "header-secret" + end + + test "record fails closed when scrubbing backend is unavailable" do + test_pid = self() + emit_event_fn = fn level, event, fields -> send(test_pid, {:evidence_store_event, level, event, fields}) end + + opts = [ + readiness_evidence_store_backend: FakeBackend, + storage_redaction_backend: __MODULE__.MissingRedactionCallbackBackend, + emit_event_fn: emit_event_fn + ] + + assert :ok = EvidenceStore.record(["issue-1"], %{"observations" => %{"feedback" => %{"summary" => "safe"}}}, opts) + + refute_received {:recorded_evidence, _, _, _} + + assert_received {:evidence_store_event, :warning, :coding_pr_delivery_readiness_evidence_store_error, + %{ + operation: "record", + payload_summary: %{code: "redaction_failed"} + }} + end + test "fails closed on invalid opts and backend failures" do test_pid = self() emit_event_fn = fn level, event, fields -> send(test_pid, {:evidence_store_event, level, event, fields}) end @@ -72,4 +116,8 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Readiness.Evidence refute inspect(event) =~ "backend failed" end + + defmodule MissingRedactionCallbackBackend do + @moduledoc false + end end diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot_test.exs index dbb038e..04354c8 100644 --- a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot_test.exs +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation/one_shot_test.exs @@ -40,6 +40,12 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One assert report.ok assert report.mode == "dry_run" + assert report.shadow["prefix"] == "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" + assert report.shadow["mode"] == "shadow_no_write" + assert report.shadow["run_id"] =~ ~r/^shadow-/ + assert report.shadow["authority"] == "diagnostic_only" + assert report.shadow["canonical_authority"] == false + assert "diagnostic_logs" in report.shadow["allowed_destinations"] assert report.candidate_discovery == "runtime_targeted" assert report.before_state == "In Review" assert report.after_state == "In Review" @@ -51,6 +57,14 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One refute_receive {:memory_tracker_state_update, "issue-one-shot-dry-run", _state}, 50 assert {:ok, [%Issue{state: "In Review"}]} = Tracker.fetch_issue_states_by_ids(["issue-one-shot-dry-run"]) + + text = OneShot.format_text(report) + + for line <- String.split(String.trim(text), "\n") do + assert String.starts_with?(line, "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]") + assert line =~ "shadow_run_id=#{report.shadow["run_id"]}" + assert line =~ "shadow_authority=diagnostic_only" + end end test "confirmed one-shot writes through normal reconciliation preconditions" do @@ -70,6 +84,8 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One assert report.ok assert report.mode == "state_write" + assert report.shadow == nil + refute OneShot.format_text(report) =~ "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" assert report.before_state == "In Review" assert report.after_state == "Merging" assert report.state_changed @@ -147,6 +163,129 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.Reconciliation.One refute error =~ "not_a_keyword" end + test "one-shot report output scrubs diagnostic secret strings" do + report = %{ + ok: false, + issue_id: "issue-secret-output", + mode: "dry_run", + shadow: nil, + tracker_kind: "memory", + repo_provider_kind: "memory", + before_state: "In Review", + after_state: "In Review", + decision: %{"decision" => "blocked", "reason" => "token=ghp_secret123", "target_route" => nil}, + transition: %{"event" => "change_proposal_transition_failed", "skip_reason" => nil, "target_state" => "Merging"}, + probes: [ + %{ + id: "diagnostic-probe", + ok: false, + summary: "Authorization: Bearer bearer-secret", + error: "LINEAR_API_KEY=lin-secret", + duration_ms: 3 + } + ] + } + + text = OneShot.format_text(report) + encoded_map = inspect(OneShot.to_map(report)) + + assert text =~ "token=[REDACTED]" + assert text =~ "Authorization: [REDACTED]" + assert text =~ "LINEAR_API_KEY=[REDACTED]" + refute text =~ "ghp_secret123" + refute text =~ "bearer-secret" + refute text =~ "lin-secret" + refute encoded_map =~ "ghp_secret123" + refute encoded_map =~ "bearer-secret" + refute encoded_map =~ "lin-secret" + end + + test "one-shot shadow report preserves Linear + CNB diagnostic provider identity" do + report = %{ + ok: true, + issue_id: "issue-linear-cnb-shadow", + mode: "dry_run", + shadow: %{ + "prefix" => "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]", + "run_id" => "shadow-linear-cnb-42", + "mode" => "shadow_no_write", + "authority" => "diagnostic_only", + "canonical_authority" => false, + "allowed_destinations" => ["diagnostic_logs", "review_packets", "non_authoritative_evidence"] + }, + tracker_kind: "linear", + repo_provider_kind: "cnb", + before_state: "In Review", + after_state: "In Review", + decision: %{"decision" => "ready_to_land", "reason" => "ready_to_land", "target_route" => "merging"}, + transition: %{"event" => "change_proposal_transition_skipped", "skip_reason" => "dry_run", "target_state" => "Merging"}, + probes: [ + %{ + id: "targeted-reconcile", + ok: true, + summary: "Linear + CNB shadow evidence remains diagnostic-only", + error: nil, + duration_ms: 3 + } + ] + } + + text = OneShot.format_text(report) + encoded = OneShot.to_map(report) + + for line <- String.split(String.trim(text), "\n") do + assert String.starts_with?(line, "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]") + assert line =~ "shadow_run_id=shadow-linear-cnb-42" + assert line =~ "shadow_authority=diagnostic_only" + end + + assert text =~ "tracker=linear repo_provider=cnb" + assert encoded.tracker_kind == "linear" + assert encoded.repo_provider_kind == "cnb" + assert encoded.shadow["canonical_authority"] == false + assert encoded.shadow["allowed_destinations"] == ["diagnostic_logs", "review_packets", "non_authoritative_evidence"] + end + + test "one-shot confirmed report preserves Linear + CNB provider identity without shadow metadata" do + report = %{ + ok: true, + issue_id: "issue-linear-cnb-write", + mode: "state_write", + shadow: nil, + tracker_kind: "linear", + repo_provider_kind: "cnb", + before_state: "In Review", + after_state: "Merging", + decision: %{"decision" => "ready_to_land", "reason" => "ready_to_land", "target_route" => "merging"}, + transition: %{"event" => "change_proposal_transition_succeeded", "skip_reason" => nil, "target_state" => "Merging"}, + probes: [ + %{ + id: "targeted-reconcile", + ok: true, + summary: "Linear + CNB confirmed output is not shadow evidence", + error: nil, + duration_ms: 3 + } + ] + } + + text = OneShot.format_text(report) + encoded = OneShot.to_map(report) + + assert text =~ "issue=issue-linear-cnb-write" + assert text =~ "mode=state_write" + assert text =~ "tracker=linear repo_provider=cnb" + assert text =~ "transition=change_proposal_transition_succeeded" + refute text =~ "[SHADOW_MODE_ONLY - NO PRODUCTION WRITE]" + refute text =~ "shadow_run_id=" + refute text =~ "shadow_authority=diagnostic_only" + + assert encoded.mode == "state_write" + assert encoded.tracker_kind == "linear" + assert encoded.repo_provider_kind == "cnb" + assert encoded.shadow == nil + end + test "probe exception diagnostics do not expose exception messages" do {probe, {:error, %RuntimeError{}}} = Probe.run("bounded-probe", fn -> 1 end, fn -> diff --git a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation_test.exs b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation_test.exs index 89f0670..3a6b968 100644 --- a/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation_test.exs +++ b/elixir/test/symphony_elixir/workflow/extensions/coding_pr_delivery/reconciliation_test.exs @@ -225,6 +225,75 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ReconciliationTest end end + test "runtime topology readiness proves singleton registered process-local writers" do + assert %{ + ready?: true, + status: "ready", + topology: "singleton", + process_local_state: true, + ownership: "single_active_writer_per_node", + writers: writers + } = Reconciliation.runtime_topology_readiness(topology: :singleton) + + assert Enum.map(writers, & &1.id) == [ + "candidate_inbox", + "known_target_registry", + "known_target_watcher" + ] + + assert Enum.all?(writers, &(&1.registered? and &1.alive?)) + assert Enum.all?(writers, &(&1.ownership == "process_local_registered_singleton")) + end + + test "runtime topology readiness fails closed without explicit singleton topology" do + result = Reconciliation.runtime_topology_readiness(topology: :multi_node, inbox: ["issue-secret"]) + + assert %{ + ready?: false, + status: "blocked", + topology: nil, + reason: :singleton_topology_required, + value_type: :atom, + writers: [] + } = result + + refute inspect(result) =~ "issue-secret" + end + + test "runtime topology readiness rejects unnamed process-local writers" do + unnamed_inbox = start_supervised!({Inbox, name: nil}) + + result = Reconciliation.runtime_topology_readiness(topology: :singleton, inbox: unnamed_inbox) + + assert %{ + ready?: false, + status: "blocked", + topology: "singleton", + reason: :writer_not_registered, + writers: [%{id: "candidate_inbox", server: "pid", registered?: false, alive?: true} | _] + } = result + end + + test "runtime topology readiness rejects missing registered writers with bounded diagnostics" do + result = + Reconciliation.runtime_topology_readiness( + topology: :singleton, + inbox: :missing_candidate_inbox, + known_target_registry: KnownTarget.Registry, + watcher: Watcher + ) + + assert %{ + ready?: false, + status: "blocked", + topology: "singleton", + reason: :writer_unavailable, + writers: [%{id: "candidate_inbox", server: ":missing_candidate_inbox", alive?: false} | _] + } = result + + refute inspect(result) =~ "private_payload" + end + test "runtime candidate inbox drains bounded deduplicated issue ids" do InboxAdmin.reset() @@ -585,6 +654,35 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ReconciliationTest StateStoreBackend.load(workflow_scope: scope) end + test "known target StateStore storage preserves Linear + CNB provider identity" do + scope = workflow_scope("linear-cnb-known-target-state-store") + + attrs = + linear_cnb_known_target_attrs("LIN-42") + |> Map.put("raw_provider_payload", %{"authorization" => "Bearer secret-token"}) + + {:ok, target} = KnownTarget.new(attrs, now_ms: 1_000) + + assert :ok = StateStoreBackend.put(target, workflow_scope: scope, now_ms: 1_001) + + assert {:ok, + [ + %KnownTarget{ + issue_id: "LIN-42", + tracker_kind: "linear", + repo_provider_kind: "cnb", + repository: "cnb/acme/widgets", + number: "42", + url: "https://cnb.cool/acme/widgets/-/merge_requests/42", + branch: "feature/linear-cnb-shadow", + head_sha: "abc123" + } = restored + ]} = StateStoreBackend.load(workflow_scope: scope, now_ms: 1_002) + + refute inspect(restored) =~ "raw_provider_payload" + refute inspect(restored) =~ "secret-token" + end + test "known target registration observes runtime inbox drops" do registry = start_supervised!({KnownTarget.Registry, name: nil}) inbox = start_supervised!({Inbox, name: nil, queue_limit: 1}) @@ -2506,6 +2604,19 @@ defmodule SymphonyElixir.Workflow.Extensions.CodingPrDelivery.ReconciliationTest } end + defp linear_cnb_known_target_attrs(issue_id) when is_binary(issue_id) do + %{ + "issue_id" => issue_id, + "tracker_kind" => "linear", + "repo_provider_kind" => "cnb", + "repository" => "cnb/acme/widgets", + "number" => "42", + "url" => "https://cnb.cool/acme/widgets/-/merge_requests/42", + "branch" => "feature/linear-cnb-shadow", + "head_sha" => "abc123" + } + end + defp reconcile(settings, runtime_input, opts \\ []) when is_map(settings) and is_map(runtime_input) and is_list(opts) do runtime = RuntimeProjection.new(runtime_input) result = Reconciliation.reconcile_runtime(settings, RuntimeInput.from_projection(runtime, CodingPrDelivery.id()), opts) diff --git a/elixir/test/symphony_elixir/workflow/structured_execution_plan/operator_inspection_test.exs b/elixir/test/symphony_elixir/workflow/structured_execution_plan/operator_inspection_test.exs new file mode 100644 index 0000000..9cd7129 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/structured_execution_plan/operator_inspection_test.exs @@ -0,0 +1,165 @@ +defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.OperatorInspectionTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.StructuredExecutionPlan + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.OperatorInspection + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Workpad.Contract, as: WorkpadContract + + @created_at "2026-05-20T00:00:00Z" + + test "builds a bounded operator packet without raw evidence payloads" do + plan = + plan([ + item("repo.commit", + required: true, + criticality: Contract.handoff_blocking_criticality(), + evidence_refs: [ + evidence_ref("ev-commit", "repo_commit", %{"head_sha" => "new456"}, "2026-05-20T00:00:03Z") + ] + ), + item("validation.passed", + required: true, + criticality: Contract.handoff_blocking_criticality(), + evidence_requirements: [requirement("repo_diff")], + evidence_refs: [ + evidence_ref( + "ev-validation", + "repo_diff", + %{"summary" => "Authorization: Bearer bearer-secret token=ghp_secret123"}, + "2026-05-20T00:00:01Z" + ) + ] + ) + ]) + |> Map.put("rendering", render_marker()) + + gates = %{ + Contract.enabled_gate_key() => true, + Contract.render_workpad_gate_key() => true, + Contract.transition_readiness_required_gate_key() => true, + Contract.provider_adapters_enabled_gate_key() => false + } + + assert {:ok, packet} = + OperatorInspection.build(plan, + gates: gates, + readiness_gate_result: %{"status" => "blocked", "reason" => "token=ghp_secret123"}, + rejected_updates: [%{"reason" => "Authorization: Bearer bearer-secret"}] + ) + + assert packet["schema"] == "workflow.execution_plan.operator_inspection.v1" + assert packet["plan"]["plan_id"] == "plan-inspect-1" + assert packet["gate_validation"]["valid"] == true + assert packet["gate_values"][Contract.transition_readiness_required_gate_key()] == true + assert packet["rollback_gate_values"]["disable_readiness_gate"]["gate"] == Contract.transition_readiness_required_gate_key() + assert packet["rollback_gate_values"]["disable_readiness_gate"]["rollback_value"] == false + assert packet["freshness_state"] == %{"status" => "stale", "stale_item_ids" => ["validation.passed"]} + assert packet["latest_render_marker"][WorkpadContract.fingerprint_key()] == "fingerprint-123" + + assert [ + %{ + "item_id" => "repo.commit", + "freshness" => "fresh" + }, + %{ + "item_id" => "validation.passed", + "evidence_ref_count" => 1, + "freshness" => "stale" + } + ] = packet["required_item_statuses"] + + assert Enum.any?(packet["evidence_refs"], &(&1["evidence_id"] == "ev-validation" and &1["payload_present"] == true)) + refute inspect(packet) =~ "bearer-secret" + refute inspect(packet) =~ "ghp_secret123" + refute inspect(packet) =~ "summary" + end + + test "validates gate packets and rejects legacy readiness gate names" do + gates = + Contract.gate_defaults() + |> Map.put("review_handoff_required", true) + |> Map.put(Contract.render_workpad_gate_key(), "yes") + + assert %{"valid" => false, "errors" => errors} = OperatorInspection.validate_gates(gates) + + assert Enum.any?(errors, &(&1["code"] == "unknown_gate_key" and &1["path"] == ["gates", "review_handoff_required"])) + assert Enum.any?(errors, &(&1["code"] == "invalid_gate_value" and &1["path"] == ["gates", Contract.render_workpad_gate_key()])) + end + + test "facade exposes operator inspection projection" do + assert {:ok, %{"schema" => "workflow.execution_plan.operator_inspection.v1"}} = + StructuredExecutionPlan.operator_inspection(plan([item("agent.plan")])) + end + + defp plan(items) do + %{ + "schema" => "workflow.execution_plan.v1", + "plan_id" => "plan-inspect-1", + "run_id" => "run-inspect-1", + "issue_id" => "TES-90", + "tracker_kind" => "linear", + "workflow_profile" => %{"kind" => "coding_pr_delivery", "version" => 1}, + "route_key" => "developing", + "status" => "active", + "items" => items, + "created_at" => @created_at, + "updated_at" => @created_at, + "revision" => 1 + } + end + + defp item(item_id, opts \\ []) do + %{ + "item_id" => item_id, + "parent_item_id" => nil, + "title" => item_id, + "kind" => "tool_evidence", + "status" => Keyword.get(opts, :status, "pending"), + "required" => Keyword.get(opts, :required, false), + "criticality" => Keyword.get(opts, :criticality, Contract.informational_criticality()), + "owned_by" => "backend", + "source" => "profile", + "depends_on" => [], + "evidence_requirements" => Keyword.get(opts, :evidence_requirements, []), + "evidence_refs" => Keyword.get(opts, :evidence_refs, []), + "created_at" => @created_at, + "updated_at" => @created_at, + "revision" => 1 + } + end + + defp requirement(kind) do + %{ + "evidence_kind" => kind, + "required_fields" => [], + "trust_classes" => ["tool_generated"] + } + end + + defp evidence_ref(id, kind, payload, observed_at) do + %{ + "evidence_id" => id, + "evidence_kind" => kind, + "source" => "tool_generated", + "producer" => "test", + "run_id" => "run-inspect-1", + "issue_id" => "TES-90", + "observed_at" => observed_at, + "payload" => payload + } + end + + defp render_marker do + %{ + "schema" => WorkpadContract.render_schema(), + "plan_id" => "plan-inspect-1", + "plan_revision" => 1, + "tracker_kind" => "linear", + "mode" => "preview", + "rendered_item_count" => 2, + "fingerprint" => "fingerprint-123", + "workpad_id" => "workpad-1" + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/structured_execution_plan/production_profile/governance_test.exs b/elixir/test/symphony_elixir/workflow/structured_execution_plan/production_profile/governance_test.exs new file mode 100644 index 0000000..a5d7774 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow/structured_execution_plan/production_profile/governance_test.exs @@ -0,0 +1,150 @@ +defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.GovernanceTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.Workflow.StructuredExecutionPlan + alias SymphonyElixir.Workflow.StructuredExecutionPlan.Contract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProductionProfile.Governance + + test "accepts explicit retention tombstone cleanup and rollback evidence" do + assert {:ok, packet} = Governance.validate_packet(valid_packet()) + + assert packet["schema"] == Governance.schema() + assert packet["profile_instance_id"] == "coding_pr_delivery.review_handoff.linear_github.staging" + assert packet["durability_profile"]["retention_period_days"] == 30 + assert packet["data_governance"]["tombstone"]["metadata_fields"] == Governance.required_tombstone_fields() + assert packet["rollback"]["preserves_stored_plans_and_evidence"] == true + assert packet["rollback"]["deletes_records"] == false + end + + test "rejects tombstone metadata that cannot explain prior readiness claims" do + packet = + valid_packet() + |> put_in(["data_governance", "tombstone", "metadata_fields"], ["plan_id", "run_id"]) + + assert {:error, %{code: "structured_execution_plan_production_governance_invalid", errors: errors}} = + Governance.validate_packet(packet) + + assert Enum.any?( + errors, + &(&1.code == "missing_tombstone_metadata" and &1.path == ["data_governance", "tombstone", "metadata_fields", "issue_id"]) + ) + + assert Enum.any?( + errors, + &(&1.code == "missing_tombstone_metadata" and &1.path == ["data_governance", "tombstone", "metadata_fields", "tombstone_reason"]) + ) + end + + test "rejects destructive rollback semantics" do + packet = + valid_packet() + |> put_in(["rollback", "deletes_records"], true) + |> put_in(["rollback", "rewrites_evidence"], true) + |> put_in(["rollback", "makes_workpad_authoritative"], true) + + assert {:error, %{errors: errors}} = Governance.validate_packet(packet) + + assert Enum.any?(errors, &(&1.code == "invalid_destructive_rollback" and &1.path == ["rollback", "deletes_records"])) + assert Enum.any?(errors, &(&1.code == "invalid_destructive_rollback" and &1.path == ["rollback", "rewrites_evidence"])) + assert Enum.any?(errors, &(&1.code == "invalid_destructive_rollback" and &1.path == ["rollback", "makes_workpad_authoritative"])) + end + + test "rejects non fail-closed scrubber behavior and legacy rollback gates" do + packet = + valid_packet() + |> put_in(["data_governance", "scrubbing_pipeline", "failure_behavior"], "best_effort") + |> update_in(["data_governance", "scrubbing_pipeline", "pattern_catalog_rules"], &List.delete(&1, "jwt")) + |> put_in(["rollback", "disable_readiness_gate"], "review_handoff_required") + + assert {:error, %{errors: errors} = error} = Governance.validate_packet(packet) + + assert Enum.any?( + errors, + &(&1.code == "invalid_scrubbing_failure_behavior" and + &1.path == ["data_governance", "scrubbing_pipeline", "failure_behavior"]) + ) + + assert Enum.any?( + errors, + &(&1.code == "missing_scrubbing_pattern_rule" and + &1.path == ["data_governance", "scrubbing_pipeline", "pattern_catalog_rules", "jwt"]) + ) + + assert Enum.any?(errors, &(&1.code == "invalid_rollback_gate" and &1.path == ["rollback", "disable_readiness_gate"])) + refute inspect(error) =~ "best_effort" + end + + test "rejects unbounded production governance evidence references" do + packet = + valid_packet() + |> Map.put("evidence_files", [ + "fill-governance-evidence.md", + "file:///var/tmp/governance.txt" + ]) + + assert {:error, %{errors: errors}} = Governance.validate_packet(packet) + + assert Enum.any?(errors, &(&1.code == "invalid_evidence_ref")) + assert Enum.any?(errors, &(&1.code == "placeholder_evidence_ref")) + end + + test "facade exposes production governance validation" do + assert {:ok, %{"schema" => "workflow.execution_plan.production_governance.v1"}} = + StructuredExecutionPlan.validate_production_governance(valid_packet()) + end + + defp valid_packet do + %{ + "profile_instance_id" => "coding_pr_delivery.review_handoff.linear_github.staging", + "durability_profile" => %{ + "storage_backend" => "sqlite", + "restart_behavior" => "survives process and worker restarts", + "backup_restore" => "daily backup with documented restore drill", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_behavior" => "tombstone_preserving_prune", + "survives_process_restart" => true, + "survives_worker_restart" => true, + "survives_service_restart" => true + }, + "data_governance" => %{ + "data_classification" => "workflow_audit", + "redaction_policy" => "bounded_payload_summaries_only", + "retention_class" => "workflow_audit_staging", + "retention_period_days" => 30, + "cleanup_owner" => "workflow-runtime", + "cleanup_schedule" => "daily", + "scrubbing_pipeline" => %{ + "owner" => "workflow-runtime-security", + "pattern_catalog_version" => "2026-06-25", + "pattern_catalog_rules" => Governance.required_scrubbing_pattern_rules(), + "failure_behavior" => "fail_closed", + "enforced_boundaries" => [ + "structured_plan_evidence_write", + "structured_plan_render", + "review_packet_render" + ] + }, + "tombstone" => %{ + "metadata_fields" => Governance.required_tombstone_fields(), + "prevents_id_reuse" => true, + "prevents_hidden_readiness_claims" => true, + "preserves_audit_trail" => true + } + }, + "rollback" => %{ + "disable_recording_gate" => Contract.enabled_gate_key(), + "disable_rendering_gate" => Contract.render_workpad_gate_key(), + "disable_readiness_gate" => Contract.transition_readiness_required_gate_key(), + "disable_provider_adapters_gate" => Contract.provider_adapters_enabled_gate_key(), + "preserves_stored_plans_and_evidence" => true, + "deletes_records" => false, + "rewrites_evidence" => false, + "makes_workpad_authoritative" => false + }, + "evidence_files" => [ + "evidence/structured-plan/governance/linear-github-staging.md" + ] + } + end +end diff --git a/elixir/test/symphony_elixir/workflow/structured_execution_plan/store_test.exs b/elixir/test/symphony_elixir/workflow/structured_execution_plan/store_test.exs index 93b3007..0c2e1cd 100644 --- a/elixir/test/symphony_elixir/workflow/structured_execution_plan/store_test.exs +++ b/elixir/test/symphony_elixir/workflow/structured_execution_plan/store_test.exs @@ -3,6 +3,9 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.StoreTest do alias SymphonyElixir.Agent.ExecutionPlan.ErrorCodes.StatusMachine, as: StatusMachineErrorCodes alias SymphonyElixir.Agent.ExecutionPlan.Store, as: AgentStore + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProviderSessionEvent + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProviderSessionEvent.Contract, as: ProviderSessionEventContract + alias SymphonyElixir.Workflow.StructuredExecutionPlan.ProviderSessionEvent.Values, as: ProviderSessionEventValues alias SymphonyElixir.Workflow.StructuredExecutionPlan.Store alias SymphonyElixir.Workflow.StructuredExecutionPlan.Workpad.Renderer @@ -126,6 +129,102 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.StoreTest do Store.append_evidence_ref("plan-test-1", "agent.plan", changed_ref, 2, server: store) end + test "append evidence ref scrubs payload before storage", %{store: store} do + assert {:ok, _plan} = Store.create(minimal_plan(), server: store) + + metadata_key = "api" <> "_key" + metadata_value = "fixture-value" + + secret_ref = + put_in(evidence_ref(), ["payload"], %{ + "branch" => "feature/demo", + "head_sha" => "abc123", + "summary" => "Authorization: Bearer bearer-secret token=ghp_secret123", + "metadata" => %{metadata_key => metadata_value} + }) + + assert {:ok, %{"items" => [%{"evidence_refs" => [stored_ref]}]}} = + Store.append_evidence_ref("plan-test-1", "agent.plan", secret_ref, 1, server: store) + + payload = stored_ref["payload"] + + assert payload["branch"] == "feature/demo" + assert payload["head_sha"] == "abc123" + assert payload["summary"] =~ "Authorization: [REDACTED]" + assert payload["summary"] =~ "token=[REDACTED]" + assert payload["metadata"]["api_key"] == "[REDACTED]" + refute inspect(payload) =~ "bearer-secret" + refute inspect(payload) =~ "ghp_secret123" + refute inspect(payload) =~ "fixture-value" + end + + test "record evidence refs scrubs matching refs before storage", %{store: store} do + assert {:ok, _plan} = Store.create(minimal_plan_with_required_evidence(), server: store) + + validation_result = + "LINEAR" <> + "_API_KEY=lin" <> + "-secret Authorization: Bearer bear" <> + "er-secret" + + secret_ref = + put_in(evidence_ref(), ["payload"], %{ + "branch" => "feature/demo", + "head_sha" => "abc123", + "published_head_sha" => "abc123", + "validation_result" => validation_result + }) + + assert {:ok, %{"items" => [%{"evidence_refs" => [stored_ref]}]}} = + Store.record_evidence_refs("plan-test-1", [secret_ref], server: store) + + payload = stored_ref["payload"] + + assert payload["validation_result"] =~ "LINEAR_API_KEY=[REDACTED]" + assert payload["validation_result"] =~ "Authorization: [REDACTED]" + refute inspect(payload) =~ "lin-secret" + refute inspect(payload) =~ "bearer-secret" + end + + test "evidence ref storage fails closed when scrubbing backend is unavailable", %{store: store} do + assert {:ok, _plan} = Store.create(minimal_plan(), server: store) + + assert {:error, %{code: "redaction_failed"}} = + Store.append_evidence_ref( + "plan-test-1", + "agent.plan", + evidence_ref(), + 1, + server: store, + storage_redaction_backend: __MODULE__.MissingRedactionCallbackBackend + ) + + assert {:ok, %{"revision" => 1, "items" => [%{"evidence_refs" => []}]}} = Store.fetch("plan-test-1", server: store) + end + + test "provider session events are scrubbed before storage", %{store: store} do + assert {:ok, _plan} = Store.create(minimal_plan(), server: store) + + event = provider_session_event("token=ghp_secret123 Authorization: Bearer bearer-secret") + + assert {:ok, %{"extensions" => extensions}} = Store.record_provider_session_event("plan-test-1", event, 1, server: store) + + assert [ + %{ + "tasks" => [ + %{ + "note" => note + } + ] + } + ] = Map.fetch!(extensions, ProviderSessionEvent.extension_key()) + + assert note =~ "token=[REDACTED]" + assert note =~ "Authorization: [REDACTED]" + refute inspect(extensions) =~ "ghp_secret123" + refute inspect(extensions) =~ "bearer-secret" + end + test "render markers are stored without changing canonical plan revision", %{store: store} do plan = minimal_plan() @@ -158,6 +257,11 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.StoreTest do } end + defp minimal_plan_with_required_evidence do + minimal_plan() + |> Map.put("items", [required_evidence_item()]) + end + defp minimal_item do %{ "item_id" => "agent.plan", @@ -178,6 +282,17 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.StoreTest do } end + defp required_evidence_item do + minimal_item() + |> Map.put("evidence_requirements", [ + %{ + "evidence_kind" => "repo_push", + "required_fields" => ["branch", "head_sha", "published_head_sha"], + "trust_classes" => ["tool_generated"] + } + ]) + end + defp evidence_ref do %{ "evidence_id" => "evidence-test-1", @@ -191,6 +306,27 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.StoreTest do } end + defp provider_session_event(note) do + %{ + ProviderSessionEventContract.schema_key() => ProviderSessionEventContract.schema_id(), + ProviderSessionEventContract.authority_key() => ProviderSessionEventValues.authority(), + ProviderSessionEventContract.trust_class_key() => ProviderSessionEventValues.default_trust_class(), + ProviderSessionEventContract.provider_kind_key() => "codex", + ProviderSessionEventContract.surface_key() => ProviderSessionEventValues.provider_session_tasks_surface(), + ProviderSessionEventContract.event_id_key() => "provider-event-1", + ProviderSessionEventContract.run_id_key() => "run-test-1", + ProviderSessionEventContract.observed_at_key() => "2026-05-20T00:00:01Z", + ProviderSessionEventContract.tasks_key() => [ + %{ + ProviderSessionEventContract.provider_task_id_key() => "task-1", + ProviderSessionEventContract.title_key() => "Review", + ProviderSessionEventContract.requested_status_key() => "completed", + ProviderSessionEventContract.note_key() => note + } + ] + } + end + defp agent_plan(plan_id) do %{ "schema" => "agent.execution_plan.v1", @@ -209,4 +345,8 @@ defmodule SymphonyElixir.Workflow.StructuredExecutionPlan.StoreTest do "revision" => 1 } end + + defmodule MissingRedactionCallbackBackend do + @moduledoc false + end end