diff --git a/.gitattributes b/.gitattributes index 9081cb07316..380b048848c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,6 +22,7 @@ DESIGN.md merge=generated-artifact ROADMAP.md merge=generated-artifact dag/gunbc/stage0/stage0_crate_layout_generated.dag merge=generated-artifact dag/gunbc/stage0/stage0_crate_partition_generated.dag merge=generated-artifact +dag/gunbc/stage0/stage0_executable_assembly_generated.dag merge=generated-artifact docs/design-ledgers.md merge=generated-artifact docs/plans/budget-tree.md merge=generated-artifact docs/plans/ci-humming.md merge=generated-artifact diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index 2d48d9e73ef..e6fe4678491 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -1,4 +1,5 @@ name: fleet-converge +run-name: fleet-converge ${{ inputs.mode }} ${{ inputs.host }} nonce=${{ inputs.transaction_nonce }} on: workflow_dispatch: inputs: @@ -10,7 +11,7 @@ on: mode: description: plan shows membership hunks; apply executes a prior plan artifact; org_actions_observe validates the org credential and refuses on runner-group drift; spark modes observe or converge the selected Spark required: true - options: [plan, allocation_store_plan, apply, org_actions_observe, spark_linger, spark_observe, spark_reboot, spark_grants, spark_durability, dashboard_deploy] + options: [plan, allocation_store_plan, apply, org_actions_observe, spark_linger, spark_observe, spark_reboot, spark_grants, spark_durability, dashboard_deploy, rlm_launch_deployment_receipt] type: choice target: description: Spark target host for spark_linger mode (srv5 or srv6) @@ -22,7 +23,23 @@ on: required: false type: string plan_workflow_run_id: - description: Workflow run id that produced the plan artifact — required for apply + description: Workflow run id that produced the plan artifact — required for apply, dashboard_deploy and rlm_launch_deployment_receipt + required: false + type: string + expected_revision: + description: R — the exact main revision this transaction installs and proves; every run of the transaction must execute at it (RLM_EXPECTED_REVISION). Required for dashboard_deploy and rlm_launch_deployment_receipt + required: false + type: string + apply_workflow_run_id: + description: Workflow run id of the fleet apply — required for dashboard_deploy and rlm_launch_deployment_receipt + required: false + type: string + dashboard_workflow_run_id: + description: Workflow run id of the dashboard deploy — required for rlm_launch_deployment_receipt + required: false + type: string + transaction_nonce: + description: Operator-minted unique nonce for one RLM transaction; echoed into run-name so each dispatched run is API-selectable by its displayTitle, closing the run-id correlation gap (review 5062738052 B4). Not consumed by any step required: false type: string jobs: @@ -125,6 +142,9 @@ jobs: contents: read actions: read id-token: write + concurrency: + group: gunbc-host-mutation-${{ github.event.inputs.host }} + cancel-in-progress: false steps: - name: Checkout uses: actions/checkout@v5 @@ -156,6 +176,7 @@ jobs: timeout-minutes: 5 - name: Materialize fleet key in-run (SM versions/1 pinned -> RUNNER_TEMP 0600 -> ssh-agent -> wipe file) run: | + # 🟡 dissolve-on: gunbc_ci_fleet_key_agent_script - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, one PINNED secret version fetched over curl, a 0600 key file under RUNNER_TEMP with a trap armed BEFORE the credential touches disk, fingerprint verified against the modeled authority, ssh-agent load, file wipe. The pipeline steps are modeled (gunbc_ci_fleet_key_agent_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) realizes the credential runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold set -euo pipefail umask 077 KEY_FILE="$RUNNER_TEMP/fleet-automation-key" @@ -186,6 +207,9 @@ jobs: run: | ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/fleet/fleet_converge_plan_cli.dag --function fleet_converge_plan_wet + env: + FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} + RLM_EXPECTED_REVISION: ${{ github.event.inputs.expected_revision }} if: github.event.inputs.mode == 'plan' timeout-minutes: 5 - name: Fleet allocation-store substrate plan @@ -226,8 +250,21 @@ jobs: "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/fleet/fleet_converge_plan_cli.dag --function fleet_converge_apply_wet env: EXPECTED_HASH: ${{ github.event.inputs.plan_artifact_hash }} + FLEET_CONVERGE_PLAN_RUN_ID: ${{ github.event.inputs.plan_workflow_run_id }} + RLM_EXPECTED_REVISION: ${{ github.event.inputs.expected_revision }} if: github.event.inputs.mode == 'apply' timeout-minutes: 5 + - name: Upload fleet converge apply receipt + id: apply_receipt_upload + uses: actions/upload-artifact@v4 + with: + name: fleet-converge-apply-receipt + path: /tmp/fleet-converge-apply + if-no-files-found: error + retention-days: 90 + compression-level: 0 + if: github.event.inputs.mode == 'apply' + timeout-minutes: 10 - name: Org Actions credential validation + read-only settings diff id: org_actions_observe run: | @@ -326,7 +363,7 @@ jobs: timeout-minutes: 50 if: github.event.inputs.mode == 'dashboard_deploy' concurrency: - group: srv1-dashboard-deploy + group: gunbc-host-mutation-${{ github.event.inputs.host }} cancel-in-progress: false steps: - name: Checkout @@ -348,10 +385,168 @@ jobs: rm -f "$ROOT/release-bins.tgz" "$ROOT/target/release/claim_executor" --verify-build-artifacts "$ROOT/target/release/claim_executor" "$ROOT/target/release/gunbc" "$ROOT/target/release/discover_source_root_ingest" "$ROOT/target/release/claim_batch" "$ROOT/target/release/interp_recorded_fixture_witness" "$ROOT/target/release/v1_src_dag_parse" "$ROOT/target/release/auth_declared_but_unwired_witness" "$ROOT/target/release/bootstrap_witness" "$ROOT/target/release/dag_collect_fingerprint_witness" "$ROOT/target/release/diagnostics_witness" "$ROOT/target/release/effects_rest_transport_witness" "$ROOT/target/release/infer_semantics_witness" "$ROOT/target/release/parse_witness" "$ROOT/target/release/cssl_assemble" "$ROOT/target/release/namespace_structural_root_exposure_generated_witness" "$ROOT/target/release/codex_app_server_stdio_session" timeout-minutes: 5 - - name: Deploy dashboard to srv1 (live_deploy_apply_srv1_wet) + - name: Download the plan artifact (carries the plan receipt) from the plan run + id: dashboard_plan_receipt_download + uses: actions/download-artifact@v4 + with: + name: fleet-converge-plan + path: /tmp/fleet-converge-plan + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.inputs.plan_workflow_run_id }} + timeout-minutes: 10 + - name: Download the fleet apply receipt from the apply run + id: dashboard_apply_receipt_download + uses: actions/download-artifact@v4 + with: + name: fleet-converge-apply-receipt + path: /tmp/fleet-converge-apply + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.inputs.apply_workflow_run_id }} + timeout-minutes: 10 + - name: Deploy dashboard to srv1 (live_deploy_apply_srv1_transaction_wet, receipted) id: dashboard_deploy run: | ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) - "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/live_deploy/apply.dag --function live_deploy_apply_srv1_wet - cat "$ROOT/target/live-deploy-srv1-receipt.txt" + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/live_deploy/apply.dag --function live_deploy_apply_srv1_transaction_wet + cat "$ROOT/target/live-deploy-srv1-receipt/live_deploy_receipt.json" + env: + RLM_EXPECTED_REVISION: ${{ github.event.inputs.expected_revision }} + RLM_PLAN_RUN_ID: ${{ github.event.inputs.plan_workflow_run_id }} + RLM_APPLY_RUN_ID: ${{ github.event.inputs.apply_workflow_run_id }} + RLM_PLAN_ARTIFACT_HASH: ${{ github.event.inputs.plan_artifact_hash }} timeout-minutes: 30 + - name: Upload live-deploy transaction receipt + id: dashboard_receipt_upload + uses: actions/upload-artifact@v4 + with: + name: live-deploy-srv1-receipt + path: target/live-deploy-srv1-receipt + if-no-files-found: error + retention-days: 90 + compression-level: 0 + if: always() + timeout-minutes: 10 + rlm-launch-deployment-receipt: + runs-on: ${{ fromJSON(format('["self-hosted","linux","arm64","{0}"]', github.event.inputs.host)) }} + needs: [build] + timeout-minutes: 80 + if: github.event.inputs.mode == 'rlm_launch_deployment_receipt' + permissions: + contents: read + actions: read + id-token: write + concurrency: + group: gunbc-host-mutation-${{ github.event.inputs.host }} + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Download release-bins artifact + uses: actions/download-artifact@v4 + with: + name: release-bins + timeout-minutes: 10 + - name: Unpack + verify release bins (claim_executor --verify-build-artifacts; fail-closed) + id: release_bins + run: | + # 🟡 dissolve-on: ci_release_bins_unpack_verify_script — concat-built foreign-executor (GitHub Actions run:) release-bins unpack + claim_executor --verify-build-artifacts runner; membership of verified paths is derived from gunbc.ci_release_bins, but the unpack/verify transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell→intent Phase 2) realizes the unpack/verify runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + mkdir -p "$ROOT/target/release" + tar -xzf "$ROOT/release-bins.tgz" -C "$ROOT/target/release" + rm -f "$ROOT/release-bins.tgz" + "$ROOT/target/release/claim_executor" --verify-build-artifacts "$ROOT/target/release/claim_executor" "$ROOT/target/release/gunbc" "$ROOT/target/release/discover_source_root_ingest" "$ROOT/target/release/claim_batch" "$ROOT/target/release/interp_recorded_fixture_witness" "$ROOT/target/release/v1_src_dag_parse" "$ROOT/target/release/auth_declared_but_unwired_witness" "$ROOT/target/release/bootstrap_witness" "$ROOT/target/release/dag_collect_fingerprint_witness" "$ROOT/target/release/diagnostics_witness" "$ROOT/target/release/effects_rest_transport_witness" "$ROOT/target/release/infer_semantics_witness" "$ROOT/target/release/parse_witness" "$ROOT/target/release/cssl_assemble" "$ROOT/target/release/namespace_structural_root_exposure_generated_witness" "$ROOT/target/release/codex_app_server_stdio_session" + timeout-minutes: 5 + - name: WIF auth (OIDC -> fleet-cloud-convergence SA, access token only) + id: wif_auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: projects/582015116396/locations/global/workloadIdentityPools/github-actions/providers/github-oidc + service_account: fleet-cloud-convergence@gunbai-secrets.iam.gserviceaccount.com + token_format: access_token + create_credentials_file: false + timeout-minutes: 5 + - name: Materialize fleet key in-run (SM versions/1 pinned -> RUNNER_TEMP 0600 -> ssh-agent -> wipe file) + run: | + # 🟡 dissolve-on: gunbc_ci_fleet_key_agent_script - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, one PINNED secret version fetched over curl, a 0600 key file under RUNNER_TEMP with a trap armed BEFORE the credential touches disk, fingerprint verified against the modeled authority, ssh-agent load, file wipe. The pipeline steps are modeled (gunbc_ci_fleet_key_agent_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) realizes the credential runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold + set -euo pipefail + umask 077 + KEY_FILE="$RUNNER_TEMP/fleet-automation-key" + HDR_FILE="$RUNNER_TEMP/fleet-automation-auth-header" + AGENT_STARTED=0 + cleanup() { rm -f "$KEY_FILE" "$HDR_FILE"; if [ "$AGENT_STARTED" = 1 ] && [ -n "${SSH_AGENT_PID:-}" ]; then ssh-agent -k >/dev/null 2>&1 || true; fi; } + trap cleanup EXIT + printf 'Authorization: Bearer %s\n' "$WIF_ACCESS_TOKEN" > "$HDR_FILE" + curl -sSf -H @"$HDR_FILE" "https://secretmanager.googleapis.com/v1/projects/gunbai-secrets/secrets/fleet-automation-ssh-key/versions/1:access" | python3 -c 'import sys,json,base64; sys.stdout.buffer.write(base64.b64decode(json.load(sys.stdin)["payload"]["data"]))' > "$KEY_FILE" + rm -f "$HDR_FILE" + chmod 600 "$KEY_FILE" + EXPECTED_FP="SHA256:mGT7qJsh36VsVb8OPh3m8br3oHedQHxRukRkW5P0CoQ" + OBSERVED_FP="$(ssh-keygen -y -f "$KEY_FILE" | ssh-keygen -lf - | awk '{print $2}')" + if [ "$OBSERVED_FP" != "$EXPECTED_FP" ]; then echo "ProbeCredentialIdentityMismatch: fetched secret versions/1 fingerprint $OBSERVED_FP != modeled $EXPECTED_FP; contacting NO host" >&2; exit 1; fi + eval "$(ssh-agent -s)" >/dev/null + AGENT_STARTED=1 + ssh-add "$KEY_FILE" 2>/dev/null + rm -f "$KEY_FILE" + trap - EXIT + echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV" + echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" + echo "fleet-key: agent loaded (identity fleet-automation@gunbc; secret versions/1 pinned; fingerprint verified against modeled authority; key file wiped)" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + timeout-minutes: 5 + - name: Download fleet converge plan artifact (plan run) + id: rlm_plan_download + uses: actions/download-artifact@v4 + with: + name: fleet-converge-plan + path: /tmp/fleet-converge-plan + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.inputs.plan_workflow_run_id }} + timeout-minutes: 10 + - name: Download fleet converge apply receipt (apply run) + id: rlm_apply_download + uses: actions/download-artifact@v4 + with: + name: fleet-converge-apply-receipt + path: /tmp/fleet-converge-apply + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.inputs.apply_workflow_run_id }} + timeout-minutes: 10 + - name: Download live-deploy transaction receipt (dashboard run) + id: rlm_dashboard_download + uses: actions/download-artifact@v4 + with: + name: live-deploy-srv1-receipt + path: target/live-deploy-srv1-receipt + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.inputs.dashboard_workflow_run_id }} + timeout-minutes: 10 + - name: Roadmap launch deployment receipt (rlm_launch_deployment_receipt_wet) + id: rlm_receipt + run: | + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/roadmap/roadmap_launch_deployment_cli.dag --function rlm_launch_deployment_receipt_wet + cat "$ROOT/target/rlm-launch-deployment-receipt/receipt.json" + env: + RLM_EXPECTED_REVISION: ${{ github.event.inputs.expected_revision }} + RLM_PLAN_RUN_ID: ${{ github.event.inputs.plan_workflow_run_id }} + RLM_APPLY_RUN_ID: ${{ github.event.inputs.apply_workflow_run_id }} + RLM_DASHBOARD_RUN_ID: ${{ github.event.inputs.dashboard_workflow_run_id }} + timeout-minutes: 5 + - name: Upload roadmap launch deployment receipt + id: rlm_receipt_upload + uses: actions/upload-artifact@v4 + with: + name: rlm-launch-deployment-receipt + path: target/rlm-launch-deployment-receipt + if-no-files-found: error + retention-days: 90 + compression-level: 0 + if: always() + timeout-minutes: 10 + - name: Kill the in-run ssh-agent (key never outlives the job) + run: | + if [ -n "${SSH_AGENT_PID:-}" ]; then ssh-agent -k || true; fi + if: always() + timeout-minutes: 5 diff --git a/dag/extdeps/github/actions.dag b/dag/extdeps/github/actions.dag index 1a9866ad39c..395f4036fd7 100644 --- a/dag/extdeps/github/actions.dag +++ b/dag/extdeps/github/actions.dag @@ -79,6 +79,7 @@ data workflow_run_activity_completed: String = "completed" type Workflow { name: String + run_name: String? on: List concurrency: ConcurrencySpec? jobs: List diff --git a/dag/extdeps/github/actions_environment.dag b/dag/extdeps/github/actions_environment.dag index 8380452036f..cc6d05b458b 100644 --- a/dag/extdeps/github/actions_environment.dag +++ b/dag/extdeps/github/actions_environment.dag @@ -60,6 +60,15 @@ data github_run_id_variable_name: String = "GITHUB_RUN_ID" data github_run_attempt_variable_name: String = "GITHUB_RUN_ATTEMPT" data github_runner_name_variable_name: String = "RUNNER_NAME" +// THE RUN'S REPOSITORY AND WORKFLOW REFERENCE, carried for the same reason the coordinates above +// are. GITHUB_REPOSITORY is `owner/repo`; GITHUB_WORKFLOW_REF is +// `owner/repo/.github/workflows/@`, the one runner-supplied variable that names the +// workflow FILE a run executes -- the same path the reviewed fleet-desired admission binds through +// the workflow_run event's `path` member. A consumer proving "this run executed THIS workflow" +// reads it here rather than re-deriving the file from a workflow display name. +data github_repository_variable_name: String = "GITHUB_REPOSITORY" +data github_workflow_ref_variable_name: String = "GITHUB_WORKFLOW_REF" + // Missing and present-but-empty stay separate arms, exactly as they do for the sha above. Outside // Actions every one of these is legitimately absent, which is a different fact from a runner that // set the variable to nothing, and only the second is a defect in the environment. diff --git a/dag/extdeps/github/workflow_runs.dag b/dag/extdeps/github/workflow_runs.dag index 95fa8b01683..3d864be49f4 100644 --- a/dag/extdeps/github/workflow_runs.dag +++ b/dag/extdeps/github/workflow_runs.dag @@ -50,6 +50,7 @@ data conclusion_authority_consolidation_frontier_rows: List = [ type WorkflowRun { id: Int name: String? + path: String? head_sha: CommitSha status: WorkflowRunStatus conclusion: WorkflowRunConclusion? @@ -128,6 +129,30 @@ service github.WorkflowRuns { rate_limit: { requests: 5000, per: hour, scope: core } } + operation GetRun { + input { + auth_token: Secret + owner: String + repo: String + run_id: String + } + output { + run: WorkflowRun + } + readonly + transport rest { + method: GET, + path: "/repos/\{owner\}/\{repo\}/actions/runs/\{run_id\}" + } + response { + 200 => WorkflowRun + 401 => GitHubErrorShape + 403 => GitHubErrorShape + 404 => GitHubErrorShape + 500 => GitHubErrorShape + } + } + operation ListForRef { input { auth_token: Secret diff --git a/dag/extdeps/http/client.dag b/dag/extdeps/http/client.dag index 417ee8c453f..59c10af932a 100644 --- a/dag/extdeps/http/client.dag +++ b/dag/extdeps/http/client.dag @@ -76,6 +76,30 @@ service http.Client { } } + operation GetBounded { + input { url: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: [ + "curl", + "-fsS", + "--connect-timeout", + http_client_localhost_connect_timeout_flag(), + "--max-time", + http_client_localhost_max_time_flag(), + "{url}", + ] + } + exit { + 0 => Unit + nonzero => String "http client bounded GET failed" + } + mock_response { + 0 => { body: "", success: false } "hermetic: no live HTTP endpoint; routed observation refuses rather than fabricate a body" + } + } + operation PostJsonFromFile { input { url: NonEmptyStr, request_body_file: NonEmptyStr } output { body: String from "stdout", success: Bool from "exit_success" } diff --git a/dag/extdeps/languages/yaml/gha_workflow.dag b/dag/extdeps/languages/yaml/gha_workflow.dag index 6ec37d9d60d..d51b64d72aa 100644 --- a/dag/extdeps/languages/yaml/gha_workflow.dag +++ b/dag/extdeps/languages/yaml/gha_workflow.dag @@ -498,7 +498,13 @@ fn optional_env_top(entries: List?) -> List { fn project_workflow_to_yaml(workflow: Workflow) -> YamlValue { yaml_mapping(entries: concat( - [kv(key: "name", value: yaml_string(s: workflow.name))], + concat( + [kv(key: "name", value: yaml_string(s: workflow.name))], + match workflow.run_name { + Absent => [] + Present { value: rn } => [kv(key: "run-name", value: yaml_string(s: rn))] + } + ), concat( [kv(key: "on", value: workflow_triggers_yaml(triggers: workflow.on))], concat( diff --git a/dag/extdeps/systemd/systemd.dag b/dag/extdeps/systemd/systemd.dag index f2ed8497433..cbe53aff2f2 100644 --- a/dag/extdeps/systemd/systemd.dag +++ b/dag/extdeps/systemd/systemd.dag @@ -1,8 +1,11 @@ module extdeps.systemd import std.types { NonEmptyStr, String, Int } +import std.types { list_length } +import std.algebra { trim } import std.types { List } -import std.measure { ByteSize, byte_size, byte_size_count } +import std.measure { ByteSize, byte_size, byte_size_count, Microsecond, microsecond } +import std.checked_arithmetic { checked_int_magnitude, CheckedNat, CheckedNatReady, CheckedNatOverflow } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import extdeps.systemd.systemd_contracts { systemd_unit_active_state_wire_contract } @@ -52,6 +55,18 @@ type SystemdUnitProperty | ActiveEnterTimestampMonotonic | ActiveState | MainPID + | LoadState + | UnitFileState + | SubState + | Result + | ExecMainStatus + | ExecStartProperty + | User + | WorkingDirectoryProperty + | Unit + | NextElapseUSecMonotonic + | AccuracyUSec + | TimersMonotonic fn systemd_unit_property_wire(property: SystemdUnitProperty) -> NonEmptyStr { match property { @@ -66,6 +81,18 @@ fn systemd_unit_property_wire(property: SystemdUnitProperty) -> NonEmptyStr { ActiveEnterTimestampMonotonic => "ActiveEnterTimestampMonotonic" as NonEmptyStr ActiveState => "ActiveState" as NonEmptyStr MainPID => "MainPID" as NonEmptyStr + LoadState => "LoadState" as NonEmptyStr + UnitFileState => "UnitFileState" as NonEmptyStr + SubState => "SubState" as NonEmptyStr + Result => "Result" as NonEmptyStr + ExecMainStatus => "ExecMainStatus" as NonEmptyStr + ExecStartProperty => "ExecStart" as NonEmptyStr + User => "User" as NonEmptyStr + WorkingDirectoryProperty => "WorkingDirectory" as NonEmptyStr + Unit => "Unit" as NonEmptyStr + NextElapseUSecMonotonic => "NextElapseUSecMonotonic" as NonEmptyStr + AccuracyUSec => "AccuracyUSec" as NonEmptyStr + TimersMonotonic => "TimersMonotonic" as NonEmptyStr } } @@ -85,6 +112,68 @@ data systemd_tasks_max_property: NonEmptyStr = systemd_unit_property_wire(proper data systemd_tasks_current_property: NonEmptyStr = systemd_unit_property_wire(property: TasksCurrent) +// systemd.time(7) TIME SPAN NORMALIZATION, the smallest slice launch standing needs: a span is one +// or more whitespace-separated parts (a bare value reads as seconds), and the units +// below are the documented spellings for the magnitudes systemd renders timer facts in (usec +// through hours). The result is microseconds, systemd's own internal resolution, so "60s" == +// "1min" becomes decidable equality instead of a string comparison. Unknown units, empty input, or +// a malformed part refuse with Absent -- never a partial sum. +type SystemdTimeSpanPart { + value: Int + unit: String +} + +fn systemd_time_span_unit_usec(unit: String) -> Int? { + if unit == "usec" || unit == "us" { Present { value: 1 } } + else if unit == "msec" || unit == "ms" { Present { value: 1000 } } + else if unit == "seconds" || unit == "second" || unit == "sec" || unit == "s" || unit == "" { Present { value: 1000000 } } + else if unit == "minutes" || unit == "minute" || unit == "min" || unit == "m" { Present { value: 60000000 } } + else if unit == "hours" || unit == "hour" || unit == "hr" || unit == "h" { Present { value: 3600000000 } } + else { none } +} + +fn systemd_time_span_split(part: String) -> SystemdTimeSpanPart? { + let scan = fold(chars(s: part), init: 0, f: (n, c) => + if n < 0 { n } else if c >= 48 && c <= 57 { n + 1 } else { 0 - n - 1 }) + let digits = if scan < 0 { 0 - scan - 1 } else { scan } + if digits == 0 { + none + } else { + match parse_int(s: substring(s: part, start: 0, end: digits)) { + Absent => none + Present { value: v } => + Present { value: SystemdTimeSpanPart { value: v, unit: substring(s: part, start: digits, end: string_length(s: part)) } } + } + } +} + +fn systemd_duration_usec(text: String) -> Microsecond? { + let parts = filter(split(s: trim(s: text), delimiter: " "), p => p != "") + if list_length(items: parts) == 0 { + none + } else { + let total = fold(parts, init: 0, f: (acc, part) => + if acc < 0 { acc } else { + match systemd_time_span_split(part: part) { + Absent => 0 - 1 + Present { value: vu } => + match systemd_time_span_unit_usec(unit: vu.unit) { + Absent => 0 - 1 + Present { value: mult } => acc + vu.value * mult + } + } + }) + if total < 0 { + none + } else { + match checked_int_magnitude(a: total) { + CheckedNatOverflow { cause: _ } => none + CheckedNatReady { value: n } => Present { value: microsecond(count: n) } + } + } + } +} + type SystemdCgroupMemoryLimit = MemoryLimitUnbounded | MemoryLimitBytes { bytes: ByteSize } diff --git a/dag/gunbc/actions_run_binding.dag b/dag/gunbc/actions_run_binding.dag new file mode 100644 index 00000000000..8084ca14148 --- /dev/null +++ b/dag/gunbc/actions_run_binding.dag @@ -0,0 +1,206 @@ +module gunbc.actions_run_binding + +import std.types { String, Bool, NonEmptyStr, Int, List } +import std.algebra { trim } +import extdeps.shell +import extdeps.github.actions_environment { + github_sha_variable_name, + github_run_id_variable_name, + github_repository_variable_name, + github_workflow_ref_variable_name, +} +import extdeps.languages.json.emit { JsonValue, JsonKeyValue, JsonString, json_kv, json_string, json_object } +import extdeps.languages.json.parse { + json_object_unique_member, + JsonMemberFound, JsonMemberAbsent, JsonMemberDuplicated, JsonMemberNotAnObject, +} +import gunbc.running_release_identity { release_revision_text_valid } + +// ONE RUN BINDING, READ ONCE PER RUN, CARRIED ON EVERY RECEIPT THE RUN PRODUCES. The Roadmap Launch +// MVP's RLM-2 transaction is four workflow runs (plan, apply, dashboard deploy, final receipt) that +// must all have executed at ONE main revision R, in ONE repository, through ONE workflow file. Each +// run therefore records the four facts the runner supplies about itself -- GITHUB_REPOSITORY, +// GITHUB_WORKFLOW_REF, GITHUB_RUN_ID, GITHUB_SHA (extdeps.github.actions_environment) -- and every +// downstream join compares bindings rather than trusting a workflow input. The expected revision is +// an INDEPENDENT input (RLM_EXPECTED_REVISION, a workflow_dispatch input the operator types), so +// `run_revision == expected` is a comparison of two origins and can fail; deriving both from the +// checkout would compare git to itself (gunbc.live_rust_observation live_rust_observation_note). +// Every absence is its own typed arm: outside Actions the variables are legitimately unset, and a +// run must refuse to mint a receipt it cannot bind rather than mint one bound to nothing. +type ActionsRunBinding { + repository: String + workflow_ref: String + run_id: String + run_revision: String +} + +type ActionsRunBindingObservation + = ActionsRunBound { binding: ActionsRunBinding } + | ActionsRunUnbound { variable: String, detail: String } + +type ActionsVariableRead + = ActionsVariablePresent { value: String } + | ActionsVariableAbsent { variable: String, detail: String } + +fn actions_variable_read(name: String) -> ActionsVariableRead { + match shell.Env.Get(name: name as NonEmptyStr).value { + Absent => ActionsVariableAbsent { variable: name, detail: "not set" } + Present { value: v } => + if trim(s: v) == "" { + ActionsVariableAbsent { variable: name, detail: "set but empty" } + } else { + ActionsVariablePresent { value: trim(s: v) } + } + } +} + +fn observe_actions_run_binding() -> ActionsRunBindingObservation { + match actions_variable_read(name: github_repository_variable_name) { + ActionsVariableAbsent { variable: v, detail: d } => ActionsRunUnbound { variable: v, detail: d } + ActionsVariablePresent { value: repository } => + match actions_variable_read(name: github_workflow_ref_variable_name) { + ActionsVariableAbsent { variable: v, detail: d } => ActionsRunUnbound { variable: v, detail: d } + ActionsVariablePresent { value: workflow_ref } => + match actions_variable_read(name: github_run_id_variable_name) { + ActionsVariableAbsent { variable: v, detail: d } => ActionsRunUnbound { variable: v, detail: d } + ActionsVariablePresent { value: run_id } => + match actions_variable_read(name: github_sha_variable_name) { + ActionsVariableAbsent { variable: v, detail: d } => ActionsRunUnbound { variable: v, detail: d } + ActionsVariablePresent { value: sha } => + if !release_revision_text_valid(text: sha) { + ActionsRunUnbound { variable: github_sha_variable_name, detail: join(["not a 40-digit lower-hex revision: ", sha], "") } + } else { + ActionsRunBound { + binding: ActionsRunBinding { + repository: repository, + workflow_ref: workflow_ref, + run_id: run_id, + run_revision: sha, + }, + } + } + } + } + } + } +} + +// THE EXPECTED REVISION IS A WORKFLOW INPUT, NOT A DERIVED FACT. One name for every mode of the +// transaction, so a receipt produced by any of the four runs is comparable to any other. +data rlm_expected_revision_env_name: String = "RLM_EXPECTED_REVISION" + +type ExpectedRevisionRead + = ExpectedRevisionPresent { revision: String } + | ExpectedRevisionAbsent { variable: String, detail: String } + +fn read_expected_revision() -> ExpectedRevisionRead { + match actions_variable_read(name: rlm_expected_revision_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => ExpectedRevisionAbsent { variable: v, detail: d } + ActionsVariablePresent { value: r } => + if release_revision_text_valid(text: r) { + ExpectedRevisionPresent { revision: r } + } else { + ExpectedRevisionAbsent { variable: rlm_expected_revision_env_name, detail: join(["not a 40-digit lower-hex revision: ", r], "") } + } + } +} + +type RunRevisionAdmission + = RunRevisionAdmitted { revision: String } + | RunRevisionRefused { expected: String, observed: String } + +fn admit_run_revision(binding: ActionsRunBinding, expected: String) -> RunRevisionAdmission { + if binding.run_revision == expected { + RunRevisionAdmitted { revision: expected } + } else { + RunRevisionRefused { expected: expected, observed: binding.run_revision } + } +} + +// GITHUB_WORKFLOW_REF is `/@`; the binding names a FILE when the +// prefix up to the `@` is exactly repository + "/" + path. +fn run_binding_names_workflow_file(binding: ActionsRunBinding, workflow_path: String) -> Bool { + starts_with(s: binding.workflow_ref, prefix: join([binding.repository, "/", workflow_path, "@"], "")) +} + +// TWO RUNS OF ONE TRANSACTION agree on repository, workflow file and revision. run_id is what +// distinguishes them and is compared by the join that knows which run is expected where. +fn run_bindings_share_transaction(left: ActionsRunBinding, right: ActionsRunBinding, workflow_path: String) -> Bool { + left.repository == right.repository + && left.run_revision == right.run_revision + && run_binding_names_workflow_file(binding: left, workflow_path: workflow_path) + && run_binding_names_workflow_file(binding: right, workflow_path: workflow_path) +} + +fn actions_run_binding_json(binding: ActionsRunBinding) -> JsonValue { + json_object(members: [ + json_kv(key: "repository", value: json_string(s: binding.repository)), + json_kv(key: "workflow_ref", value: json_string(s: binding.workflow_ref)), + json_kv(key: "run_id", value: json_string(s: binding.run_id)), + json_kv(key: "run_revision", value: json_string(s: binding.run_revision)), + ]) +} + +// A SHARED READER FOR REQUIRED STRING MEMBERS, so every receipt decoder in this transaction +// refuses the same four ways with the same words. +type JsonStringMember + = JsonStringMemberFound { value: String } + | JsonStringMemberRefused { reason: String } + +fn json_required_string_member(doc: JsonValue, key: String) -> JsonStringMember { + match json_object_unique_member(v: doc, key: key) { + JsonMemberFound { value: JsonString { value: s } } => JsonStringMemberFound { value: s } + JsonMemberFound { value: _ } => JsonStringMemberRefused { reason: join([key, " is not a string"], "") } + JsonMemberAbsent => JsonStringMemberRefused { reason: join([key, " is missing"], "") } + JsonMemberDuplicated { count: _ } => JsonStringMemberRefused { reason: join([key, " is duplicated"], "") } + JsonMemberNotAnObject => JsonStringMemberRefused { reason: "document is not an object" } + } +} + +type ActionsRunBindingDecode + = ActionsRunBindingDecoded { binding: ActionsRunBinding } + | ActionsRunBindingUnreadable { reason: String } + +fn actions_run_binding_of_json(doc: JsonValue) -> ActionsRunBindingDecode { + match json_required_string_member(doc: doc, key: "repository") { + JsonStringMemberRefused { reason: r } => ActionsRunBindingUnreadable { reason: r } + JsonStringMemberFound { value: repository } => + match json_required_string_member(doc: doc, key: "workflow_ref") { + JsonStringMemberRefused { reason: r } => ActionsRunBindingUnreadable { reason: r } + JsonStringMemberFound { value: workflow_ref } => + match json_required_string_member(doc: doc, key: "run_id") { + JsonStringMemberRefused { reason: r } => ActionsRunBindingUnreadable { reason: r } + JsonStringMemberFound { value: run_id } => + match json_required_string_member(doc: doc, key: "run_revision") { + JsonStringMemberRefused { reason: r } => ActionsRunBindingUnreadable { reason: r } + JsonStringMemberFound { value: run_revision } => + if !release_revision_text_valid(text: run_revision) { + ActionsRunBindingUnreadable { reason: "run_revision is not a 40-digit lower-hex revision" } + } else { + ActionsRunBindingDecoded { + binding: ActionsRunBinding { + repository: repository, + workflow_ref: workflow_ref, + run_id: run_id, + run_revision: run_revision, + }, + } + } + } + } + } + } +} + +fn actions_run_binding_of_member(doc: JsonValue, key: String) -> ActionsRunBindingDecode { + match json_object_unique_member(v: doc, key: key) { + JsonMemberFound { value: inner } => actions_run_binding_of_json(doc: inner) + JsonMemberAbsent => ActionsRunBindingUnreadable { reason: join([key, " is missing"], "") } + JsonMemberDuplicated { count: _ } => ActionsRunBindingUnreadable { reason: join([key, " is duplicated"], "") } + JsonMemberNotAnObject => ActionsRunBindingUnreadable { reason: "document is not an object" } + } +} + +fn actions_run_binding_identity_text(binding: ActionsRunBinding) -> String { + join([binding.repository, "|", binding.workflow_ref, "|", binding.run_id, "|", binding.run_revision], "") +} diff --git a/dag/gunbc/assimilate/bmc_token_federation.dag b/dag/gunbc/assimilate/bmc_token_federation.dag index ac35784f3ad..4175f1c14f8 100644 --- a/dag/gunbc/assimilate/bmc_token_federation.dag +++ b/dag/gunbc/assimilate/bmc_token_federation.dag @@ -214,6 +214,7 @@ fn bmc_token_smoke_job() -> Job { data bmc_token_smoke_workflow: Workflow = { name: "bmc-token-smoke", + run_name: none, on: [ WorkflowDispatch { inputs: [] } ], diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 5d34bd673c0..bc7f4a7869b 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -93,14 +93,12 @@ type RuntimeUnitCount = RuntimeUnitCountObserved { units: Nat } | RuntimeUnitCou // — never map zero to one and render a corpus failure as FLOOR-BATCH-OVER-BUDGET. Authority type // here; observation and enforcement live in claim_executor.rs (see // gunbc_ci_floor_batch_runtime_unit_count_claim_executor_seed_note). - // §7 seed-retained HAND-RUST bridge (src/v1/stage0/src/bin/claim_executor.rs): // FloorRuntimeUnitCount enum mirrors RuntimeUnitCount; each ClaimResult carries the observation; // batch aggregation refuses the clamp when any row is Unavailable. Reason: unit-count availability // is executor realization over batch results, not yet expressible as substrate behavior inside // claim_executor. Dissolve-on: v2 self-emitted claim_executor when the executor bin crosses the // self-host frontier. - data gunbc_ci_floor_batch_runtime_unit_count_claim_executor_seed_disposition: Disposition = Scaffold { dissolves_to: SingleAuthority, bind: DeclarationRef { @@ -159,7 +157,6 @@ fn gunbc_ci_workflow_wrapper_budget_minutes() -> Minute { // witnesses clamps at ~44min". This run discovered 8681, so the clamp's stated basis is ~3.7x stale // against the corpus it governs; a deep-std diff is simply the first shape to hit a wall every // broad diff now approaches. Re-denominating that basis is owed and not done here. - // Floor batch stop policy (operator ruling 2026-07-22, fail-fast lane): claim_executor's walk halt // is event-scoped so cheap-gate-early reorder does not truncate main's ledger. pull_request → // StopBeforeDependents (a cheap-gate red stops before compile/corpus). push (main) and schedule @@ -168,7 +165,6 @@ fn gunbc_ci_workflow_wrapper_budget_minutes() -> Minute { // Authority: gunbc_ci_floor_batch_stop_policy_for_github_event in v2.workflow.ci_floor_plan; // consumer: claim_executor.rs run_walk (see // gunbc_ci_floor_batch_stop_policy_claim_executor_seed_note). - // §7 seed-retained HAND-RUST bridge (src/v1/stage0/src/bin/claim_executor.rs): FloorBatchStopPolicy // enum mirrors this type; run_walk halts or continues on the variant returned by // gunbc_ci_floor_batch_stop_policy_for_github_event after @@ -179,7 +175,6 @@ fn gunbc_ci_workflow_wrapper_budget_minutes() -> Minute { // measurement (PR fail-fast ~2min; main push prints the complete gate+corpus ledger under // FullLedger). Dissolve-on: v2 self-emitted claim_executor run_walk when the executor bin crosses // the self-host frontier (DESIGN §7 terminal collapse of stage0 host bins). - data gunbc_ci_floor_batch_stop_policy_claim_executor_seed_disposition: Disposition = Scaffold { dissolves_to: SingleAuthority, bind: DeclarationRef { @@ -218,7 +213,6 @@ type DeployStage { // (execution-kind rows with their own resource profile). Empty witness_entries AND empty // discovery_scan_dirs means the plan carries no witness-corpus node at all (the regen spec's // shape). - data ci_spec_discovery_flip_note: String = "SUPERSEDED IN ITS OPERATIVE HALF 2026-08-13, AND ITS AUTHORITY DELETED 2026-08-15: the corpus batch carried SelectionOff (v2.workflow.ci_floor_plan corpus_selection_off_note — that module is deleted by the floor cut, so this names history, not a live symbol), so a PR and a main push both ran the tree-wide witness corpus WHOLE; the replacement preserves that whole-roster property by construction rather than by a flag. The shrink this note was written to describe is gone; the enrollment half it also describes — discovery_scan_dirs being the roster source at all, rather than a fixed opt-in list — still stands and is why the row survives instead of being deleted. Read the rest as the history of how the roster came to be discovered, not as the live selection policy.\n\nAS ORIGINALLY WRITTEN (affected-set enrollment flip, operator acceptance 2026-07-09 on #6403; re-landed 2026-07-10 after the operator's direct go, merged onto the resolve-receipt-gate era): the ci floor's corpus batch scans these dirs (plus the source-root *_test.dag walk) with SelectionApplied, so a PR runs the tree-wide witness corpus SHRUNK to the diff's affected set instead of the fixed opt-in roster. Rationale accepted: at flip authoring per-PR coverage was 8 of ~1721 discoverable witnesses, so a selection miss (skipped-but-affected witness) is bounded by the status quo - the witness would not have run at all - while the nightly affected-set-falsifier keeps running the full corpus cold with predictions recorded, so any missing selection edge still surfaces as a counted divergence within one cadence window. Selection stays fail-closed per the PR-A rulings (a provenance gap refuses, never widens; host-scaffold/live-tree rows never predict-skip). The 8-clean-window falsifier criterion continues to govern the falsifier's own retirement, not this flip. Empty list = no discovery (the regen spec, and synthetic test specs exercising pure roster machinery)." type CiSpec { gates: List @@ -242,7 +236,6 @@ data gunbc_ci_floor_gates: List = project_ci_floor_gates(roster: commit_ga // projection to the bag EXTENDS the totality check to the new surface; the tempting alternative — // dropping the two flipped arms or slackening bag-equality to subset — would have silenced the wall // in the same motion that made it informative, the absorbing-fallback shape DESIGN §5 forbids. - data gunbc_ci_falsifier_gates: List = project_falsifier_gates(roster: commit_gate_roster) data gunbc_ci_floor_witness_entries: List = project_ci_floor_witness_entries(roster: commit_gate_roster) @@ -439,7 +432,6 @@ data gunbc_ci_floor_batch_wall_budget_note: String = "SUPERSEDED by the derived // (witness_floor_batch_clamp_params_cover_schedule) proves the COUNT and cannot prove the MAPPING; // that gap is the standing argument for deriving these rows from the schedule rather than pairing // them by position. - // NATIVE BUNDLE ROW (index 5, operator ruling via Dispatch A->C, 2026-08-02): the production // selector enrollment adds one isolated fixed-count batch, so the positional clamp table adds // exactly one companion row; co-locating it with an unrelated batch is rejected because that would @@ -452,7 +444,6 @@ data gunbc_ci_floor_batch_wall_budget_note: String = "SUPERSEDED by the derived // 11940978688 bytes. The Runnable remains Substantial and isolated, preserving the measured 7.14GB // gap between process RSS and cgroup peak rather than disguising it through co-residence. The clamp // bounds wall admission; the memory governor remains the fail-closed capacity authority. - // The authority is filled ONCE here rather than per row: every row in this list is declared by this // list, and restating it six times would be six chances for one to name something else. The reader // supplies the index — an offset is the one part of the citation no symbol can carry. @@ -558,7 +549,6 @@ fn ci_cargo_eagain_retry_intent(command: String) -> PipelineStep { // ruling 2026-07-11 still applies to the handoff shape. The intentionally-absent negative control // (no_such_witness_bin_zzz) is excluded. The build-if-absent stale-binary class (#6352) is out of // scope: a same-run artifact is fresh by construction. - fn artifact_shell_path(name: String) -> String { concat(concat("\"$ROOT/target/release/", name), "\"") } @@ -647,7 +637,6 @@ fn ci_merge_base_diff_range(policy: DiffPolicy) -> String { // pids.current, ancestor limit, observer slot membership) via the typed read seams only. // workflow_dispatch-gated at the job, so it never rides PR or push events; the printed return value // IS the typed receipt. - // Main→desired-ref advancement (CONVERGE-0 loop leg, operator directive 2026-08-09): on a // floor-green main push (needs: [ci] IS the floor-green admission — this job exists only downstream // of the required check succeeding in the same run), advance refs/fleet/desired to the merged @@ -655,7 +644,6 @@ fn ci_merge_base_diff_range(policy: DiffPolicy) -> String { // decide_fleet_desired_advance's from side), so a race with a concurrent advance refuses rather // than overwriting; ref creation is the from=none arm. Hosts follow the ref; no host is contacted // here. - // EVERY `gunbc run` STEP THIS SPEC EMITS NAMES ITS TARGET HERE, AND NOWHERE ELSE. // // The pair was previously two string literals at each call site, and that cost a real defect: a @@ -783,6 +771,18 @@ data gunbc_ci_live_deploy_apply_srv1_target: GunbcRunStepTarget = GunbcRunStepTa function: "live_deploy_apply_srv1_wet", } +// THE RECEIPTED DASHBOARD DEPLOY (RLM-2a): the same fold as live_deploy_apply_srv1_wet, bound to +// the expected revision and the fleet transaction, minting gunbc.live_deploy.apply_receipt. +data gunbc_ci_live_deploy_transaction_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/live_deploy/apply.dag", + function: "live_deploy_apply_srv1_transaction_wet", +} + +data gunbc_ci_rlm_launch_deployment_receipt_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/roadmap/roadmap_launch_deployment_cli.dag", + function: "rlm_launch_deployment_receipt_wet", +} + data gunbc_ci_srv1_tasks_observation_target: GunbcRunStepTarget = GunbcRunStepTarget { entry: "dag/gunbc/srv1_tasks_preapply_observation.dag", function: "observe_srv1_tasks_preapply_wet", @@ -850,6 +850,8 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_probe_identity_observe_target, gunbc_ci_authorized_key_enroll_target, gunbc_ci_live_deploy_apply_srv1_target, + gunbc_ci_live_deploy_transaction_target, + gunbc_ci_rlm_launch_deployment_receipt_target, gunbc_ci_srv1_tasks_observation_target, gunbc_ci_heal_regen_target, gunbc_ci_heal_repo_local_git_config_target, @@ -872,6 +874,33 @@ fn gunbc_ci_live_deploy_apply_srv1_invoke() -> String { ) } +data gunbc_live_deploy_receipt_artifact_name: String = "live-deploy-srv1-receipt" +data gunbc_live_deploy_receipt_artifact_dir: String = "target/live-deploy-srv1-receipt" +data gunbc_fleet_converge_apply_receipt_artifact_name: String = "fleet-converge-apply-receipt" +data gunbc_fleet_converge_apply_receipt_artifact_dir: String = "/tmp/fleet-converge-apply" +data gunbc_rlm_launch_deployment_receipt_artifact_name: String = "rlm-launch-deployment-receipt" +data gunbc_rlm_launch_deployment_receipt_artifact_dir: String = "target/rlm-launch-deployment-receipt" + +fn gunbc_ci_live_deploy_transaction_invoke() -> String { + gunbc_run_step_script( + source_roots: witness_layer_roots, + entry: gunbc_ci_live_deploy_transaction_target.entry, + function: gunbc_ci_live_deploy_transaction_target.function, + claim_run: false, + receipt_rel: "target/live-deploy-srv1-receipt/live_deploy_receipt.json" + ) +} + +fn gunbc_ci_rlm_launch_deployment_receipt_invoke() -> String { + gunbc_run_step_script( + source_roots: witness_layer_roots, + entry: gunbc_ci_rlm_launch_deployment_receipt_target.entry, + function: gunbc_ci_rlm_launch_deployment_receipt_target.function, + claim_run: false, + receipt_rel: "target/rlm-launch-deployment-receipt/receipt.json" + ) +} + fn gunbc_ci_fleet_reach_probe_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, @@ -888,7 +917,6 @@ fn gunbc_ci_fleet_reach_probe_invoke() -> String { // materialization (gunbc_ci_fleet_key_agent_script); fleet_multi_principal_probe_wet uses // typed_argv_probe_over_ssh over SSH_AUTH_SOCK, not a second Secret Manager fetch from gunbc run. // Its receipt is a separate file. - fn gunbc_ci_multi_principal_probe_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, @@ -1062,9 +1090,23 @@ fn gunbc_ci_fleet_key_agent_prelude() -> List { ] } +data gunbc_ci_fleet_key_agent_shell_emit_scaffold: Disposition = Scaffold { + dissolves_to: SingleAuthority, + bind: DeclarationRef { + module_path: "gunbc.ci_spec", + decl_name: "gunbc_ci_fleet_key_agent_script", + field: WholeDeclaration + } +} + +data gunbc_ci_fleet_key_agent_shell_emit_dissolution_trigger: DissolutionCondition = unbound_dissolution(description: "🟡 dissolve-on: gunbc_ci_fleet_key_agent_script - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, one PINNED secret version fetched over curl, a 0600 key file under RUNNER_TEMP with a trap armed BEFORE the credential touches disk, fingerprint verified against the modeled authority, ssh-agent load, file wipe. The pipeline steps are modeled (gunbc_ci_fleet_key_agent_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell-to-intent Phase 2) realizes the credential runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold") + fn gunbc_ci_fleet_key_agent_script() -> String { - gunbc_invoke_step_emit_pipeline( - p: Pipeline { steps: gunbc_ci_fleet_key_agent_prelude(), on_failure: FailFast } + concat( + concat("# ", concat(dissolution_description(condition: gunbc_ci_fleet_key_agent_shell_emit_dissolution_trigger), "\n")), + gunbc_invoke_step_emit_pipeline( + p: Pipeline { steps: gunbc_ci_fleet_key_agent_prelude(), on_failure: FailFast } + ) ) } @@ -1201,7 +1243,6 @@ fn gunbc_ci_fleet_key_agent_cleanup_script() -> String { // concat-built foreign-executor shell awaiting bash-emit; deleting the emitter discharges the // obligation instead of carrying it, and an unbound condition whose subject no longer exists can // never report itself fired. - data gunbc_fleet_converge_plan_artifact_name: String = "fleet-converge-plan" fn gunbc_ci_fleet_converge_plan_invoke() -> String { @@ -1242,7 +1283,6 @@ data gunbc_ci_fleet_converge_plan_artifact_hash_env_name: String = "EXPECTED_HAS // and a per-site widening is the workaround class DESIGN section 5 names — the substrate answer is // a typed parameter-expansion carrier, default-substitution as a variant rather than a character in // a string, making the construct emittable without loosening the wall. Dissolve-on below. - data gunbc_ci_fleet_converge_apply_plan_hash_predicate_dissolution_trigger: DissolutionCondition = unbound_dissolution(description: "🟡 dissolve-on: gunbc_ci_fleet_converge_apply_plan_hash_prelude — RUNG: mitigatable, and stated rather than left to be read off the Pipeline it now sits inside. The guard executes and its refusal is typed and located at RUNTIME (PlanArtifactHashMismatch naming both hashes, exit 1 before any actuation), but the condition itself is an opaque Run leaf, so nothing at emit time can tell a correct comparison from a lying one — a mistyped operand emits happily and fails open on the runner. CEILING: structurally guaranteed. The property is decidable and the emitter already refuses malformed operands for every predicate it can carry; this one is outside what the carrier can express, not outside what is decidable. NEXT-RUNG TRIGGER, the one named thing that moves it: v2.std.orchestration Predicate operands take a typed shell-word carrier with a default-substitution variant, so the POSIX default-substitution form this guard needs becomes a constructor rather than a character sequence that v2.compiler.emit_orchestration orch_shell_test_operand_alphabet must be widened to admit. At that point the guard becomes Or/Not/StrEmpty/StrEq and Exit, the alphabet check on this construct is unwritable-by-construction, and this row and the three Do lines delete together. NOT BLOCKED ON ANYTHING ELSE: this is can-climb-after-one-grounding, not cannot-climb.") fn gunbc_ci_fleet_converge_apply_plan_hash_prelude() -> List { @@ -1360,7 +1400,6 @@ fn gunbc_ci_spark_serving_converge_slice_invoke() -> String { // WHAT THE FUNCTION STILL IS: an emitter for a step script, retained because // collect_fleet_convergence_wet is intact and hand-callable. What ended is this wire's enrolment, // not the capability. - fn gunbc_ci_collect_fleet_convergence_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, @@ -1373,7 +1412,6 @@ fn gunbc_ci_collect_fleet_convergence_invoke() -> String { // Host-key scan + enroll dispatch wires (CONVERGE acceptance item 4): scan observes, // enroll applies the committed registry; both srv1-dispatched, receipts catted. - fn gunbc_ci_fleet_host_key_scan_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, @@ -1446,7 +1484,6 @@ fn gunbc_ci_authorized_key_enroll_invoke() -> String { // section 3 forbids, so they are merged here, and the accompanying `data ..._deleted_note: String` // row is dropped — a String declaration whose sole purpose is commentary is the misplaced data // section 4c names. - fn gunbc_ci_srv1_tasks_observation_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, @@ -1488,7 +1525,6 @@ fn gunbc_ci_heal_regen_invoke() -> String { // hooksPath binding does not invoke pre-push during the unattended push (cargo is absent from the // hook PATH on heal runners — a push refusal, not a formatting finding). Idempotent: // already-converged checkouts are a no-op. - fn gunbc_ci_heal_repo_local_git_config_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, @@ -1521,7 +1557,6 @@ fn gunbc_ci_heal_dispatch_invoke() -> String { // var. A transport refusal makes the gunbc invocation nonzero; the heal script classifies // HealDispatchRefused, keeps revalidation required, and fails loudly. Deliberately no fallback to // the ordinary push event and no silent retry. - // RETIRED 2026-08-18 WITH THE REGEN ROOT CUT: the guard this note describes is DELETED and no // function implements it. What follows is the incident record that produced it, kept because the // receipt outlives the mechanism; read it in the past tense. THE PAIRING THE HEAL JOB NEVER MODELED @@ -1540,7 +1575,6 @@ fn gunbc_ci_heal_dispatch_invoke() -> String { // execution: ONE binary built from the merge ref, run against a pre-#7348 tree -> the exact CI // failure; against the same tree with origin/main merged in -> ExitSuccess, zero drift. The guard // refused the unsound pairing instead of discovering it as a mid-regen crash. - // RETIRED 2026-08-18 WITH THE REGEN ROOT CUT: the guard is DELETED. The operator ruling of // 2026-07-29 below is kept as the ruling it was, not as live behaviour. THE COMPARAND IS THE // BUILT-FROM REVISION, NOT A BRANCH NAME (operator ruling 2026-07-29). The first cut compared @@ -1572,7 +1606,6 @@ fn gunbc_ci_heal_dispatch_invoke() -> String { // from MERGING at all. That is merge-admission policy, needs a source-of-truth model this repo does // not have yet (operator: the SCM lane after v1 deletion), and this guard does not pretend to // supply one — it protects the one pairing this job has agency over. - // RETIRED 2026-08-18 WITH THE REGEN ROOT CUT: the guard and its remedy are DELETED. The operator // ruling of 2026-07-29 below is kept as the ruling it was, not as live behaviour. THE GUARD APPLIES // ITS OWN REMEDY (operator ruling 2026-07-29: 'we can also try to apply the remedy automatically'). @@ -1591,7 +1624,6 @@ fn gunbc_ci_heal_dispatch_invoke() -> String { // diagnostic plus the conflicting paths — the machine will not resolve semantic conflicts on an // author's behalf. A merge that succeeds but leaves the seed still ahead (possible if the base // moved again mid-run) refuses too, rather than looping. The remedy is attempted exactly once. - data ci_heal_author_commit_artifact_dir: String = "heal-author-commit-required" data ci_heal_author_commit_artifact_name: String = "heal-author-commit-required" diff --git a/dag/gunbc/fleet/fleet_converge_plan.dag b/dag/gunbc/fleet/fleet_converge_plan.dag index 598ba5dbf92..bdc5f78d91f 100644 --- a/dag/gunbc/fleet/fleet_converge_plan.dag +++ b/dag/gunbc/fleet/fleet_converge_plan.dag @@ -1116,6 +1116,34 @@ fn fleet_converge_apply_terminal_line(terminal: FleetConvergeApplyTerminal) -> S } } +// The terminal's wire spelling, carried by the plan artifact and both fleet receipts so the RLM-2 +// join can require FullyApplied without re-deriving refusal counts it cannot observe. +fn fleet_converge_apply_terminal_wire(terminal: FleetConvergeApplyTerminal) -> String { + match terminal { + FullyApplied => "fully_applied" + PartiallyApplied { refused_axis_count: n, detail: d } => join(["partially_applied|", to_string(n), "|", d], "") + } +} + +fn fleet_converge_apply_terminal_of_wire(text: String) -> FleetConvergeApplyTerminal? { + if text == "fully_applied" { + Present { value: FullyApplied } + } else if starts_with(s: text, prefix: "partially_applied|") { + let rest = substring(s: text, start: 18, end: string_length(s: text)) + let n_text = fold(split(s: rest, delimiter: "|"), init: "", f: (acc, seg) => if acc == "" { seg } else { acc }) + match parse_int(s: n_text) { + Absent => none + Present { value: n } => + Present { value: PartiallyApplied { + refused_axis_count: n, + detail: substring(s: rest, start: string_length(s: n_text) + 1, end: string_length(s: rest)), + } } + } + } else { + none + } +} + fn fleet_converge_activation_declaration_lines() -> List { [ concat("# activation-reachability: ", runner_activation_reachability_report()), @@ -1565,6 +1593,7 @@ type FleetConvergePlanArtifact { subject: FleetConvergePlanSubject prior_generation: Int plan_lease: HeldLease + apply_terminal: FleetConvergeApplyTerminal observed_baseline_hex: Fnv1a64StructuralDigestHex plan_body: String apply_shell: String @@ -2370,21 +2399,22 @@ fn fleet_converge_plan_artifact( prior_generation: prior_generation, owner_fingerprint: owner_fp, ) + let planned_terminal = fleet_converge_apply_terminal( + host: host, + observed_slots: observed_slots, + observed_caps: observed_caps, + timer_plan: timer_plan, + cap_plan: cap_plan, + observed_fabric_cells: observed_fabric_cells, + observed_activation_readiness: observed_activation_readiness, + ) let apply_shell = fleet_converge_apply_shell( timer_plan: timer_plan, host: host, observed_slots: observed_slots, observed_fabric_cells: observed_fabric_cells, observed_activation_readiness: observed_activation_readiness, - terminal: fleet_converge_apply_terminal( - host: host, - observed_slots: observed_slots, - observed_caps: observed_caps, - timer_plan: timer_plan, - cap_plan: cap_plan, - observed_fabric_cells: observed_fabric_cells, - observed_activation_readiness: observed_activation_readiness, - ), + terminal: planned_terminal, ) match content_hash_of_value(value: baseline as NonEmptyStr) { Fnv1a64(structural) => @@ -2393,6 +2423,7 @@ fn fleet_converge_plan_artifact( subject: subject, prior_generation: prior_generation, plan_lease: lease, + apply_terminal: planned_terminal, observed_baseline_hex: structural.digest, plan_body: body, apply_shell: apply_shell, @@ -2463,6 +2494,7 @@ fn fleet_converge_allocation_store_plan_artifact( subject: subject, prior_generation: prior_generation, plan_lease: lease, + apply_terminal: FullyApplied, observed_baseline_hex: structural.digest, plan_body: body, apply_shell: apply_shell, diff --git a/dag/gunbc/fleet/fleet_converge_plan_cli.dag b/dag/gunbc/fleet/fleet_converge_plan_cli.dag index b0586009fa8..c5821f857c1 100644 --- a/dag/gunbc/fleet/fleet_converge_plan_cli.dag +++ b/dag/gunbc/fleet/fleet_converge_plan_cli.dag @@ -106,6 +106,27 @@ import gunbc.fleet_converge_plan { import gunbc.spark.serving_execution_schedule { scheduled_rows_list, } +import gunbc.actions_run_binding { + ActionsRunBinding, observe_actions_run_binding, ActionsRunBound, ActionsRunUnbound, + actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, + read_expected_revision, + ExpectedRevisionPresent, + ExpectedRevisionAbsent, + admit_run_revision, + RunRevisionAdmitted, + RunRevisionRefused, +} +import gunbc.fleet_converge_receipt { + FleetConvergePlanReceipt, FleetConvergeApplyReceipt, + fleet_converge_plan_receipt_path, fleet_converge_apply_receipt_dir, + fleet_converge_expected_host_env_name, fleet_converge_plan_run_id_env_name, + write_fleet_converge_plan_receipt, write_fleet_converge_apply_receipt, + ReceiptWritten, ReceiptWriteRefused, + read_fleet_converge_plan_receipt_at, FleetConvergePlanReceiptDecoded, FleetConvergePlanReceiptUnreadable, + fleet_converge_plan_receipt_identity_hex, + admit_apply_plan_binding, PlanBindingAdmitted, plan_binding_admission_text, + PlanBindingRunIdMismatch, PlanBindingHashMismatch, PlanBindingRevisionMismatch, PlanBindingRepositoryMismatch, +} data fleet_converge_plan_spark_typed_actions_wire_path: String = "/tmp/fleet-converge-plan/spark_serving_typed_actions.wire" @@ -422,13 +443,47 @@ func observe_allocation_store_under_namespace_wet( } } + +// THE PLAN BINDS ITS RUN AND ADMITS ITS HOST BEFORE OBSERVING ANYTHING ELSE (RLM-2 ruling sections +// 2 and 8). The run binding is what the plan receipt carries; the expected host is the workflow's +// `host` input, delivered through FLEET_CONVERGE_EXPECTED_HOST, and a plan whose kernel hostname is +// not that input refuses -- the runner label aims the dispatch, the hostname is the evidence. func fleet_converge_plan_wet() -> ProcessExit uses net: Network +{ + match observe_actions_run_binding() { + ActionsRunUnbound { variable: v, detail: d } => + exit_failure(reason: join(["fleet_converge_plan: RunUnbound — ", v, " ", d, "; the plan mode mints a run-bound receipt and runs only inside the fleet-converge workflow"], "")) + ActionsRunBound { binding: run } => + match read_expected_revision() { + ExpectedRevisionAbsent => + exit_failure(reason: "fleet_converge_plan: ExpectedRevisionAbsent — RLM_EXPECTED_REVISION is required so the plan run is bound to the operator-selected R, not merely to whatever ref was dispatched") + ExpectedRevisionPresent { revision: expected_r } => + match admit_run_revision(binding: run, expected: expected_r) { + RunRevisionRefused { expected: e, observed: o } => + exit_failure(reason: join(["fleet_converge_plan: RunRevisionRefused — expected ", e, " but this run checked out ", o], "")) + RunRevisionAdmitted { revision: _ } => + match actions_variable_read(name: fleet_converge_expected_host_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => + exit_failure(reason: join(["fleet_converge_plan: ExpectedHostAbsent — ", v, " ", d], "")) + ActionsVariablePresent { value: expected_host } => + fleet_converge_plan_bound_wet(run: run, expected_host: expected_host) + } + } + } + } +} + +func fleet_converge_plan_bound_wet(run: ActionsRunBinding, expected_host: String) -> ProcessExit + uses net: Network { match observe_host_short_wet() { Absent => exit_failure(reason: "fleet_converge_plan: HostObservationFailed — hostname probe did not succeed or returned empty output, refuse to plan") - Present { value: host } => { + Present { value: host } => + if (host as String) != expected_host { + exit_failure(reason: join(["fleet_converge_plan: ExpectedHostMismatch — the workflow input names ", expected_host, " but this runner observes ", host as String], "")) + } else { let host_short = host as String match observe_generation_store_wet() { GenerationAdmissionRefused { reason: why } => exit_failure(reason: why) @@ -514,14 +569,35 @@ func fleet_converge_plan_wet() -> ProcessExit && w_member_fp.success && w_scope.success && w_spark_wire.success { - run_shell_commands( - commands: [ - chmod_set_mode_command(chmod_program: chmod_path_resolved_program, mode: fleet_converge_plan_apply_shell_mode, path: fleet_converge_plan_apply_shell_path), - cat_command(path: fleet_converge_plan_body_path), - cat_command(path: fleet_converge_plan_content_hash_path), - ], - transport: LocalExec, - ) + let receipt = FleetConvergePlanReceipt { + run: run, + observed_host: host_short, + scope_wire: fleet_converge_scope_wire(scope: artifact.subject.scope), + member_set_fingerprint_hex: serialize_content_hash(hash: artifact.subject.member_set_fingerprint) as String, + plan_artifact_hash: content_hash as String, + observed_baseline_hex: artifact.observed_baseline_hex as String, + prior_generation: prior_generation, + planned_generation: planned_generation, + lease_key: artifact.plan_lease.epoch.lease_key as String, + lease_resource_fingerprint: serialize_content_hash(hash: artifact.plan_lease.epoch.resource_fingerprint) as String, + lease_owner_fingerprint: serialize_content_hash(hash: artifact.plan_lease.epoch.owner_fingerprint) as String, + lease_generation: artifact.plan_lease.epoch.generation, + apply_terminal: artifact.apply_terminal, + } + match write_fleet_converge_plan_receipt(r: receipt) { + ReceiptWriteRefused { path: p, error: e } => + exit_failure(reason: join(["fleet_converge_plan: PlanReceiptWriteRefused at ", p, ": ", e], "")) + ReceiptWritten { path: _ } => + run_shell_commands( + commands: [ + chmod_set_mode_command(chmod_program: chmod_path_resolved_program, mode: fleet_converge_plan_apply_shell_mode, path: fleet_converge_plan_apply_shell_path), + cat_command(path: fleet_converge_plan_body_path), + cat_command(path: fleet_converge_plan_content_hash_path), + cat_command(path: fleet_converge_plan_receipt_path), + ], + transport: LocalExec, + ) + } } else { exit_failure(reason: "fleet_converge_plan: artifact write refused") } @@ -537,8 +613,48 @@ func fleet_converge_plan_wet() -> ProcessExit } } +// APPLY BINDS ITSELF TO THE PLAN RECEIPT IT WAS HANDED (RLM-2 ruling section 2): the plan run id +// the operator typed, the bundle hash on disk, the revision and the repository must all agree with +// what the plan receipt says, and the apply receipt carries the plan receipt's identity so the +// final join can pair them without re-deriving anything. func fleet_converge_apply_wet() -> ProcessExit uses net: Network +{ + match observe_actions_run_binding() { + ActionsRunUnbound { variable: v, detail: d } => + exit_failure(reason: join(["fleet_converge_apply: RunUnbound — ", v, " ", d, "; the apply mode mints a run-bound receipt and runs only inside the fleet-converge workflow"], "")) + ActionsRunBound { binding: run } => + match read_expected_revision() { + ExpectedRevisionAbsent => + exit_failure(reason: "fleet_converge_apply: ExpectedRevisionAbsent — RLM_EXPECTED_REVISION is required; without it apply can mutate srv1 at a revision the operator never selected") + ExpectedRevisionPresent { revision: expected_r } => + match admit_run_revision(binding: run, expected: expected_r) { + RunRevisionRefused { expected: e, observed: o } => + exit_failure(reason: join(["fleet_converge_apply: RunRevisionRefused — expected ", e, " but this run checked out ", o, "; refusing before the locked actuator"], "")) + RunRevisionAdmitted { revision: admitted_r } => + match actions_variable_read(name: fleet_converge_plan_run_id_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => + exit_failure(reason: join(["fleet_converge_apply: PlanRunIdAbsent — ", v, " ", d], "")) + ActionsVariablePresent { value: plan_run_id } => + match read_fleet_converge_plan_receipt_at(path: fleet_converge_plan_receipt_path) { + FleetConvergePlanReceiptUnreadable { reason: why } => + exit_failure(reason: join(["fleet_converge_apply: PlanReceiptUnreadable — ", why, " (a plan artifact minted before the typed plan receipt joined the bundle will not carry one)"], "")) + FleetConvergePlanReceiptDecoded { receipt: plan_receipt } => + fleet_converge_apply_bound_wet(run: run, plan_run_id: plan_run_id, plan_receipt: plan_receipt, expected_revision_admitted: admitted_r) + } + } + } + } + } +} + +func fleet_converge_apply_bound_wet( + run: ActionsRunBinding, + plan_run_id: String, + plan_receipt: FleetConvergePlanReceipt, + expected_revision_admitted: String, +) -> ProcessExit + uses net: Network { let read_plan = Filesystem.Read(path: fleet_converge_plan_body_path) let read_base = Filesystem.Read(path: fleet_converge_plan_baseline_hex_path) @@ -622,18 +738,62 @@ func fleet_converge_apply_wet() -> ProcessExit reason: "fleet_converge_apply: PlanArtifactHashMismatch — the plan.txt + apply.sh + spark_serving_typed_actions.wire bundle does not match plan_content.hex (a plan artifact minted before the typed-actions wire joined the bundle will not match)", ) } else { - run_shell_commands( - commands: [ - bash_program_command( - script: fleet_converge_locked_apply_script( - prior_generation: prior_generation, - planned_generation: planned_generation, - apply_shell_path: fleet_converge_plan_apply_shell_path, - ), - ), - ], - transport: LocalExec, + let binding = admit_apply_plan_binding( + plan: plan_receipt, + apply_run: run, + plan_run_id_input: plan_run_id, + plan_hash_on_disk: trim(s: read_hash.content), ) + match binding { + PlanBindingRunIdMismatch { planned: _, requested: _ } => + exit_failure(reason: join(["fleet_converge_apply: ", plan_binding_admission_text(a: binding)], "")) + PlanBindingHashMismatch { receipt: _, on_disk: _ } => + exit_failure(reason: join(["fleet_converge_apply: ", plan_binding_admission_text(a: binding)], "")) + PlanBindingRevisionMismatch { plan: _, apply: _ } => + exit_failure(reason: join(["fleet_converge_apply: ", plan_binding_admission_text(a: binding)], "")) + PlanBindingRepositoryMismatch { plan: _, apply: _ } => + exit_failure(reason: join(["fleet_converge_apply: ", plan_binding_admission_text(a: binding)], "")) + PlanBindingAdmitted => { + let applied = run_shell_commands( + commands: [ + bash_program_command( + script: fleet_converge_locked_apply_script( + prior_generation: prior_generation, + planned_generation: planned_generation, + apply_shell_path: fleet_converge_plan_apply_shell_path, + ), + ), + ], + transport: LocalExec, + ) + let exit_code = match applied { + ExitSuccess => 0 + ExitFailure { code: c, reason: _ } => c + } + let receipt = FleetConvergeApplyReceipt { + run: run, + plan_run_id: plan_run_id, + plan_receipt_identity: fleet_converge_plan_receipt_identity_hex(r: plan_receipt), + plan_artifact_hash: trim(s: read_hash.content), + observed_host: observed_host as String, + prior_generation: prior_generation, + planned_generation: planned_generation, + expected_revision_admitted: expected_revision_admitted, + terminal: plan_receipt.apply_terminal, + locked_apply_exit_code: exit_code, + } + match run_shell_commands(commands: [mkdir_parents_command(path: fleet_converge_apply_receipt_dir)], transport: LocalExec) { + ExitFailure { code: _, reason: why } => + exit_failure(reason: join(["fleet_converge_apply: ApplyReceiptDirRefused — ", why], "")) + ExitSuccess => + match write_fleet_converge_apply_receipt(r: receipt) { + ReceiptWriteRefused { path: p, error: e } => + exit_failure(reason: join(["fleet_converge_apply: ApplyReceiptWriteRefused at ", p, ": ", e], "")) + ReceiptWritten { path: _ } => applied + } + } + } + } } } } diff --git a/dag/gunbc/fleet/fleet_converge_receipt.dag b/dag/gunbc/fleet/fleet_converge_receipt.dag new file mode 100644 index 00000000000..5d7d39b341f --- /dev/null +++ b/dag/gunbc/fleet/fleet_converge_receipt.dag @@ -0,0 +1,439 @@ +module gunbc.fleet_converge_receipt + +import std.types { String, Bool, NonEmptyStr, Int, List } +import std.algebra { trim } +import std.content_hash { Fnv1a64Structural, content_hash_atom, content_hash_tagged_structural } +import extdeps.languages.json.emit { JsonValue, json_kv, json_string, json_object, serialize_json } +import extdeps.languages.json.parse { + parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, +} +import extdeps.filesystem.filesystem_io { Filesystem } +import gunbc.fleet_converge_plan { + FleetConvergeApplyTerminal, FullyApplied, PartiallyApplied, + fleet_converge_apply_terminal_wire, fleet_converge_apply_terminal_of_wire, +} +import gunbc.actions_run_binding { + ActionsRunBinding, + actions_run_binding_json, + actions_run_binding_of_member, + ActionsRunBindingDecoded, ActionsRunBindingUnreadable, + json_required_string_member, + JsonStringMemberFound, JsonStringMemberRefused, + actions_run_binding_identity_text, +} + +// THE TYPED PLAN AND APPLY RECEIPTS THE FLEET TRANSACTION LACKED. gunbc.fleet_converge_plan_cli +// flattened both wet entries to ProcessExit: the plan artifact on disk carried the subject, +// baseline, generation and bundle hash, and apply re-verified every one of them, but nothing +// durable said WHICH RUN, at WHICH REVISION, on WHICH OBSERVED HOST minted the plan or performed +// the apply. gunbc.fleet_converge_cli ConvergeCliReceipt is not that receipt: it belongs to the +// periodic `gunbc converge` path and binds no plan hash, subject, lease generation or run. The +// RLM-2 ruling (docs/plans/roadmap-launch-mvp-plan.md, RLM-2 section 2) requires one receipt per +// phase, produced INSIDE the typed composition before the CLI projection to ProcessExit, so the +// final RoadmapLaunchDeploymentReceipt can join a plan run to an apply run to a dashboard run by +// identity rather than by narrative. +// +// THE PLAN RECEIPT LIVES INSIDE THE PLAN ARTIFACT DIRECTORY, so the existing upload step carries it +// and the existing apply download step delivers it -- no new artifact, no new transport. THE APPLY +// RECEIPT has its own directory and its own upload step because apply produces nothing else the +// workflow keeps. +// +// GENERATIONS ARE CARRIED AS DECIMAL STRINGS ON THE WIRE. The receipt is decoded by a later run +// through extdeps.languages.json.parse, whose number carrier is a lexeme; a string round-trips +// through parse_int exactly and refuses on anything that is not a non-negative decimal, which is +// the same wall fleet_converge_parse_nonneg_generation_text already holds for the on-disk +// generation files. +// +// THE OBSERVED HOST IS RECORDED AND ALSO ADMITTED AGAINST THE WORKFLOW INPUT. The runner label is +// what aims a dispatch at srv1; the kernel hostname is what the plan actually observed. +// FLEET_CONVERGE_EXPECTED_HOST carries the input into the step, and a plan whose observed host is +// not the input's host refuses before writing anything -- artifact naming from an input was never +// host evidence, and this is the row that makes the input a CHECK rather than a label. +data fleet_converge_plan_receipt_schema: String = "fleet-converge-plan-receipt/v1" +data fleet_converge_apply_receipt_schema: String = "fleet-converge-apply-receipt/v1" + +data fleet_converge_plan_receipt_path: String = "/tmp/fleet-converge-plan/plan_receipt.json" +data fleet_converge_apply_receipt_dir: String = "/tmp/fleet-converge-apply" +data fleet_converge_apply_receipt_path: String = "/tmp/fleet-converge-apply/apply_receipt.json" + +data fleet_converge_expected_host_env_name: String = "FLEET_CONVERGE_EXPECTED_HOST" +data fleet_converge_plan_run_id_env_name: String = "FLEET_CONVERGE_PLAN_RUN_ID" + +type FleetConvergePlanReceipt { + run: ActionsRunBinding + observed_host: String + scope_wire: String + member_set_fingerprint_hex: String + plan_artifact_hash: String + observed_baseline_hex: String + prior_generation: Int + planned_generation: Int + lease_key: String + lease_resource_fingerprint: String + lease_owner_fingerprint: String + lease_generation: Int + apply_terminal: FleetConvergeApplyTerminal +} + +type FleetConvergeApplyReceipt { + run: ActionsRunBinding + plan_run_id: String + plan_receipt_identity: String + plan_artifact_hash: String + observed_host: String + prior_generation: Int + planned_generation: Int + expected_revision_admitted: String + terminal: FleetConvergeApplyTerminal + locked_apply_exit_code: Int +} + +fn fleet_converge_plan_receipt_identity_text(r: FleetConvergePlanReceipt) -> String { + join([ + actions_run_binding_identity_text(binding: r.run), "|", + r.observed_host, "|", r.scope_wire, "|", r.member_set_fingerprint_hex, "|", + r.plan_artifact_hash, "|", r.observed_baseline_hex, "|", + to_string(r.prior_generation), "|", to_string(r.planned_generation), "|", r.lease_key, "|", + r.lease_resource_fingerprint, "|", r.lease_owner_fingerprint, "|", to_string(r.lease_generation), "|", + fleet_converge_apply_terminal_wire(terminal: r.apply_terminal), + ], "") +} + +fn fleet_converge_plan_receipt_identity_hex(r: FleetConvergePlanReceipt) -> String { + content_hash_tagged_structural( + tag: "fleet-converge-plan-receipt-v1" as NonEmptyStr, + payload: content_hash_atom(value: fleet_converge_plan_receipt_identity_text(r: r) as NonEmptyStr), + ).digest as String +} + +fn fleet_converge_apply_receipt_identity_text(r: FleetConvergeApplyReceipt) -> String { + join([ + actions_run_binding_identity_text(binding: r.run), "|", + r.plan_run_id, "|", r.plan_receipt_identity, "|", r.plan_artifact_hash, "|", r.observed_host, "|", + to_string(r.prior_generation), "|", to_string(r.planned_generation), "|", r.expected_revision_admitted, "|", + fleet_converge_apply_terminal_wire(terminal: r.terminal), "|", to_string(r.locked_apply_exit_code), + ], "") +} + +fn fleet_converge_apply_receipt_identity_hex(r: FleetConvergeApplyReceipt) -> String { + content_hash_tagged_structural( + tag: "fleet-converge-apply-receipt-v1" as NonEmptyStr, + payload: content_hash_atom(value: fleet_converge_apply_receipt_identity_text(r: r) as NonEmptyStr), + ).digest as String +} + +fn fleet_converge_plan_receipt_json(r: FleetConvergePlanReceipt) -> JsonValue { + json_object(members: [ + json_kv(key: "schema", value: json_string(s: fleet_converge_plan_receipt_schema)), + json_kv(key: "run", value: actions_run_binding_json(binding: r.run)), + json_kv(key: "observed_host", value: json_string(s: r.observed_host)), + json_kv(key: "scope_wire", value: json_string(s: r.scope_wire)), + json_kv(key: "member_set_fingerprint_hex", value: json_string(s: r.member_set_fingerprint_hex)), + json_kv(key: "plan_artifact_hash", value: json_string(s: r.plan_artifact_hash)), + json_kv(key: "observed_baseline_hex", value: json_string(s: r.observed_baseline_hex)), + json_kv(key: "prior_generation", value: json_string(s: to_string(r.prior_generation))), + json_kv(key: "planned_generation", value: json_string(s: to_string(r.planned_generation))), + json_kv(key: "lease_key", value: json_string(s: r.lease_key)), + json_kv(key: "lease_resource_fingerprint", value: json_string(s: r.lease_resource_fingerprint)), + json_kv(key: "lease_owner_fingerprint", value: json_string(s: r.lease_owner_fingerprint)), + json_kv(key: "lease_generation", value: json_string(s: to_string(r.lease_generation))), + json_kv(key: "apply_terminal", value: json_string(s: fleet_converge_apply_terminal_wire(terminal: r.apply_terminal))), + json_kv(key: "receipt_identity", value: json_string(s: fleet_converge_plan_receipt_identity_hex(r: r))), + ]) +} + +fn fleet_converge_apply_receipt_json(r: FleetConvergeApplyReceipt) -> JsonValue { + json_object(members: [ + json_kv(key: "schema", value: json_string(s: fleet_converge_apply_receipt_schema)), + json_kv(key: "run", value: actions_run_binding_json(binding: r.run)), + json_kv(key: "plan_run_id", value: json_string(s: r.plan_run_id)), + json_kv(key: "plan_receipt_identity", value: json_string(s: r.plan_receipt_identity)), + json_kv(key: "plan_artifact_hash", value: json_string(s: r.plan_artifact_hash)), + json_kv(key: "observed_host", value: json_string(s: r.observed_host)), + json_kv(key: "prior_generation", value: json_string(s: to_string(r.prior_generation))), + json_kv(key: "planned_generation", value: json_string(s: to_string(r.planned_generation))), + json_kv(key: "expected_revision_admitted", value: json_string(s: r.expected_revision_admitted)), + json_kv(key: "terminal", value: json_string(s: fleet_converge_apply_terminal_wire(terminal: r.terminal))), + json_kv(key: "locked_apply_exit_code", value: json_string(s: to_string(r.locked_apply_exit_code))), + json_kv(key: "receipt_identity", value: json_string(s: fleet_converge_apply_receipt_identity_hex(r: r))), + ]) +} + +type ReceiptIntMember + = ReceiptIntFound { value: Int } + | ReceiptIntRefused { reason: String } + +fn receipt_int_member(doc: JsonValue, key: String) -> ReceiptIntMember { + match json_required_string_member(doc: doc, key: key) { + JsonStringMemberRefused { reason: r } => ReceiptIntRefused { reason: r } + JsonStringMemberFound { value: text } => + match parse_int(s: trim(s: text)) { + Absent => ReceiptIntRefused { reason: join([key, " is not a decimal integer"], "") } + Present { value: n } => + if n < 0 { ReceiptIntRefused { reason: join([key, " is negative"], "") } } else { ReceiptIntFound { value: n } } + } + } +} + +type FleetConvergePlanReceiptDecode + = FleetConvergePlanReceiptDecoded { receipt: FleetConvergePlanReceipt } + | FleetConvergePlanReceiptUnreadable { reason: String } + +fn fleet_converge_plan_receipt_decode(raw: String) -> FleetConvergePlanReceiptDecode { + match parse_json_document(s: raw) { + JsonDocumentUnreadable { gap: gap } => + FleetConvergePlanReceiptUnreadable { reason: join(["plan receipt is ", json_document_gap_text(gap: gap)], "") } + JsonDocumentParsed { value: doc } => + match json_required_string_member(doc: doc, key: "schema") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: schema } => + if schema != fleet_converge_plan_receipt_schema { + FleetConvergePlanReceiptUnreadable { reason: join(["plan receipt schema is not ", fleet_converge_plan_receipt_schema, ": ", schema], "") } + } else { + match actions_run_binding_of_member(doc: doc, key: "run") { + ActionsRunBindingUnreadable { reason: r } => FleetConvergePlanReceiptUnreadable { reason: join(["plan receipt run: ", r], "") } + ActionsRunBindingDecoded { binding: run } => + match json_required_string_member(doc: doc, key: "observed_host") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: observed_host } => + match json_required_string_member(doc: doc, key: "scope_wire") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: scope_wire } => + match json_required_string_member(doc: doc, key: "member_set_fingerprint_hex") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: member_fp } => + match json_required_string_member(doc: doc, key: "plan_artifact_hash") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: plan_hash } => + match json_required_string_member(doc: doc, key: "observed_baseline_hex") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: baseline } => + match receipt_int_member(doc: doc, key: "prior_generation") { + ReceiptIntRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + ReceiptIntFound { value: prior } => + match receipt_int_member(doc: doc, key: "planned_generation") { + ReceiptIntRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + ReceiptIntFound { value: planned } => + match json_required_string_member(doc: doc, key: "lease_key") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: lease_key } => + match json_required_string_member(doc: doc, key: "lease_resource_fingerprint") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: lease_rfp } => + match json_required_string_member(doc: doc, key: "lease_owner_fingerprint") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: lease_ofp } => + match receipt_int_member(doc: doc, key: "lease_generation") { + ReceiptIntRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + ReceiptIntFound { value: lease_gen } => + match json_required_string_member(doc: doc, key: "apply_terminal") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: terminal_wire } => + match fleet_converge_apply_terminal_of_wire(text: terminal_wire) { + Absent => FleetConvergePlanReceiptUnreadable { reason: join(["plan receipt apply_terminal wire is unknown: ", terminal_wire], "") } + Present { value: planned_terminal } => { + let receipt = FleetConvergePlanReceipt { + run: run, + observed_host: observed_host, + scope_wire: scope_wire, + member_set_fingerprint_hex: member_fp, + plan_artifact_hash: plan_hash, + observed_baseline_hex: baseline, + prior_generation: prior, + planned_generation: planned, + lease_key: lease_key, + lease_resource_fingerprint: lease_rfp, + lease_owner_fingerprint: lease_ofp, + lease_generation: lease_gen, + apply_terminal: planned_terminal, + } + match json_required_string_member(doc: doc, key: "receipt_identity") { + JsonStringMemberRefused { reason: r } => FleetConvergePlanReceiptUnreadable { reason: r } + JsonStringMemberFound { value: claimed } => + if claimed != fleet_converge_plan_receipt_identity_hex(r: receipt) { + FleetConvergePlanReceiptUnreadable { reason: "plan receipt identity does not match its members" } + } else { + FleetConvergePlanReceiptDecoded { receipt: receipt } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} + +type FleetConvergeApplyReceiptDecode + = FleetConvergeApplyReceiptDecoded { receipt: FleetConvergeApplyReceipt } + | FleetConvergeApplyReceiptUnreadable { reason: String } + +fn fleet_converge_apply_receipt_decode(raw: String) -> FleetConvergeApplyReceiptDecode { + match parse_json_document(s: raw) { + JsonDocumentUnreadable { gap: gap } => + FleetConvergeApplyReceiptUnreadable { reason: join(["apply receipt is ", json_document_gap_text(gap: gap)], "") } + JsonDocumentParsed { value: doc } => + match json_required_string_member(doc: doc, key: "schema") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: schema } => + if schema != fleet_converge_apply_receipt_schema { + FleetConvergeApplyReceiptUnreadable { reason: join(["apply receipt schema is not ", fleet_converge_apply_receipt_schema, ": ", schema], "") } + } else { + match actions_run_binding_of_member(doc: doc, key: "run") { + ActionsRunBindingUnreadable { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: join(["apply receipt run: ", r], "") } + ActionsRunBindingDecoded { binding: run } => + match json_required_string_member(doc: doc, key: "plan_run_id") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: plan_run_id } => + match json_required_string_member(doc: doc, key: "plan_receipt_identity") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: plan_receipt_identity } => + match json_required_string_member(doc: doc, key: "plan_artifact_hash") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: plan_hash } => + match json_required_string_member(doc: doc, key: "observed_host") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: observed_host } => + match receipt_int_member(doc: doc, key: "prior_generation") { + ReceiptIntRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + ReceiptIntFound { value: prior } => + match receipt_int_member(doc: doc, key: "planned_generation") { + ReceiptIntRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + ReceiptIntFound { value: planned } => + match receipt_int_member(doc: doc, key: "locked_apply_exit_code") { + ReceiptIntRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + ReceiptIntFound { value: exit_code } => + match json_required_string_member(doc: doc, key: "expected_revision_admitted") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: expected_admitted } => + match json_required_string_member(doc: doc, key: "terminal") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: terminal_wire } => + match fleet_converge_apply_terminal_of_wire(text: terminal_wire) { + Absent => FleetConvergeApplyReceiptUnreadable { reason: join(["apply receipt terminal wire is unknown: ", terminal_wire], "") } + Present { value: terminal } => { + let receipt = FleetConvergeApplyReceipt { + run: run, + plan_run_id: plan_run_id, + plan_receipt_identity: plan_receipt_identity, + plan_artifact_hash: plan_hash, + observed_host: observed_host, + prior_generation: prior, + planned_generation: planned, + expected_revision_admitted: expected_admitted, + terminal: terminal, + locked_apply_exit_code: exit_code, + } + match json_required_string_member(doc: doc, key: "receipt_identity") { + JsonStringMemberRefused { reason: r } => FleetConvergeApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: claimed } => + if claimed != fleet_converge_apply_receipt_identity_hex(r: receipt) { + FleetConvergeApplyReceiptUnreadable { reason: "apply receipt identity does not match its members" } + } else { + FleetConvergeApplyReceiptDecoded { receipt: receipt } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} + +// APPLY BINDS ITSELF TO THE PLAN IT WAS HANDED, and each of the four equalities has its own arm +// because each has a different remedy: a substituted run id is an operator typo, a hash mismatch is +// a stale artifact, a revision mismatch is a plan from a different R, a repository mismatch is a +// fork's artifact. +type FleetConvergePlanBindingAdmission + = PlanBindingAdmitted + | PlanBindingRunIdMismatch { planned: String, requested: String } + | PlanBindingHashMismatch { receipt: String, on_disk: String } + | PlanBindingRevisionMismatch { plan: String, apply: String } + | PlanBindingRepositoryMismatch { plan: String, apply: String } + +fn admit_apply_plan_binding( + plan: FleetConvergePlanReceipt, + apply_run: ActionsRunBinding, + plan_run_id_input: String, + plan_hash_on_disk: String, +) -> FleetConvergePlanBindingAdmission { + if plan.run.run_id != plan_run_id_input { + PlanBindingRunIdMismatch { planned: plan.run.run_id, requested: plan_run_id_input } + } else if plan.plan_artifact_hash != plan_hash_on_disk { + PlanBindingHashMismatch { receipt: plan.plan_artifact_hash, on_disk: plan_hash_on_disk } + } else if plan.run.run_revision != apply_run.run_revision { + PlanBindingRevisionMismatch { plan: plan.run.run_revision, apply: apply_run.run_revision } + } else if plan.run.repository != apply_run.repository { + PlanBindingRepositoryMismatch { plan: plan.run.repository, apply: apply_run.repository } + } else { + PlanBindingAdmitted + } +} + +fn plan_binding_admission_text(a: FleetConvergePlanBindingAdmission) -> String { + match a { + PlanBindingAdmitted => "plan binding admitted" + PlanBindingRunIdMismatch { planned: p, requested: r } => + join(["PlanRunIdMismatch — the plan receipt was minted by run ", p, " but apply was asked to execute run ", r], "") + PlanBindingHashMismatch { receipt: a, on_disk: b } => + join(["PlanReceiptHashMismatch — the plan receipt names bundle ", a, " but the artifact on disk hashes to ", b], "") + PlanBindingRevisionMismatch { plan: a, apply: b } => + join(["PlanRevisionMismatch — the plan ran at ", a, " and this apply runs at ", b], "") + PlanBindingRepositoryMismatch { plan: a, apply: b } => + join(["PlanRepositoryMismatch — the plan ran in ", a, " and this apply runs in ", b], "") + } +} + +type FleetConvergeReceiptWrite + = ReceiptWritten { path: String } + | ReceiptWriteRefused { path: String, error: String } + +fn write_fleet_converge_plan_receipt(r: FleetConvergePlanReceipt) -> FleetConvergeReceiptWrite { + let w = Filesystem.Write(path: fleet_converge_plan_receipt_path, content: serialize_json(v: fleet_converge_plan_receipt_json(r: r))) + if w.success { ReceiptWritten { path: fleet_converge_plan_receipt_path } } else { ReceiptWriteRefused { path: fleet_converge_plan_receipt_path, error: w.error } } +} + +fn write_fleet_converge_apply_receipt(r: FleetConvergeApplyReceipt) -> FleetConvergeReceiptWrite { + let w = Filesystem.Write(path: fleet_converge_apply_receipt_path, content: serialize_json(v: fleet_converge_apply_receipt_json(r: r))) + if w.success { ReceiptWritten { path: fleet_converge_apply_receipt_path } } else { ReceiptWriteRefused { path: fleet_converge_apply_receipt_path, error: w.error } } +} + +fn read_fleet_converge_plan_receipt_at(path: String) -> FleetConvergePlanReceiptDecode { + let read = Filesystem.Read(path: path) + if !read.success { + FleetConvergePlanReceiptUnreadable { reason: join(["plan receipt could not be read at ", path, ": ", read.error], "") } + } else { + fleet_converge_plan_receipt_decode(raw: read.content) + } +} + +fn read_fleet_converge_apply_receipt_at(path: String) -> FleetConvergeApplyReceiptDecode { + let read = Filesystem.Read(path: path) + if !read.success { + FleetConvergeApplyReceiptUnreadable { reason: join(["apply receipt could not be read at ", path, ": ", read.error], "") } + } else { + fleet_converge_apply_receipt_decode(raw: read.content) + } +} diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index d3cb972941b..e2cbbd607ce 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -1,5 +1,7 @@ module gunbc.fleet_converge_workflow +import std.measure { minute_count } + import extdeps.languages.yaml.types { yaml_string, yaml_int, kv } import extdeps.github.actions { Workflow, Job, Step, RunStep, UsesStep, @@ -44,7 +46,8 @@ import gunbc.fleet_workflow_steps { gunbc_ci_prelude_allowance_minutes, gunbc_ci_live_deploy_step_timeout_minutes, gunbc_ci_dashboard_deploy_job_backstop_timeout_minutes, - srv1_dashboard_deploy_concurrency_group, + gunbc_ci_rlm_launch_deployment_receipt_job_backstop_timeout, + fleet_host_mutation_concurrency_group_expression, checked_job_timeout_minutes, JobTimeoutWithinCeiling, JobTimeoutExceedsCeiling, @@ -63,7 +66,19 @@ import gunbc.ci_spec { gunbc_ci_spark_grant_install_invoke, gunbc_fleet_converge_plan_artifact_name, gunbc_ci_fleet_converge_plan_artifact_hash_env_name, + gunbc_ci_live_deploy_transaction_invoke, + gunbc_ci_rlm_launch_deployment_receipt_invoke, + gunbc_live_deploy_receipt_artifact_name, + gunbc_live_deploy_receipt_artifact_dir, + gunbc_fleet_converge_apply_receipt_artifact_name, + gunbc_fleet_converge_apply_receipt_artifact_dir, + gunbc_rlm_launch_deployment_receipt_artifact_name, + gunbc_rlm_launch_deployment_receipt_artifact_dir, } +import gunbc.fleet_converge_receipt { fleet_converge_expected_host_env_name, fleet_converge_plan_run_id_env_name } +import gunbc.actions_run_binding { rlm_expected_revision_env_name } +import gunbc.live_deploy.apply_receipt { rlm_plan_run_id_env_name, rlm_apply_run_id_env_name, rlm_plan_artifact_hash_env_name } +import gunbc.roadmap_launch_deployment_cli { rlm_dashboard_run_id_env_name } import gunbc.fleet_converge_plan { fleet_converge_plan_artifact_dir } import extdeps.languages.yaml.emit { serialize_yaml } import extdeps.languages.yaml.gha_workflow { project_workflow_to_yaml } @@ -71,19 +86,18 @@ import std.types { NonEmptyStr, List, String, Int } // Emitted on-demand fleet converge workflow (operator directive 2026-08-13, sleek-heron-218). // workflow_dispatch with host=srv1|srv2|srv3|srv4 (executor runner pin) and -// mode includes plan, apply, org_actions_observe, and the spark modes: plan observes the RUNNER +// mode includes plan, apply, org_actions_observe, dashboard_deploy, rlm_launch_deployment_receipt, and the spark modes: plan observes the RUNNER // host; apply downloads a prior plan artifact; org_actions_observe validates the separately held // org-admin credential and performs only a read/diff; spark_linger runs executor-on-runner → target-on-spark (srv5|srv6) typed argv over // FleetSsh via run_typed_argv_transport. Replaces the retired 15-minute ctrl-fleet-converge.timer // cadence on every enrolled CI host (timer install route is TimerRetired fleet-wide). Standalone // dispatch carries its own build job (release-bins pack/upload) so the converge job never downloads // an artifact the current run did not produce. - data fleet_converge_enrolled_host_options: List = ["srv1", "srv2", "srv3", "srv4"] data fleet_converge_spark_target_options: List = ["srv5", "srv6"] -data fleet_converge_mode_options: List = ["plan", "allocation_store_plan", "apply", "org_actions_observe", "spark_linger", "spark_observe", "spark_reboot", "spark_grants", "spark_durability", "dashboard_deploy"] +data fleet_converge_mode_options: List = ["plan", "allocation_store_plan", "apply", "org_actions_observe", "spark_linger", "spark_observe", "spark_reboot", "spark_grants", "spark_durability", "dashboard_deploy", "rlm_launch_deployment_receipt"] data fleet_converge_host_runner_expression: String = "${{ fromJSON(format('[\"self-hosted\",\"linux\",\"arm64\",\"{0}\"]', github.event.inputs.host)) }}" @@ -155,6 +169,8 @@ data fleet_converge_spark_grants_step_if: String = "github.event.inputs.mode == data fleet_converge_dashboard_deploy_step_if: String = "github.event.inputs.mode == 'dashboard_deploy'" +data fleet_converge_rlm_receipt_step_if: String = "github.event.inputs.mode == 'rlm_launch_deployment_receipt'" + fn fleet_converge_plan_upload_step() -> Step { UsesStep { name: Present { value: "Upload fleet converge plan artifact" }, @@ -202,7 +218,12 @@ fn fleet_converge_plan_step() -> Step { id: Present { value: "plan" }, run: gunbc_ci_fleet_converge_plan_invoke(), shell: none, - env: none, + env: Present { + value: [ + kv(key: fleet_converge_expected_host_env_name, value: yaml_string(s: "${{ github.event.inputs.host }}")), + kv(key: rlm_expected_revision_env_name, value: yaml_string(s: "${{ github.event.inputs.expected_revision }}")), + ] + }, working_directory: none, if_condition: Present { value: fleet_converge_plan_step_if }, continue_on_error: none, @@ -236,6 +257,8 @@ fn fleet_converge_apply_step() -> Step { key: gunbc_ci_fleet_converge_plan_artifact_hash_env_name, value: yaml_string(s: "${{ github.event.inputs.plan_artifact_hash }}"), ), + kv(key: fleet_converge_plan_run_id_env_name, value: yaml_string(s: "${{ github.event.inputs.plan_workflow_run_id }}")), + kv(key: rlm_expected_revision_env_name, value: yaml_string(s: "${{ github.event.inputs.expected_revision }}")), ] }, working_directory: none, @@ -245,6 +268,31 @@ fn fleet_converge_apply_step() -> Step { } } +// TYPED RECEIPT ARTIFACTS (RLM-2a). Each mutating run of the transaction uploads the receipt it +// minted; the receipt mode downloads the three predecessors by the run ids the operator typed. One +// builder per direction, parameterized by name/path/run-id expression, so the four transfers are +// one shape rather than four spellings. +fn fleet_converge_receipt_upload_step(id: String, name: String, artifact: String, path: String, if_condition: String) -> Step { + UsesStep { + name: Present { value: name }, + id: Present { value: id }, + uses: upload_artifact_action, + with: Present { + value: [ + kv(key: "name", value: yaml_string(s: artifact)), + kv(key: "path", value: yaml_string(s: path)), + kv(key: "if-no-files-found", value: yaml_string(s: "error")), + kv(key: "retention-days", value: yaml_int(n: 90)), + kv(key: "compression-level", value: yaml_int(n: 0)), + ] + }, + env: none, + if_condition: Present { value: if_condition }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_artifact_transfer_step_timeout_minutes } + } +} + fn fleet_converge_org_actions_step() -> Step { RunStep { name: Present { value: "Org Actions credential validation + read-only settings diff" }, @@ -280,8 +328,38 @@ fn fleet_converge_org_actions_receipt_upload_step() -> Step { } } +fn fleet_converge_receipt_download_step(id: String, name: String, artifact: String, path: String, run_id_expression: String) -> Step { + UsesStep { + name: Present { value: name }, + id: Present { value: id }, + uses: download_artifact_action, + with: Present { + value: [ + kv(key: "name", value: yaml_string(s: artifact)), + kv(key: "path", value: yaml_string(s: path)), + kv(key: "github-token", value: yaml_string(s: "${{ secrets.GITHUB_TOKEN }}")), + kv(key: "run-id", value: yaml_string(s: run_id_expression)), + ] + }, + env: none, + if_condition: none, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_artifact_transfer_step_timeout_minutes } + } +} + +fn fleet_converge_apply_receipt_upload_step() -> Step { + fleet_converge_receipt_upload_step( + id: "apply_receipt_upload", + name: "Upload fleet converge apply receipt", + artifact: gunbc_fleet_converge_apply_receipt_artifact_name, + path: gunbc_fleet_converge_apply_receipt_artifact_dir, + if_condition: fleet_converge_apply_step_if, + ) +} + // THE DASHBOARD DEPLOY IS ITS OWN JOB, AND THE REASON IS A REQUIREMENT ITS OWN CARRIER STATES. -// gunbc.fleet_workflow_steps srv1_dashboard_deploy_concurrency_group carries it: two candidate +// gunbc.fleet_workflow_steps fleet_host_mutation_concurrency_group_expression carries it: two candidate // transactions converging ONE instance in place interleave writes to the same tree, binary, unit, // process and readiness subject, so each transaction's readback can observe the other's artifacts. // (An earlier revision of this paragraph gave blue/green slot selection as that reason. Blue/green @@ -306,11 +384,18 @@ fn fleet_converge_org_actions_receipt_upload_step() -> Step { // preflight rather than deploying somewhere unintended. fn fleet_converge_dashboard_deploy_step() -> Step { RunStep { - name: Present { value: "Deploy dashboard to srv1 (live_deploy_apply_srv1_wet)" }, + name: Present { value: "Deploy dashboard to srv1 (live_deploy_apply_srv1_transaction_wet, receipted)" }, id: Present { value: "dashboard_deploy" }, - run: gunbc_ci_live_deploy_apply_srv1_invoke(), + run: gunbc_ci_live_deploy_transaction_invoke(), shell: none, - env: none, + env: Present { + value: [ + kv(key: rlm_expected_revision_env_name, value: yaml_string(s: "${{ github.event.inputs.expected_revision }}")), + kv(key: rlm_plan_run_id_env_name, value: yaml_string(s: "${{ github.event.inputs.plan_workflow_run_id }}")), + kv(key: rlm_apply_run_id_env_name, value: yaml_string(s: "${{ github.event.inputs.apply_workflow_run_id }}")), + kv(key: rlm_plan_artifact_hash_env_name, value: yaml_string(s: "${{ github.event.inputs.plan_artifact_hash }}")), + ] + }, working_directory: none, if_condition: none, continue_on_error: none, @@ -321,7 +406,7 @@ fn fleet_converge_dashboard_deploy_step() -> Step { // QueueNotMax RATHER THAN QueueMax, and the two are not interchangeable here. QueueMax permits many // pending entries, so a burst of dispatches would deploy each queued revision in turn -- installing // revisions already superseded before they were installed. QueueNotMax holds at most one pending and -// REPLACES it with the newest arrival, which is what srv1_dashboard_deploy_concurrency_group's +// REPLACES it with the newest arrival, which is what fleet_host_mutation_concurrency_group_expression's // annotation describes: an older pending deployment gives way to the newest arrival. NOTE the // weaker claim -- "newest arrival", not "newest main revision". While the trigger is // workflow_dispatch over a caller-selected ref, the newest arrival is whatever was dispatched last @@ -340,7 +425,28 @@ fn fleet_converge_dashboard_deploy_job() -> Job { [ ci_release_bins_download_step(), ci_release_bins_unpack_verify_step(), + fleet_converge_receipt_download_step( + id: "dashboard_plan_receipt_download", + name: "Download the plan artifact (carries the plan receipt) from the plan run", + artifact: gunbc_fleet_converge_plan_artifact_name, + path: fleet_converge_plan_artifact_dir, + run_id_expression: "${{ github.event.inputs.plan_workflow_run_id }}", + ), + fleet_converge_receipt_download_step( + id: "dashboard_apply_receipt_download", + name: "Download the fleet apply receipt from the apply run", + artifact: gunbc_fleet_converge_apply_receipt_artifact_name, + path: fleet_converge_apply_receipt_dir, + run_id_expression: "${{ github.event.inputs.apply_workflow_run_id }}", + ), fleet_converge_dashboard_deploy_step(), + fleet_converge_receipt_upload_step( + id: "dashboard_receipt_upload", + name: "Upload live-deploy transaction receipt", + artifact: gunbc_live_deploy_receipt_artifact_name, + path: gunbc_live_deploy_receipt_artifact_dir, + if_condition: "always()", + ), ] ), needs: ["build"], @@ -351,7 +457,7 @@ fn fleet_converge_dashboard_deploy_job() -> Job { continue_on_error: none, concurrency: Present { value: ConcurrencyMappingQueueNotMax { - group: srv1_dashboard_deploy_concurrency_group, + group: fleet_host_mutation_concurrency_group_expression, cancel_in_progress: Present { value: CancelInProgressBool { value: false } }, explicit_single: none } @@ -459,7 +565,6 @@ fn fleet_converge_spark_observe_step() -> Step { // Plain fetch-depth-0 checkout only — this workflow has no workflow_dispatch expected_healed_sha // input (unlike ci.yml heal revalidation). ci_artifact_consumer_prelude_steps paste-through would // reference an undeclared input and emit a dormant preflight guard. - fn fleet_converge_consumer_prelude_steps() -> List { [ci_checkout_step()] } @@ -485,6 +590,7 @@ fn fleet_converge_job() -> Job { fleet_converge_plan_upload_step(), fleet_converge_plan_download_step(), fleet_converge_apply_step(), + fleet_converge_apply_receipt_upload_step(), fleet_converge_org_actions_step(), fleet_converge_org_actions_receipt_upload_step(), fleet_converge_spark_linger_step(), @@ -501,7 +607,113 @@ fn fleet_converge_job() -> Job { if_condition: none, timeout_minutes: Present { value: gunbc_ci_fleet_job_backstop_timeout_minutes() }, continue_on_error: none, - concurrency: none, + concurrency: Present { + value: ConcurrencyMappingQueueNotMax { + group: fleet_host_mutation_concurrency_group_expression, + cancel_in_progress: Present { value: CancelInProgressBool { value: false } }, + explicit_single: none + } + }, + permissions: Present { + value: WorkflowPermissions { + contents: Present { value: PermRead }, + pull_requests: none, + issues: none, + actions: Present { value: PermRead }, + id_token: Present { value: PermWrite }, + } + } + } +} + +// THE RLM-2 RECEIPT MODE (RLM-2a deliverable 10): the fourth run of the transaction. Its own job +// for the same reason the dashboard deploy is -- it must sit in the host mutation concurrency +// domain so it never reads a host mid-mutation -- and it carries the WIF/key-agent prelude the +// converge job carries, because the deployed-tree readback reaches srv1 through the sealed FleetSsh +// locus. It downloads the three predecessor receipts by the run ids the operator typed and uploads +// its own receipt with always(), so an Incomplete verdict is preserved as evidence. +fn fleet_converge_rlm_receipt_step() -> Step { + RunStep { + name: Present { value: "Roadmap launch deployment receipt (rlm_launch_deployment_receipt_wet)" }, + id: Present { value: "rlm_receipt" }, + run: gunbc_ci_rlm_launch_deployment_receipt_invoke(), + shell: none, + env: Present { + value: [ + kv(key: rlm_expected_revision_env_name, value: yaml_string(s: "${{ github.event.inputs.expected_revision }}")), + kv(key: rlm_plan_run_id_env_name, value: yaml_string(s: "${{ github.event.inputs.plan_workflow_run_id }}")), + kv(key: rlm_apply_run_id_env_name, value: yaml_string(s: "${{ github.event.inputs.apply_workflow_run_id }}")), + kv(key: rlm_dashboard_run_id_env_name, value: yaml_string(s: "${{ github.event.inputs.dashboard_workflow_run_id }}")), + ] + }, + working_directory: none, + if_condition: none, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_aux_step_timeout_minutes } + } +} + +fn fleet_converge_rlm_launch_deployment_receipt_job() -> Job { + Job { + id: "rlm-launch-deployment-receipt", + name: none, + runner: fleet_converge_host_pinned_runner_spec(), + steps: concat( + concat( + fleet_converge_consumer_prelude_steps(), + [ + ci_release_bins_download_step(), + ci_release_bins_unpack_verify_step(), + ci_fleet_wif_auth_step(), + ci_fleet_key_agent_step(), + ] + ), + [ + fleet_converge_receipt_download_step( + id: "rlm_plan_download", + name: "Download fleet converge plan artifact (plan run)", + artifact: gunbc_fleet_converge_plan_artifact_name, + path: fleet_converge_plan_artifact_dir, + run_id_expression: "${{ github.event.inputs.plan_workflow_run_id }}", + ), + fleet_converge_receipt_download_step( + id: "rlm_apply_download", + name: "Download fleet converge apply receipt (apply run)", + artifact: gunbc_fleet_converge_apply_receipt_artifact_name, + path: gunbc_fleet_converge_apply_receipt_artifact_dir, + run_id_expression: "${{ github.event.inputs.apply_workflow_run_id }}", + ), + fleet_converge_receipt_download_step( + id: "rlm_dashboard_download", + name: "Download live-deploy transaction receipt (dashboard run)", + artifact: gunbc_live_deploy_receipt_artifact_name, + path: gunbc_live_deploy_receipt_artifact_dir, + run_id_expression: "${{ github.event.inputs.dashboard_workflow_run_id }}", + ), + fleet_converge_rlm_receipt_step(), + fleet_converge_receipt_upload_step( + id: "rlm_receipt_upload", + name: "Upload roadmap launch deployment receipt", + artifact: gunbc_rlm_launch_deployment_receipt_artifact_name, + path: gunbc_rlm_launch_deployment_receipt_artifact_dir, + if_condition: "always()", + ), + ci_fleet_key_agent_cleanup_step(), + ] + ), + needs: ["build"], + env: none, + outputs: none, + if_condition: Present { value: fleet_converge_rlm_receipt_step_if }, + timeout_minutes: Present { value: minute_count(gunbc_ci_rlm_launch_deployment_receipt_job_backstop_timeout()) }, + continue_on_error: none, + concurrency: Present { + value: ConcurrencyMappingQueueNotMax { + group: fleet_host_mutation_concurrency_group_expression, + cancel_in_progress: Present { value: CancelInProgressBool { value: false } }, + explicit_single: none + } + }, permissions: Present { value: WorkflowPermissions { contents: Present { value: PermRead }, @@ -516,6 +728,7 @@ fn fleet_converge_job() -> Job { data fleet_converge_workflow: Workflow = Workflow { name: "fleet-converge", + run_name: Present { value: "fleet-converge ${{ inputs.mode }} ${{ inputs.host }} nonce=${{ inputs.transaction_nonce }}" }, on: [ WorkflowDispatch { inputs: [ @@ -549,7 +762,35 @@ data fleet_converge_workflow: Workflow = Workflow { }, DispatchInput { name: "plan_workflow_run_id", - description: Present { value: "Workflow run id that produced the plan artifact — required for apply" }, + description: Present { value: "Workflow run id that produced the plan artifact — required for apply, dashboard_deploy and rlm_launch_deployment_receipt" }, + required: false, + default: none, + type: InputString, + }, + DispatchInput { + name: "expected_revision", + description: Present { value: "R — the exact main revision this transaction installs and proves; every run of the transaction must execute at it (RLM_EXPECTED_REVISION). Required for dashboard_deploy and rlm_launch_deployment_receipt" }, + required: false, + default: none, + type: InputString, + }, + DispatchInput { + name: "apply_workflow_run_id", + description: Present { value: "Workflow run id of the fleet apply — required for dashboard_deploy and rlm_launch_deployment_receipt" }, + required: false, + default: none, + type: InputString, + }, + DispatchInput { + name: "dashboard_workflow_run_id", + description: Present { value: "Workflow run id of the dashboard deploy — required for rlm_launch_deployment_receipt" }, + required: false, + default: none, + type: InputString, + }, + DispatchInput { + name: "transaction_nonce", + description: Present { value: "Operator-minted unique nonce for one RLM transaction; echoed into run-name so each dispatched run is API-selectable by its displayTitle, closing the run-id correlation gap (review 5062738052 B4). Not consumed by any step" }, required: false, default: none, type: InputString, @@ -560,7 +801,7 @@ data fleet_converge_workflow: Workflow = Workflow { concurrency: none, env: none, permissions: none, - jobs: [fleet_converge_build_job(), fleet_converge_job(), fleet_converge_dashboard_deploy_job()], + jobs: [fleet_converge_build_job(), fleet_converge_job(), fleet_converge_dashboard_deploy_job(), fleet_converge_rlm_launch_deployment_receipt_job()], } type FleetConvergeYamlGenerationOutcome diff --git a/dag/gunbc/fleet/fleet_desired_admission_workflow.dag b/dag/gunbc/fleet/fleet_desired_admission_workflow.dag index 41c7fa7ce32..6241cfe617b 100644 --- a/dag/gunbc/fleet/fleet_desired_admission_workflow.dag +++ b/dag/gunbc/fleet/fleet_desired_admission_workflow.dag @@ -370,6 +370,7 @@ fn fleet_desired_admission_job() -> Job { // than this change. Declared here rather than left to be discovered. data fleet_desired_admission_workflow: Workflow = Workflow { name: "fleet-desired", + run_name: none, on: [ WorkflowRunCompleted { workflows: [witness_floor_workflow_name], diff --git a/dag/gunbc/fleet/fleet_workflow_steps.dag b/dag/gunbc/fleet/fleet_workflow_steps.dag index 463f4cb5ea1..b78f67dfc69 100644 --- a/dag/gunbc/fleet/fleet_workflow_steps.dag +++ b/dag/gunbc/fleet/fleet_workflow_steps.dag @@ -17,7 +17,6 @@ module gunbc.fleet_workflow_steps // constructors, because its build is one cargo invocation with no artifact handoff, and reusing // a cross-job artifact-transfer vocabulary to express that would be borrowing an architecture it // does not have. - import std.measure { minute_count } import std.types { Duration } import extdeps.languages.yaml.types { yaml_string, yaml_int, yaml_bool, kv } @@ -426,7 +425,6 @@ data ci_native_cache_derive_step_name: String = "Derive native-cache root (rustc // regresses. A hand-rolled match on Step would be the structural alternative; non-fold-residue // forbids the wildcard arms that predicate would need (receipt: nfr floor on 14bc815db4). Dissolves // when prelude ordering is witnessed without coproduct-residue match arms. - data ci_release_bins_artifact_name: String = "release-bins" data gunbc_ci_aux_step_timeout_minutes: Duration = 5 @@ -452,14 +450,12 @@ data gunbc_ci_v1_compiler_tests_compile_gate_timeout_minutes: Duration = 15 // retained modules / 30 tests, which execute rather than only compile. Warm runs should complete in // seconds once the release artifact is warm; 15m retains the prior cold-host envelope without // raising the cap. - data gunbc_ci_stage0_partition_compile_gate_timeout_minutes: Duration = 15 // stage0 partition compile gate step budget (sharp-fox-403 C1): seven sequential cargo check -p // v1-stage0-* partition crates enrolled as the build-job compile backstop over // stage0_partition_crate_rows. Cold-host envelope matches v1-compiler-tests compile gate until a // dedicated receipt exists; warm partition crates are smaller than the monolith test crate. - data gunbc_ci_v1_compiler_test_targets_compile_gate_timeout_minutes: Duration = 10 // v1-compiler test-targets compile gate step budget. 10 minutes against 67s MEASURED for this @@ -474,7 +470,6 @@ data gunbc_ci_v1_compiler_test_targets_compile_gate_timeout_minutes: Duration = // same reason the retained-kernel gate does — v1-compiler is already compiled in release with the // shared feature set, so the check reuses that artifact instead of forcing a second compile. // Raising this needs a measurement, not a round number. - data gunbc_ci_artifact_transfer_step_timeout_minutes: Duration = 10 // Cross-job handoff of the release bins (operator job-split ruling 2026-07-11): the build job packs @@ -484,7 +479,6 @@ data gunbc_ci_artifact_transfer_step_timeout_minutes: Duration = 10 // (fail-closed: a missing or truncated artifact reds the job at the verify, never as a mid-floor // bin fallback). 10m is the transfer envelope for a ~200MB archive under fleet pressure; upload // uses if-no-files-found: error so an empty pack is loud. - data gunbc_ci_prelude_allowance_minutes: Duration = 5 data gunbc_ci_release_build_step_timeout_minutes: Duration = 45 @@ -530,7 +524,28 @@ data gunbc_ci_live_deploy_step_timeout_minutes: Duration = 30 // lease exists and is witnessed, and this workflow does not yet acquire it -- binding it into the // mutating ingresses is a separate change, so the bypass is closable rather than closed. Recorded here rather than left implicit because a concurrency group is exactly the kind // of artifact a later reader cites as the serialization authority. -data srv1_dashboard_deploy_concurrency_group: String = "srv1-dashboard-deploy" +// ONE MUTATION DOMAIN PER HOST, SHARED BY EVERY JOB THAT CONVERGES OR READS BACK THAT HOST (RLM-2 +// ruling section 9). The group used to be the literal "srv1-dashboard-deploy" and applied to the +// dashboard job alone, so a fleet apply and a dashboard deploy could interleave on srv1, and a +// final readback could observe a host mid-mutation. The group is now derived from the `host` +// input, so plan/apply, dashboard deploy and the launch-deployment receipt of ONE host queue behind +// each other while different hosts stay independent. Expression-valued because the host is a +// dispatch input, not a constant of the workflow. +data fleet_host_mutation_concurrency_group_expression: String = "gunbc-host-mutation-${{ github.event.inputs.host }}" + +// The Int->Nat crossing is admitted at a function RETURN position (std.checked_arithmetic's own +// note), so the sum of the positive per-step allowances crosses here and the public fn carries the +// std.measure Minute duration rather than a bare scalar (review 57760 finding 1). +fn gunbc_ci_rlm_launch_deployment_receipt_job_backstop_minute_count() -> Nat { + gunbc_ci_prelude_allowance_minutes + + (4 * gunbc_ci_artifact_transfer_step_timeout_minutes) + + (5 * gunbc_ci_aux_step_timeout_minutes) + + gunbc_ci_artifact_transfer_step_timeout_minutes +} + +fn gunbc_ci_rlm_launch_deployment_receipt_job_backstop_timeout() -> Minute { + minute(count: gunbc_ci_rlm_launch_deployment_receipt_job_backstop_minute_count()) +} fn gunbc_ci_build_job_backstop_timeout_minutes() -> Int { gunbc_ci_aux_step_timeout_minutes + gunbc_ci_v1_compiler_tests_compile_gate_timeout_minutes + gunbc_ci_stage0_partition_compile_gate_timeout_minutes + gunbc_ci_v1_compiler_test_targets_compile_gate_timeout_minutes + gunbc_ci_release_build_step_timeout_minutes + gunbc_ci_aux_step_timeout_minutes + gunbc_ci_artifact_transfer_step_timeout_minutes + gunbc_ci_prelude_allowance_minutes @@ -544,8 +559,51 @@ data gunbc_ci_fleet_job_worst_case_mode_aux_step_count: Int = 1 data gunbc_ci_fleet_job_worst_case_mode_spark_step_count: Int = 1 -data gunbc_ci_fleet_job_backstop_timeout_note: String = "SUPERSEDES the 2026-08-21 first correction (prelude + 7*aux + 3*transfer + 5*spark = 370m), which fixed the tier miscount in the prior 11*aux+2*spark formula but kept its structural error: summing every tier-step COUNT as though every step in fleet_converge_job() executes in one run. It does not. The job's steps are gated by the single-select `mode` DispatchInput (InputChoice, exactly one value per dispatch — fleet_converge_workflow.dag DispatchInput \"mode\"), so plan/apply/spark_linger/spark_grants/spark_observe/spark_reboot/spark_durability are MUTUALLY EXCLUSIVE per run, not concurrent tiers to sum. Recounted from fleet_converge_job()'s actual step list (fleet_converge_workflow.dag:280-303) by what a single dispatch can actually execute: ALWAYS-RUN, no if_condition or if_condition: always() — ci_release_bins_download_step (transfer), ci_release_bins_unpack_verify_step, ci_fleet_wif_auth_step, ci_fleet_key_agent_step, ci_spark_admin_credential_cleanup_step, ci_fleet_key_agent_cleanup_step (5 aux + 1 transfer, always). MODE-GATED, at most one bundle applies per dispatch: mode=plan adds fleet_converge_plan_step(aux)+fleet_converge_plan_upload_step(transfer)=15m; mode=apply adds fleet_converge_plan_download_step(transfer)+fleet_converge_apply_step(aux)=15m; mode=spark_linger/spark_observe/spark_reboot/spark_durability each add exactly one spark step=60m; mode=spark_grants adds ci_spark_admin_credential_step(aux)+fleet_converge_spark_grants_step(spark)=65m, the WORST CASE because it is the only mode combining an aux-tier step with a spark-tier step. Worst-case single-run total: 5 + (5+1)*5 + 1*10 + 1*60 = 105m, comfortably inside extdeps.github.actions max_job_timeout_minutes (360m) — the >360 conflict raised on review of the first correction (bright-ferret-335, gunbc#8782) dissolves under the corrected model rather than needing a rounded-down number or a job split; checked_job_timeout_minutes below still guards the class for any future per-tier raise. The mode roster shrank on 2026-08-27 when the session-container lane was deleted; session_demand_observe was one aux-tier-shaped step under a spark-tier budget, strictly below the spark_grants worst case, so removing it does not move this figure either -- the value derives from the always-run counts plus that worst case and never from the roster size. These counts remain hand-authored cardinals against fleet_converge_job()'s step list (fleet_workflow_steps cannot import fleet_converge_workflow without a cycle), so this note is the single authority to update alongside any change to that job's step composition, if_condition gating, or mode set. UPDATED for the dashboard_deploy mode, which was added after that deletion: THIS job's bound is unchanged, and the reason is the load-bearing part rather than the arithmetic. dashboard_deploy does not add a step to fleet_converge_job() at all -- it is a separate job (fleet_converge_workflow fleet_converge_dashboard_deploy_job), because srv1_dashboard_deploy_concurrency_group requires JOB-GRAIN concurrency that a step inside this job could not carry. So the mode is gated by that job's own if_condition and budgeted by its own backstop (gunbc_ci_dashboard_deploy_job_backstop_timeout_minutes, a straight sum because every step in it runs on every dispatch of it), and the 105m worst case here still belongs to spark_grants. A future reader checking whether a new mode moved this bound should ask FIRST whether the mode added a step to this job: adding a mode and adding a step to this job are different events, and only the second one moves this number." - +// SUPERSEDES the 2026-08-21 first correction (prelude + 7*aux + 3*transfer + 5*spark = 370m), which +// fixed the tier miscount in the prior 11*aux+2*spark formula but kept its structural error: +// summing every tier-step COUNT as though every step in fleet_converge_job() executes in one run. +// It does not. The job's steps are gated by the single-select `mode` DispatchInput (InputChoice, +// exactly one value per dispatch — fleet_converge_workflow.dag DispatchInput "mode"), so +// plan/apply/spark_linger/spark_grants/spark_observe/spark_reboot/spark_durability are MUTUALLY +// EXCLUSIVE per run, not concurrent tiers to sum. Recounted from fleet_converge_job()'s actual step +// list (gunbc.fleet_converge_workflow fleet_converge_job) by what a single dispatch can actually execute: +// ALWAYS-RUN, no if_condition or if_condition: always() — ci_release_bins_download_step (transfer), +// ci_release_bins_unpack_verify_step, ci_fleet_wif_auth_step, ci_fleet_key_agent_step, +// ci_spark_admin_credential_cleanup_step, ci_fleet_key_agent_cleanup_step (5 aux + 1 transfer, +// always). MODE-GATED, at most one bundle applies per dispatch: mode=plan adds +// fleet_converge_plan_step(aux)+fleet_converge_plan_upload_step(transfer)=15m; mode=apply adds +// fleet_converge_plan_download_step(transfer)+fleet_converge_apply_step(aux)+fleet_converge_apply_receipt_upload_step(transfer)=25m +// (the typed apply receipt upload, RLM-2a); +// mode=spark_linger/spark_observe/spark_reboot/spark_durability each add exactly one spark +// step=60m; mode=spark_grants adds +// ci_spark_admin_credential_step(aux)+fleet_converge_spark_grants_step(spark)=65m, the WORST CASE +// because it is the only mode combining an aux-tier step with a spark-tier step. Worst-case +// single-run total: 5 + (5+1)*5 + 1*10 + 1*60 = 105m, comfortably inside extdeps.github.actions +// max_job_timeout_minutes (360m) — the >360 conflict raised on review of the first correction +// (bright-ferret-335, gunbc#8782) dissolves under the corrected model rather than needing a +// rounded-down number or a job split; checked_job_timeout_minutes below still guards the class for +// any future per-tier raise. The mode roster shrank on 2026-08-27 when the session-container lane +// was deleted; session_demand_observe was one aux-tier-shaped step under a spark-tier budget, +// strictly below the spark_grants worst case, so removing it does not move this figure either -- +// the value derives from the always-run counts plus that worst case and never from the roster size. +// These counts remain hand-authored cardinals against fleet_converge_job()'s step list +// (fleet_workflow_steps cannot import fleet_converge_workflow without a cycle), so this note is the +// single authority to update alongside any change to that job's step composition, if_condition +// gating, or mode set. UPDATED for the dashboard_deploy mode, which was added after that deletion: +// THIS job's bound is unchanged, and the reason is the load-bearing part rather than the +// arithmetic. dashboard_deploy does not add a step to fleet_converge_job() at all -- it is a +// separate job (fleet_converge_workflow fleet_converge_dashboard_deploy_job), because +// fleet_host_mutation_concurrency_group_expression requires JOB-GRAIN concurrency that a step +// inside this job could not carry. So the mode is gated by that job's own if_condition and budgeted +// by its own backstop (gunbc_ci_dashboard_deploy_job_backstop_timeout_minutes, a straight sum +// because every step in it runs on every dispatch of it), and the 105m worst case here still +// belongs to spark_grants. A future reader checking whether a new mode moved this bound should ask +// FIRST whether the mode added a step to this job: adding a mode and adding a step to this job are +// different events, and only the second one moves this number. The rlm_launch_deployment_receipt +// mode (RLM-2a) is, like dashboard_deploy, its OWN job (fleet_converge_workflow +// fleet_converge_rlm_launch_deployment_receipt_job) with its own backstop +// (gunbc_ci_rlm_launch_deployment_receipt_job_backstop_timeout_minutes), so it does not move this +// figure either. type JobTimeoutBudget = JobTimeoutWithinCeiling { minutes: Int } | JobTimeoutExceedsCeiling { minutes: Int, ceiling: Int } @@ -558,7 +616,6 @@ type JobTimeoutBudget // any future per-tier raise, not evidence the current backstop is at risk: see // gunbc_ci_fleet_job_backstop_timeout_note for why the honest worst-case-single-run total sits well // under the ceiling today. - fn checked_job_timeout_minutes(computed: Int) -> JobTimeoutBudget { if computed <= max_job_timeout_minutes { JobTimeoutWithinCeiling { minutes: computed } diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index c2f7c49f692..acc0c1abd98 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -13,7 +13,7 @@ import gunbc.live_deploy.spec { deployment_spec_srv1, deployment_plan_listen_port, } -import std.types { String, CommitSha } +import std.types { String, CommitSha, NonEmptyStr } import gunbc.live_deploy.candidate { CandidateRelease, CandidateObservation, @@ -57,7 +57,7 @@ import gunbc.host_effect { } import gunbc.host_effect_realize { host_effect_apply_gated } import gunbc.live_deploy.readiness { - live_deploy_poll_until_service_ready_for_deploy, + live_deploy_poll_until_service_ready_for_deploy, live_deploy_healthz_get_for_port, running_release_unidentified_detail, ServiceReadyApplyEffect, ServiceReadyEvidence, } @@ -71,7 +71,37 @@ import gunbc.live_deploy.target_decision { } import std.realization_reconcile { Reconciliation, reconciliation_converged, Converged, NotConverged } import extdeps.filesystem.filesystem_io { Filesystem } -import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.process { ProcessExit, ExitSuccess, ExitFailure, exit_failure } +import extdeps.exec.command { LocalExec } +import extdeps.tools.mkdir { mkdir_parents_command } +import gunbc.command_runner { run_shell_commands } +import gunbc.clock_read { clock_now_probed_at_or_unknown } +import extdeps.languages.json.emit { serialize_json } +import gunbc.actions_run_binding { + ActionsRunBinding, observe_actions_run_binding, ActionsRunBound, ActionsRunUnbound, + read_expected_revision, ExpectedRevisionPresent, ExpectedRevisionAbsent, + admit_run_revision, RunRevisionAdmitted, RunRevisionRefused, +} +import gunbc.fleet_converge_receipt { + read_fleet_converge_plan_receipt_at, FleetConvergePlanReceiptDecoded, FleetConvergePlanReceiptUnreadable, + read_fleet_converge_apply_receipt_at, FleetConvergeApplyReceiptDecoded, FleetConvergeApplyReceiptUnreadable, + fleet_converge_plan_receipt_identity_hex, fleet_converge_apply_receipt_identity_hex, + fleet_converge_plan_receipt_path, fleet_converge_apply_receipt_path, +} +import gunbc.live_deploy.apply_receipt { + LiveDeployApplyReceipt, LiveDeployFleetProvenance, + LiveDeployTargetDecisionLabel, LiveDeployApplyOutcome, LiveDeployReadinessOutcome, + read_live_deploy_fleet_provenance, FleetProvenancePresent, FleetProvenanceAbsent, LiveDeployFleetProvenance, + DecisionProceed, DecisionTerminalNoOp, DecisionRefused, + LiveDeployApplyConverged, LiveDeployApplyNotConverged, + LiveDeployReadinessObserved, LiveDeployReadinessRefused, + observe_candidate_root_tree_oid, CandidateTreeOidObserved, CandidateTreeOidUnobservable, + live_deploy_apply_receipt_json, live_deploy_apply_receipt_dir, live_deploy_apply_receipt_artifact_path, +} +import gunbc.roadmap_site_surface_types { HealthzBodyRead, HealthzProbeRefused } +import gunbc.running_release_identity { + observe_running_release_identity, RunningReleaseIdentified, RunningReleaseUnidentified, +} data single_deployment_note: String = "ONE DEPLOYMENT, ONE PORT. Blue/green was deleted at the root on 2026-08-19 by operator directive, and the reason is the one its own predecessor note recorded against itself: blue/green is sound for a STATELESS server, and this dashboard is stateful. The compromise it reached -- the slot owns source, binary, unit and port while dispatch worktrees, attempt evidence and the tmux server stay host-shared -- is a two-copy deployment that is only half two copies, and it cost more to reason about than the zero-downtime swap was worth for a single-operator dashboard.\n\nWHAT WENT, AS ONE POPULATION: the slot selection coproduct and its Tailscale-status read, the deploy-idle-then-flip-then-retire fold, the slot/route/retiring spec projections, and the second HostDashboardInstance. What replaces them is this: apply converges deployment_spec_srv1 IN PLACE, members in declared order, and the Tailscale mapping is an ordinary member pointing at the one port rather than a route flipped between two.\n\nWHAT IS HONESTLY LOST is the property blue/green existed to buy: a failed candidate can no longer be discovered on an idle slot while the old process keeps serving. A bad deploy now restarts the one service, and the readiness gate refuses AFTER the restart rather than before a flip -- so the failure mode is downtime, not a silent bad cutover. That trade is the point of the directive, not an oversight, and the mitigation is that every member is idempotent and a retract remains available.\n\nTHE HAZARD THIS ALSO CLOSES: two slots meant two belt timers, both enabled, sharing one dispatch-worktree root and one attempt-state root. Nothing prevented a collision except that both belts happened to be crashing; with one deployment there is one belt and the prepare/activate split it would have needed is no longer owed.\n\nA SECOND ORDERING CONSEQUENCE, named because the loss above is not the only one. Under the deleted cutover fold the route write ran AFTER the candidate slot converged, readiness gate included. The mapping is now an ordinary member, last in deployment_owned_steps and inside the mutation, so the route is asserted BEFORE live_deploy_apply_readiness_gate runs -- which is, in shape, the pre-blue/green ordering that blue_green_spec_partition_note recorded as having recreated a 502 window. It is not that defect here, because with one port the write is an idempotent re-assert of the same endpoint-to-8080 mapping rather than a redirection to an unverified process, so it opens no window beyond the restart downtime already accepted above. The residual is bounded by the realization: if the Tailscale mapping is ever applied as remove-then-add rather than as a replace, the route is briefly ABSENT while the service is unverified. That is a property of extdeps.tailscale.serve, not of this fold, and it is stated here so the ordering is a known quantity rather than something a later reader rediscovers as a surprise." @@ -348,7 +378,6 @@ fn live_deploy_apply_srv1_with_access( // condition with no bearing whatsoever on tearing down a unit.\n\nThe fold now passes // RevisionNotInstalled, so the arm that would render an ExecStart refuses loudly instead of naming // a revision this operation never had. - fn live_deploy_retract_srv1_with_access( access: DeployAccess, ) -> Reconciliation { @@ -528,3 +557,199 @@ func live_deploy_retract_srv1_wet() -> ProcessExit { func live_deploy_retract_srv1_operator_wet() -> ProcessExit { live_deploy_retract_wet_with_access(access: ci_deploy_srv1_operator_access) } + + +// ---------------------------------------------------------------- the receipted transaction entry +// THE DASHBOARD-DEPLOY MODE OF THE RLM-2 TRANSACTION. live_deploy_apply_srv1_wet stays as the +// operator's plain entry; this one is what the fleet-converge workflow's dashboard_deploy mode +// invokes, and it differs in exactly three ways, all of them additions around the SAME fold: (1) it +// binds its run and refuses unless GITHUB_SHA equals RLM_EXPECTED_REVISION and the observed +// candidate equals it too -- a deploy asked to install R must not install what it happens to have +// checked out; (2) it reads the fleet plan/apply provenance the dispatch named (RLM_PLAN_RUN_ID, +// RLM_APPLY_RUN_ID, RLM_PLAN_ARTIFACT_HASH) and carries it as provenance, never as eligibility; (3) +// it mints gunbc.live_deploy.apply_receipt LiveDeployApplyReceipt after the fold returns, with one +// independent post-apply /healthz observation and a completion instant, and writes it to the +// artifact path the workflow uploads. A TerminalNoOp target decision is recorded as such and exits +// nonzero HERE, unlike the plain entry: within the transaction, 'R was already running' means this +// run installed nothing, and the join must see that rather than a converged-looking receipt. +fn live_deploy_transaction_decision_label(decision: DeployTargetDecision) -> LiveDeployTargetDecisionLabel { + match decision { + DeployTargetProceed { note: _ } => DecisionProceed + DeployTargetTerminalNoOp { note: _ } => DecisionTerminalNoOp + DeployTargetRefused { cause: _ } => DecisionRefused + } +} + +fn live_deploy_post_apply_readiness(access: DeployAccess) -> LiveDeployReadinessOutcome { + match live_deploy_healthz_get_for_port(port: deployment_plan_listen_port(spec: deployment_spec_srv1()), transport: access.transport) { + HealthzProbeRefused { probe_error: e } => LiveDeployReadinessRefused { detail: e } + HealthzBodyRead { body: b } => + match observe_running_release_identity(healthz_body: b) { + RunningReleaseIdentified { observation: o } => + LiveDeployReadinessObserved { revision: o.revision as String, surface: o.surface_bundle_identity.digest as String } + RunningReleaseUnidentified { cause: c } => LiveDeployReadinessRefused { detail: running_release_unidentified_detail(cause: c) } + } + } +} + +fn live_deploy_write_transaction_receipt(receipt: LiveDeployApplyReceipt, exit: ProcessExit) -> ProcessExit { + match run_shell_commands(commands: [mkdir_parents_command(path: live_deploy_apply_receipt_dir)], transport: LocalExec) { + ExitFailure { code: _, reason: why } => exit_failure(reason: join(["live_deploy transaction: receipt dir refused: ", why], "")) + ExitSuccess => { + let wrote = Filesystem.Write(path: live_deploy_apply_receipt_artifact_path, content: serialize_json(v: live_deploy_apply_receipt_json(r: receipt))) + if !wrote.success { + exit_failure(reason: join(["live_deploy transaction: receipt write refused at ", live_deploy_apply_receipt_artifact_path, ": ", wrote.error], "")) + } else { + exit + } + } + } +} + +fn live_deploy_transaction_receipt_for( + run: ActionsRunBinding, + expected: String, + candidate: CandidateRelease, + candidate_tree: String, + decision: DeployTargetDecision, + apply_outcome: LiveDeployApplyOutcome, + readiness: LiveDeployReadinessOutcome, + provenance: LiveDeployFleetProvenance, +) -> LiveDeployApplyReceipt { + LiveDeployApplyReceipt { + run: run, + target: srv1_dashboard_target_name, + expected_revision: expected, + candidate_revision: candidate.revision as String, + candidate_surface_identity: candidate.expected_surface_identity.digest as String, + candidate_tree_oid: candidate_tree, + target_decision: live_deploy_transaction_decision_label(decision: decision), + apply_outcome: apply_outcome, + readiness: readiness, + provenance: provenance, + completed_at: clock_now_probed_at_or_unknown() as String, + } +} + +data srv1_dashboard_target_name: String = "srv1" + +fn live_deploy_transaction_decided( + access: DeployAccess, + run: ActionsRunBinding, + expected: String, + candidate: CandidateRelease, + candidate_tree: String, + decision: DeployTargetDecision, + provenance: LiveDeployFleetProvenance, +) -> ProcessExit { + match decision { + DeployTargetRefused { cause: _ } => + live_deploy_write_transaction_receipt( + receipt: live_deploy_transaction_receipt_for(run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, decision: decision, apply_outcome: LiveDeployApplyNotConverged { reason: deploy_target_decision_line(decision: decision) }, readiness: LiveDeployReadinessRefused { detail: "not observed: target decision refused" }, provenance: provenance), + exit: exit_failure(reason: deploy_target_decision_line(decision: decision)), + ) + DeployTargetTerminalNoOp { note: _ } => + live_deploy_write_transaction_receipt( + receipt: live_deploy_transaction_receipt_for(run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, decision: decision, apply_outcome: LiveDeployApplyNotConverged { reason: "terminal no-op: this run installed nothing" }, readiness: live_deploy_post_apply_readiness(access: access), provenance: provenance), + exit: exit_failure(reason: join(["live_deploy transaction: TerminalNoOp — ", deploy_target_decision_line(decision: decision), "; within the RLM-2 transaction a deploy that installs nothing is not a completed dashboard deployment"], "")), + ) + DeployTargetProceed { note: _ } => { + let preflight = live_deploy_access_preflight_for(access: access) + if !reconciliation_converged(r: preflight) { + live_deploy_write_transaction_receipt( + receipt: live_deploy_transaction_receipt_for(run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, decision: decision, apply_outcome: LiveDeployApplyNotConverged { reason: (match preflight { NotConverged { reason: why, applied: _ } => why Converged { evidence: _, applied: _ } => "preflight" }) }, readiness: LiveDeployReadinessRefused { detail: "not observed: preflight refused" }, provenance: provenance), + exit: live_deploy_reconciliation_exit(r: preflight), + ) + } else { + let applied = live_deploy_apply_srv1_with_access(access: access, candidate: candidate) + let outcome = match applied { + Converged { evidence: _, applied: _ } => LiveDeployApplyConverged + NotConverged { reason: why, applied: _ } => LiveDeployApplyNotConverged { reason: why } + } + live_deploy_write_transaction_receipt( + receipt: live_deploy_transaction_receipt_for(run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, decision: decision, apply_outcome: outcome, readiness: live_deploy_post_apply_readiness(access: access), provenance: provenance), + exit: live_deploy_reconciliation_exit(r: applied), + ) + } + } + } +} + +fn live_deploy_transaction_admitted(access: DeployAccess, run: ActionsRunBinding, expected: String, provenance: LiveDeployFleetProvenance) -> ProcessExit { + match observe_candidate_release() { + CandidateUnobservable { cause: why } => + exit_failure(reason: candidate_unobservable_detail(cause: why)) + CandidateObserved { candidate: candidate } => + if (candidate.revision as String) != expected { + exit_failure(reason: join(["live_deploy transaction: CandidateNotExpected — the checkout is ", candidate.revision as String, " but R is ", expected], "")) + } else { + match observe_candidate_root_tree_oid(repo: ".") { + CandidateTreeOidUnobservable { detail: d } => + exit_failure(reason: join(["live_deploy transaction: CandidateTreeUnobservable — ", d], "")) + CandidateTreeOidObserved { oid_hex: candidate_tree } => + live_deploy_transaction_decided( + access: access, + run: run, + expected: expected, + candidate: candidate, + candidate_tree: candidate_tree, + decision: observe_deploy_target( + spec: deployment_spec_srv1(), + transport: access.transport, + candidate: candidate.revision, + ), + provenance: provenance, + ) + } + } + } +} + +fn live_deploy_transaction_wet_with_access(access: DeployAccess) -> ProcessExit { + match observe_actions_run_binding() { + ActionsRunUnbound { variable: v, detail: d } => + exit_failure(reason: join(["live_deploy transaction: RunUnbound — ", v, " ", d, "; the transaction entry runs only inside the fleet-converge workflow"], "")) + ActionsRunBound { binding: run } => + match read_expected_revision() { + ExpectedRevisionAbsent { variable: v, detail: d } => + exit_failure(reason: join(["live_deploy transaction: ExpectedRevisionAbsent — ", v, " ", d], "")) + ExpectedRevisionPresent { revision: expected } => + match admit_run_revision(binding: run, expected: expected) { + RunRevisionRefused { expected: e, observed: o } => + exit_failure(reason: join(["live_deploy transaction: RunRevisionNotExpected — this run executes ", o, " but R is ", e], "")) + RunRevisionAdmitted { revision: _ } => + match read_live_deploy_fleet_provenance() { + FleetProvenanceAbsent { variable: v, detail: d } => + exit_failure(reason: join(["live_deploy transaction: FleetProvenanceAbsent — ", v, " ", d], "")) + FleetProvenancePresent { inputs: inputs } => + match read_fleet_converge_plan_receipt_at(path: fleet_converge_plan_receipt_path) { + FleetConvergePlanReceiptUnreadable { reason: why } => + exit_failure(reason: join(["live_deploy transaction: PlanReceiptUnreadable — ", why, "; the dashboard transaction refuses to run without the predecessor receipts it must cite"], "")) + FleetConvergePlanReceiptDecoded { receipt: plan_receipt } => + match read_fleet_converge_apply_receipt_at(path: fleet_converge_apply_receipt_path) { + FleetConvergeApplyReceiptUnreadable { reason: why } => + exit_failure(reason: join(["live_deploy transaction: FleetApplyReceiptUnreadable — ", why], "")) + FleetConvergeApplyReceiptDecoded { receipt: fleet_apply_receipt } => { + let provenance = LiveDeployFleetProvenance { + plan_run_id: inputs.plan_run_id, + apply_run_id: inputs.apply_run_id, + plan_artifact_hash: inputs.plan_artifact_hash, + plan_receipt_identity: fleet_converge_plan_receipt_identity_hex(r: plan_receipt), + apply_receipt_identity: fleet_converge_apply_receipt_identity_hex(r: fleet_apply_receipt), + } + match repository_transition_admission(realization: deployed_tree_repository_transition) { + LegacyGitFileSyncNotAdmitted { reason: why } => exit_failure(reason: why) + RepositoryTransitionAdmitted => live_deploy_transaction_admitted(access: access, run: run, expected: expected, provenance: provenance) + } + } + } + } + } + } + } + } +} + +func live_deploy_apply_srv1_transaction_wet() -> ProcessExit { + live_deploy_transaction_wet_with_access(access: ci_deploy_srv1_access) +} diff --git a/dag/gunbc/live_deploy/apply_receipt.dag b/dag/gunbc/live_deploy/apply_receipt.dag new file mode 100644 index 00000000000..995967ddc83 --- /dev/null +++ b/dag/gunbc/live_deploy/apply_receipt.dag @@ -0,0 +1,440 @@ +module gunbc.live_deploy.apply_receipt + +import std.types { String, Bool, NonEmptyStr, Int, List, FilePath, GitRef } +import std.algebra { trim } +import std.content_hash { content_hash_atom, content_hash_tagged_structural } +import extdeps.languages.json.emit { JsonValue, json_kv, json_string, json_object, serialize_json } +import extdeps.languages.json.parse { + parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, + json_object_unique_member, + JsonMemberFound, JsonMemberAbsent, JsonMemberDuplicated, JsonMemberNotAnObject, +} +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.git +import extdeps.git.object_store { git_object_id_from_untagged_hex, git_object_id_wire_hex } +import gunbc.actions_run_binding { + ActionsRunBinding, + actions_run_binding_json, + actions_run_binding_of_member, + ActionsRunBindingDecoded, ActionsRunBindingUnreadable, + json_required_string_member, + JsonStringMemberFound, JsonStringMemberRefused, + actions_run_binding_identity_text, + actions_variable_read, + ActionsVariablePresent, ActionsVariableAbsent, +} + +// THE DASHBOARD TRANSACTION'S TYPED RECEIPT. gunbc.live_deploy.apply live_deploy_apply_srv1_wet +// observes the candidate, decides the target, applies, gates on readiness and reads the digest back +// -- and then flattens all of it to ProcessExit, so the workflow that dispatched it could name no +// revision it installed, no tree it verified, no transaction it belonged to. This receipt is minted +// by the receipted entry (live_deploy_apply_srv1_transaction_wet) around that same composition: the +// fold is reused, not re-implemented, and what is added is the expected revision the operator typed +// (RLM_EXPECTED_REVISION), the candidate's root-tree object identity, the fleet plan/apply +// provenance the dispatch named, and one independent readiness observation taken AFTER the apply +// returned. +// +// THE PLAN HASH IS PROVENANCE HERE, NOT ELIGIBILITY. R is the shared safety key across the four +// runs; the plan and apply run identities ride on this receipt so the final join cannot pair an +// arbitrary fleet transaction with an arbitrary dashboard transaction, but nothing about a plan +// hash decides whether this deploy may proceed -- that stays with the target decision and the +// candidate observation the existing fold already performs. +// +// THE CANDIDATE TREE OID IS `git rev-parse HEAD:` -- the colon form names the root tree of HEAD, +// and it is the one spelling of that object the fleet portability wall admits +// (gunbc.fleet_known_hosts_anchor portable_remote_word_char_allowed admits ':' and refuses '^', '{' +// and '}'), so the candidate side and the deployed side +// (gunbc.live_deploy.deployed_tree_observation deployed_tree_root_tree_argv) read the same object +// the same way. +data live_deploy_apply_receipt_schema: String = "live-deploy-apply-receipt/v1" +data live_deploy_apply_receipt_dir: String = "target/live-deploy-srv1-receipt" +data live_deploy_apply_receipt_artifact_path: String = "target/live-deploy-srv1-receipt/live_deploy_receipt.json" + +data rlm_plan_run_id_env_name: String = "RLM_PLAN_RUN_ID" +data rlm_apply_run_id_env_name: String = "RLM_APPLY_RUN_ID" +data rlm_plan_artifact_hash_env_name: String = "RLM_PLAN_ARTIFACT_HASH" + +type LiveDeployFleetProvenance { + plan_run_id: String + apply_run_id: String + plan_artifact_hash: String + plan_receipt_identity: String + apply_receipt_identity: String +} + +type LiveDeployFleetProvenanceInputs { + plan_run_id: String + apply_run_id: String + plan_artifact_hash: String +} + +type LiveDeployFleetProvenanceRead + = FleetProvenancePresent { inputs: LiveDeployFleetProvenanceInputs } + | FleetProvenanceAbsent { variable: String, detail: String } + +fn read_live_deploy_fleet_provenance() -> LiveDeployFleetProvenanceRead { + match actions_variable_read(name: rlm_plan_run_id_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => FleetProvenanceAbsent { variable: v, detail: d } + ActionsVariablePresent { value: plan_run_id } => + match actions_variable_read(name: rlm_apply_run_id_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => FleetProvenanceAbsent { variable: v, detail: d } + ActionsVariablePresent { value: apply_run_id } => + match actions_variable_read(name: rlm_plan_artifact_hash_env_name) { + ActionsVariableAbsent { variable: v, detail: d } => FleetProvenanceAbsent { variable: v, detail: d } + ActionsVariablePresent { value: hash } => + FleetProvenancePresent { + inputs: LiveDeployFleetProvenanceInputs { + plan_run_id: plan_run_id, + apply_run_id: apply_run_id, + plan_artifact_hash: hash, + }, + } + } + } + } +} + +type LiveDeployTargetDecisionLabel = DecisionProceed | DecisionTerminalNoOp | DecisionRefused + +fn target_decision_label_text(d: LiveDeployTargetDecisionLabel) -> String { + match d { + DecisionProceed => "proceed" + DecisionTerminalNoOp => "terminal_no_op" + DecisionRefused => "refused" + } +} + +fn target_decision_label_of_text(s: String) -> LiveDeployTargetDecisionLabel? { + match s { + "proceed" => Present { value: DecisionProceed } + "terminal_no_op" => Present { value: DecisionTerminalNoOp } + "refused" => Present { value: DecisionRefused } + _ => none + } +} + +type LiveDeployApplyOutcome + = LiveDeployApplyConverged + | LiveDeployApplyNotConverged { reason: String } + +type LiveDeployReadinessOutcome + = LiveDeployReadinessObserved { revision: String, surface: String } + | LiveDeployReadinessRefused { detail: String } + +type LiveDeployApplyReceipt { + run: ActionsRunBinding + target: String + expected_revision: String + candidate_revision: String + candidate_surface_identity: String + candidate_tree_oid: String + target_decision: LiveDeployTargetDecisionLabel + apply_outcome: LiveDeployApplyOutcome + readiness: LiveDeployReadinessOutcome + provenance: LiveDeployFleetProvenance + completed_at: String +} + +fn live_deploy_apply_outcome_text(o: LiveDeployApplyOutcome) -> String { + match o { + LiveDeployApplyConverged => "converged" + LiveDeployApplyNotConverged { reason: r } => join(["not_converged:", r], "") + } +} + +fn live_deploy_readiness_text(o: LiveDeployReadinessOutcome) -> String { + match o { + LiveDeployReadinessObserved { revision: r, surface: s } => join(["observed:", r, ":", s], "") + LiveDeployReadinessRefused { detail: d } => join(["refused:", d], "") + } +} + +fn live_deploy_apply_receipt_identity_text(r: LiveDeployApplyReceipt) -> String { + join([ + actions_run_binding_identity_text(binding: r.run), "|", r.target, "|", r.expected_revision, "|", + r.candidate_revision, "|", r.candidate_surface_identity, "|", r.candidate_tree_oid, "|", + target_decision_label_text(d: r.target_decision), "|", live_deploy_apply_outcome_text(o: r.apply_outcome), "|", + live_deploy_readiness_text(o: r.readiness), "|", + r.provenance.plan_run_id, "|", r.provenance.apply_run_id, "|", r.provenance.plan_artifact_hash, "|", + r.provenance.plan_receipt_identity, "|", r.provenance.apply_receipt_identity, "|", + r.completed_at, + ], "") +} + +fn live_deploy_apply_receipt_identity_hex(r: LiveDeployApplyReceipt) -> String { + content_hash_tagged_structural( + tag: "live-deploy-apply-receipt-v1" as NonEmptyStr, + payload: content_hash_atom(value: live_deploy_apply_receipt_identity_text(r: r) as NonEmptyStr), + ).digest as String +} + +fn live_deploy_apply_outcome_json(o: LiveDeployApplyOutcome) -> JsonValue { + match o { + LiveDeployApplyConverged => json_object(members: [json_kv(key: "outcome", value: json_string(s: "converged"))]) + LiveDeployApplyNotConverged { reason: r } => + json_object(members: [ + json_kv(key: "outcome", value: json_string(s: "not_converged")), + json_kv(key: "reason", value: json_string(s: r)), + ]) + } +} + +fn live_deploy_readiness_json(o: LiveDeployReadinessOutcome) -> JsonValue { + match o { + LiveDeployReadinessObserved { revision: r, surface: s } => + json_object(members: [ + json_kv(key: "outcome", value: json_string(s: "observed")), + json_kv(key: "revision", value: json_string(s: r)), + json_kv(key: "surface", value: json_string(s: s)), + ]) + LiveDeployReadinessRefused { detail: d } => + json_object(members: [ + json_kv(key: "outcome", value: json_string(s: "refused")), + json_kv(key: "detail", value: json_string(s: d)), + ]) + } +} + +fn live_deploy_apply_receipt_json(r: LiveDeployApplyReceipt) -> JsonValue { + json_object(members: [ + json_kv(key: "schema", value: json_string(s: live_deploy_apply_receipt_schema)), + json_kv(key: "run", value: actions_run_binding_json(binding: r.run)), + json_kv(key: "target", value: json_string(s: r.target)), + json_kv(key: "expected_revision", value: json_string(s: r.expected_revision)), + json_kv(key: "candidate_revision", value: json_string(s: r.candidate_revision)), + json_kv(key: "candidate_surface_identity", value: json_string(s: r.candidate_surface_identity)), + json_kv(key: "candidate_tree_oid", value: json_string(s: r.candidate_tree_oid)), + json_kv(key: "target_decision", value: json_string(s: target_decision_label_text(d: r.target_decision))), + json_kv(key: "apply_outcome", value: live_deploy_apply_outcome_json(o: r.apply_outcome)), + json_kv(key: "readiness", value: live_deploy_readiness_json(o: r.readiness)), + json_kv(key: "provenance", value: json_object(members: [ + json_kv(key: "plan_run_id", value: json_string(s: r.provenance.plan_run_id)), + json_kv(key: "apply_run_id", value: json_string(s: r.provenance.apply_run_id)), + json_kv(key: "plan_artifact_hash", value: json_string(s: r.provenance.plan_artifact_hash)), + json_kv(key: "plan_receipt_identity", value: json_string(s: r.provenance.plan_receipt_identity)), + json_kv(key: "apply_receipt_identity", value: json_string(s: r.provenance.apply_receipt_identity)), + ])), + json_kv(key: "completed_at", value: json_string(s: r.completed_at)), + json_kv(key: "receipt_identity", value: json_string(s: live_deploy_apply_receipt_identity_hex(r: r))), + ]) +} + +type LiveDeployApplyReceiptDecode + = LiveDeployApplyReceiptDecoded { receipt: LiveDeployApplyReceipt } + | LiveDeployApplyReceiptUnreadable { reason: String } + +type LiveDeployOutcomeDecode + = OutcomeDecoded { outcome: LiveDeployApplyOutcome } + | OutcomeUnreadable { reason: String } + +fn live_deploy_apply_outcome_of_member(doc: JsonValue) -> LiveDeployOutcomeDecode { + match json_object_unique_member(v: doc, key: "apply_outcome") { + JsonMemberAbsent => OutcomeUnreadable { reason: "apply_outcome is missing" } + JsonMemberDuplicated { count: c } => OutcomeUnreadable { reason: join(["apply_outcome appears ", to_string(c), " times; the member must be unique"], "") } + JsonMemberNotAnObject => OutcomeUnreadable { reason: "the receipt document is not an object at apply_outcome" } + JsonMemberFound { value: inner } => + match json_required_string_member(doc: inner, key: "outcome") { + JsonStringMemberRefused { reason: r } => OutcomeUnreadable { reason: join(["apply_outcome: ", r], "") } + JsonStringMemberFound { value: label } => + match label { + "converged" => OutcomeDecoded { outcome: LiveDeployApplyConverged } + "not_converged" => + match json_required_string_member(doc: inner, key: "reason") { + JsonStringMemberRefused { reason: r } => OutcomeUnreadable { reason: join(["apply_outcome: ", r], "") } + JsonStringMemberFound { value: reason } => OutcomeDecoded { outcome: LiveDeployApplyNotConverged { reason: reason } } + } + _ => OutcomeUnreadable { reason: join(["apply_outcome is unknown: ", label], "") } + } + } + } +} + +type LiveDeployReadinessDecode + = ReadinessDecoded { readiness: LiveDeployReadinessOutcome } + | ReadinessUnreadable { reason: String } + +fn live_deploy_readiness_of_member(doc: JsonValue) -> LiveDeployReadinessDecode { + match json_object_unique_member(v: doc, key: "readiness") { + JsonMemberAbsent => ReadinessUnreadable { reason: "readiness is missing" } + JsonMemberDuplicated { count: c } => ReadinessUnreadable { reason: join(["readiness appears ", to_string(c), " times; the member must be unique"], "") } + JsonMemberNotAnObject => ReadinessUnreadable { reason: "the receipt document is not an object at readiness" } + JsonMemberFound { value: inner } => + match json_required_string_member(doc: inner, key: "outcome") { + JsonStringMemberRefused { reason: r } => ReadinessUnreadable { reason: join(["readiness: ", r], "") } + JsonStringMemberFound { value: label } => + match label { + "observed" => + match json_required_string_member(doc: inner, key: "revision") { + JsonStringMemberRefused { reason: r } => ReadinessUnreadable { reason: join(["readiness: ", r], "") } + JsonStringMemberFound { value: revision } => + match json_required_string_member(doc: inner, key: "surface") { + JsonStringMemberRefused { reason: r } => ReadinessUnreadable { reason: join(["readiness: ", r], "") } + JsonStringMemberFound { value: surface } => + ReadinessDecoded { readiness: LiveDeployReadinessObserved { revision: revision, surface: surface } } + } + } + "refused" => + match json_required_string_member(doc: inner, key: "detail") { + JsonStringMemberRefused { reason: r } => ReadinessUnreadable { reason: join(["readiness: ", r], "") } + JsonStringMemberFound { value: detail } => ReadinessDecoded { readiness: LiveDeployReadinessRefused { detail: detail } } + } + _ => ReadinessUnreadable { reason: join(["readiness outcome is unknown: ", label], "") } + } + } + } +} + +type LiveDeployProvenanceDecode + = ProvenanceDecoded { provenance: LiveDeployFleetProvenance } + | ProvenanceUnreadable { reason: String } + +fn live_deploy_provenance_of_member(doc: JsonValue) -> LiveDeployProvenanceDecode { + match json_object_unique_member(v: doc, key: "provenance") { + JsonMemberAbsent => ProvenanceUnreadable { reason: "provenance is missing" } + JsonMemberDuplicated { count: c } => ProvenanceUnreadable { reason: join(["provenance appears ", to_string(c), " times; the member must be unique"], "") } + JsonMemberNotAnObject => ProvenanceUnreadable { reason: "the receipt document is not an object at provenance" } + JsonMemberFound { value: inner } => + match json_required_string_member(doc: inner, key: "plan_run_id") { + JsonStringMemberRefused { reason: r } => ProvenanceUnreadable { reason: join(["provenance: ", r], "") } + JsonStringMemberFound { value: plan_run_id } => + match json_required_string_member(doc: inner, key: "apply_run_id") { + JsonStringMemberRefused { reason: r } => ProvenanceUnreadable { reason: join(["provenance: ", r], "") } + JsonStringMemberFound { value: apply_run_id } => + match json_required_string_member(doc: inner, key: "plan_artifact_hash") { + JsonStringMemberRefused { reason: r } => ProvenanceUnreadable { reason: join(["provenance: ", r], "") } + JsonStringMemberFound { value: hash } => + match json_required_string_member(doc: inner, key: "plan_receipt_identity") { + JsonStringMemberRefused { reason: r } => ProvenanceUnreadable { reason: join(["provenance: ", r], "") } + JsonStringMemberFound { value: plan_rid } => + match json_required_string_member(doc: inner, key: "apply_receipt_identity") { + JsonStringMemberRefused { reason: r } => ProvenanceUnreadable { reason: join(["provenance: ", r], "") } + JsonStringMemberFound { value: apply_rid } => + ProvenanceDecoded { + provenance: LiveDeployFleetProvenance { + plan_run_id: plan_run_id, apply_run_id: apply_run_id, plan_artifact_hash: hash, + plan_receipt_identity: plan_rid, apply_receipt_identity: apply_rid, + }, + } + } + } + } + } + } + } +} + +fn live_deploy_apply_receipt_decode(raw: String) -> LiveDeployApplyReceiptDecode { + match parse_json_document(s: raw) { + JsonDocumentUnreadable { gap: gap } => + LiveDeployApplyReceiptUnreadable { reason: join(["live-deploy receipt is ", json_document_gap_text(gap: gap)], "") } + JsonDocumentParsed { value: doc } => + match json_required_string_member(doc: doc, key: "schema") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: schema } => + if schema != live_deploy_apply_receipt_schema { + LiveDeployApplyReceiptUnreadable { reason: join(["live-deploy receipt schema is not ", live_deploy_apply_receipt_schema, ": ", schema], "") } + } else { + match actions_run_binding_of_member(doc: doc, key: "run") { + ActionsRunBindingUnreadable { reason: r } => LiveDeployApplyReceiptUnreadable { reason: join(["live-deploy receipt run: ", r], "") } + ActionsRunBindingDecoded { binding: run } => + match json_required_string_member(doc: doc, key: "target") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: target } => + match json_required_string_member(doc: doc, key: "expected_revision") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: expected } => + match json_required_string_member(doc: doc, key: "candidate_revision") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: candidate } => + match json_required_string_member(doc: doc, key: "candidate_surface_identity") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: surface } => + match json_required_string_member(doc: doc, key: "candidate_tree_oid") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: tree_oid } => + match json_required_string_member(doc: doc, key: "target_decision") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: decision_text } => + match target_decision_label_of_text(s: decision_text) { + Absent => LiveDeployApplyReceiptUnreadable { reason: join(["target_decision is unknown: ", decision_text], "") } + Present { value: decision } => + match live_deploy_apply_outcome_of_member(doc: doc) { + OutcomeUnreadable { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + OutcomeDecoded { outcome: apply_outcome } => + match live_deploy_readiness_of_member(doc: doc) { + ReadinessUnreadable { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + ReadinessDecoded { readiness: readiness } => + match live_deploy_provenance_of_member(doc: doc) { + ProvenanceUnreadable { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + ProvenanceDecoded { provenance: provenance } => + match json_required_string_member(doc: doc, key: "completed_at") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: completed_at } => { + let receipt = LiveDeployApplyReceipt { + run: run, + target: target, + expected_revision: expected, + candidate_revision: candidate, + candidate_surface_identity: surface, + candidate_tree_oid: tree_oid, + target_decision: decision, + apply_outcome: apply_outcome, + readiness: readiness, + provenance: provenance, + completed_at: completed_at, + } + match json_required_string_member(doc: doc, key: "receipt_identity") { + JsonStringMemberRefused { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + JsonStringMemberFound { value: claimed } => + if claimed != live_deploy_apply_receipt_identity_hex(r: receipt) { + LiveDeployApplyReceiptUnreadable { reason: "live-deploy receipt identity does not match its members" } + } else { + LiveDeployApplyReceiptDecoded { receipt: receipt } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} + +fn read_live_deploy_apply_receipt_at(path: String) -> LiveDeployApplyReceiptDecode { + let read = Filesystem.Read(path: path) + if !read.success { + LiveDeployApplyReceiptUnreadable { reason: join(["live-deploy receipt could not be read at ", path, ": ", read.error], "") } + } else { + live_deploy_apply_receipt_decode(raw: read.content) + } +} + +// THE CANDIDATE ROOT TREE, read from the deploying checkout by the same colon form the deployed +// side uses, so the two identities are the same object read the same way. +type CandidateTreeOidObservation + = CandidateTreeOidObserved { oid_hex: String } + | CandidateTreeOidUnobservable { detail: String } + +data candidate_root_tree_ref: String = "HEAD:" + +fn observe_candidate_root_tree_oid(repo: String) -> CandidateTreeOidObservation { + let read = git.Core.RevParseInRepo(repo: repo as FilePath, target: candidate_root_tree_ref as GitRef) + if read.exit_code != 0 { + CandidateTreeOidUnobservable { detail: join(["git rev-parse HEAD: exited ", to_string(read.exit_code), ": ", read.stderr], "") } + } else { + match git_object_id_from_untagged_hex(hex: trim(s: read.revision)) { + Absent => CandidateTreeOidUnobservable { detail: join(["git rev-parse HEAD: printed no object id: ", trim(s: read.revision)], "") } + Present { value: oid } => CandidateTreeOidObserved { oid_hex: git_object_id_wire_hex(oid: oid) as String } + } + } +} diff --git a/dag/gunbc/live_deploy/deployed_tree_observation.dag b/dag/gunbc/live_deploy/deployed_tree_observation.dag index 4b6b6d26df5..293aa461a49 100644 --- a/dag/gunbc/live_deploy/deployed_tree_observation.dag +++ b/dag/gunbc/live_deploy/deployed_tree_observation.dag @@ -4,7 +4,7 @@ import extdeps.tools.env { env_path_resolved_program } import std.types { String, NonEmptyStr, List, Bool, Int } import std.algebra { trim } import extdeps.git -import extdeps.git.object_store { GitObjectId, git_object_id_from_untagged_hex, git_object_id_eq } +import extdeps.git.object_store { GitObjectId, git_object_id_from_untagged_hex, git_object_id_eq, git_object_id_wire_hex } import extdeps.ssh.session { SshSessionExecResult } import gunbc.fleet_known_hosts_anchor { FleetSshExecutionContext, SshTarget } import gunbc.live_deploy.candidate { scan_checkout_status, scan_ignored_deployed_paths } @@ -77,6 +77,16 @@ fn deployed_tree_revision_argv(repo: NonEmptyStr) -> List { ["git", "-C", repo as String, "rev-parse", "HEAD"] } +// THE ROOT-TREE OBJECT IDENTITY (RLM-2 ruling section 4, G7). `HEAD:` is the colon form naming the +// root tree of HEAD; `HEAD^{tree}` names the same object but `^`, `{` and `}` are outside the +// portability wall (gunbc.fleet_known_hosts_anchor portable_remote_word_char_allowed), while `:` +// is inside it. Same locus, same transport, one more argv -- not a second remote-revision path. +data deployed_tree_root_tree_ref: String = "HEAD:" + +fn deployed_tree_root_tree_argv(repo: NonEmptyStr) -> List { + ["git", "-C", repo as String, "rev-parse", deployed_tree_root_tree_ref] +} + // THE WORKTREE IS COMPARED AGAINST AN INDEX WE BUILD, AND THE DEPLOYED REPO'S OWN INDEX IS NEVER // CONSULTED. This is the third instrument this observation has had, and the first two were both // wrong in the same way, so the reasoning is recorded rather than just the answer. @@ -143,7 +153,6 @@ fn deployed_tree_revision_argv(repo: NonEmptyStr) -> List { // `unspecified`. .gitattributes is generated from gunbc.gitattributes_emit, whose two attribute // constants are literals with no mechanism to admit a third, so the premise is structural rather than // merely observed. - // THE COMPOSED CLAIM, stated because the status leg alone proves less than it looks like it proves. // `status` reports HEAD-versus-index as well as worktree-versus-index. Here the temp index is built // from the revision the rev-parse leg OBSERVED, so the HEAD-versus-index half is equal by @@ -253,7 +262,7 @@ type DeployedTreeUnobservableCause | TreeStatusUnreadable { detail: String } type DeployedTreeObservation - = DeployedTreeObserved { revision: GitObjectId, content: DeployedTreeContentState } + = DeployedTreeObserved { revision: GitObjectId, tree: GitObjectId, content: DeployedTreeContentState } | DeployedTreeUnobservable { cause: DeployedTreeUnobservableCause } // ONE RENDERING, so every consumer that needs prose derives it from the arms rather than each @@ -329,7 +338,6 @@ fn deployed_tree_content_reading(entries_nul: String, ignored_nul: String) -> De // second is required anyway -- publication has to hold it -- so the atomic read arrives as a property // of the lease rather than as a separate mechanism, and building the v2 scanner first would be a // second grammar for one concept that the lease then obsoletes. - // BOTH READINGS OR NEITHER. The revision read is attempted first and a failure there ends the // observation, because a modification count without a revision to attach it to is a fact about no // particular commit -- and reporting it beside a fabricated or absent revision is the state-space @@ -348,7 +356,28 @@ fn observe_deployed_tree(locus: DeployedTreeLocus) -> DeployedTreeObservation { Absent => DeployedTreeUnobservable { cause: TreeRevisionUndecodable { text: trim(s: rev.stdout) } } Present { value: oid } => - observe_deployed_tree_against_revision(locus: locus, revision: oid, revision_hex: trim(s: rev.stdout)) + match deployed_tree_leg(locus: locus, argv: deployed_tree_root_tree_argv(repo: locus.repo)) { + DeployedTreeLegNotAttempted { reason: why } => + DeployedTreeUnobservable { cause: TreeLegNotAttempted { leg: "root tree", reason: why } } + DeployedTreeLegRan { result: tr } => + if tr.exit_code != 0 { + DeployedTreeUnobservable { + cause: TreeCommandFailed { leg: "root tree", exit_code: tr.exit_code, stderr: tr.stderr }, + } + } else { + match git_object_id_from_untagged_hex(hex: trim(s: tr.stdout)) { + Absent => + DeployedTreeUnobservable { cause: TreeRevisionUndecodable { text: trim(s: tr.stdout) } } + Present { value: tree_oid } => + observe_deployed_tree_against_revision( + locus: locus, + revision: oid, + tree: tree_oid, + revision_hex: trim(s: rev.stdout), + ) + } + } + } } } } @@ -362,6 +391,7 @@ fn observe_deployed_tree(locus: DeployedTreeLocus) -> DeployedTreeObservation { fn observe_deployed_tree_against_revision( locus: DeployedTreeLocus, revision: GitObjectId, + tree: GitObjectId, revision_hex: String, ) -> DeployedTreeObservation { let index_path = deployed_tree_temp_index_path(locus: locus) @@ -413,7 +443,7 @@ fn observe_deployed_tree_against_revision( ContentUnreadable { detail: d } => DeployedTreeUnobservable { cause: TreeStatusUnreadable { detail: d } } ContentRead { state: s } => - DeployedTreeObserved { revision: revision, content: s } + DeployedTreeObserved { revision: revision, tree: tree, content: s } } } } @@ -477,3 +507,39 @@ fn deployed_tree_standing_is_converged(standing: DeployedTreeStanding) -> Bool { DeployedTreeStandingUnobserved { cause: _ } => false } } + + +// THE ROOT-TREE JOIN: candidate tree OID (read from the deploying checkout at R) equals the deployed +// tree OID, AND the deployed scope matches its commit. Both are required; a tree that matches by +// object identity but carries a modified deployed path is still not R. +type DeployedRootTreeStanding + = DeployedRootTreeMatches { tree_hex: String } + | DeployedRootTreeMismatch { candidate_hex: String, deployed_hex: String } + | DeployedRootTreeScopeDiverged { tree_hex: String, changed_paths: NonEmptyStr } + | DeployedRootTreeUnobserved { cause: DeployedTreeUnobservableCause } + +fn deployed_root_tree_standing(candidate_tree_hex: String, observation: DeployedTreeObservation) -> DeployedRootTreeStanding { + match observation { + DeployedTreeUnobservable { cause: c } => DeployedRootTreeUnobserved { cause: c } + DeployedTreeObserved { revision: _, tree: t, content: content } => { + let deployed_hex = git_object_id_wire_hex(oid: t) as String + if deployed_hex != candidate_tree_hex { + DeployedRootTreeMismatch { candidate_hex: candidate_tree_hex, deployed_hex: deployed_hex } + } else { + match content { + DeployedScopeMatchesCommit => DeployedRootTreeMatches { tree_hex: deployed_hex } + DeployedScopeDiverged { changed_paths: p } => DeployedRootTreeScopeDiverged { tree_hex: deployed_hex, changed_paths: p } + } + } + } + } +} + +fn deployed_root_tree_standing_text(s: DeployedRootTreeStanding) -> String { + match s { + DeployedRootTreeMatches { tree_hex: h } => join(["deployed root tree ", h, " matches the candidate"], "") + DeployedRootTreeMismatch { candidate_hex: c, deployed_hex: d } => join(["deployed root tree ", d, " is not the candidate tree ", c], "") + DeployedRootTreeScopeDiverged { tree_hex: h, changed_paths: p } => join(["deployed root tree ", h, " matches but the deployed scope diverged: ", p as String], "") + DeployedRootTreeUnobserved { cause: c } => join(["deployed root tree unobserved: ", deployed_tree_unobservable_text(cause: c)], "") + } +} diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index bcc03f8bff9..65d6c16bc9f 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -180,7 +180,6 @@ fn systemctl_disable_now_argv_for_deploy(unit: NonEmptyStr, privileged: Bool) -> // show/read failure therefore stops before the conditional, and a disable/stop failure remains // loud. This replaces the former `2>/dev/null || true` without smuggling command substitution or // control flow through a String leaf. - fn systemctl_load_state_assignment_intent(unit: NonEmptyStr) -> Node { bash_build_assign( name: "_gunbc_load_state", @@ -314,7 +313,6 @@ data emit_systemd_unit_serve_note: String = "The belt-B unit: ExecStart is `gunb // not in a witness — so the oracle lives in the test corpus against RevisionBoundAtEmission with a // fixture, and the artifact was simply dead (no execution consumer, and a HostReconciler consumer // row that named a reader which does not exist). - type ReleaseRevisionBinding = RevisionBoundAtEmission { revision: CommitSha } | RevisionNotInstalled @@ -341,11 +339,15 @@ fn release_revision_execstart_text(binding: ReleaseRevisionBinding) -> String { } } -fn emit_systemd_unit_doc(spec: DeploymentSpec, revision: ReleaseRevisionBinding) -> Doc { +// THE TYPED UNIT VALUE IS A NAMED PRODUCER, so a reader that compares the EFFECTIVE unit on a host +// against what this revision would install (gunbc.live_deploy.unit_standing) consumes the same +// directive list the emission serializes, rather than re-parsing the rendered text. The doc +// producers below delegate here; behaviour is unchanged. +fn live_deploy_serve_unit_file(spec: DeploymentSpec, revision: ReleaseRevisionBinding) -> SystemdUnitFile { let svc = spec.service let port = to_string(svc.listen.port) let host = svc.listen.host - doc_text(text: serialize_systemd_unit_file(file: ServiceUnitFile { + ServiceUnitFile { unit: [ Description { text: "gunbc roadmap HTTP server (gunbc serve)" as NonEmptyStr }, After { target: "network-online.target tailscaled.service" as NonEmptyStr }, @@ -368,16 +370,20 @@ fn emit_systemd_unit_doc(spec: DeploymentSpec, revision: ReleaseRevisionBinding) Restart { policy: "on-failure" as NonEmptyStr }, ], install: Installable { directives: [WantedBy { target: "multi-user.target" as NonEmptyStr }] }, - })) + } +} + +fn emit_systemd_unit_doc(spec: DeploymentSpec, revision: ReleaseRevisionBinding) -> Doc { + doc_text(text: serialize_systemd_unit_file(file: live_deploy_serve_unit_file(spec: spec, revision: revision))) } data belt_tick_cadence_note: String = "OnUnitInactiveSec, NOT OnUnitActiveSec, and the difference is a fail-closed choice rather than a formatting one. OnUnitActiveSec measures from when the tick STARTED, so a tick that runs longer than the cadence schedules its successor while it is still running and ticks pile up back-to-back — for a unit whose job is spawning agent sessions, that converts a slow tick into overlapping spawns. OnUnitInactiveSec measures from when the previous tick FINISHED, so the gap is real regardless of how long a tick takes and the belt can never outrun itself. systemd additionally refuses to start a Type=oneshot unit that is already active, so the two together make overlap unwritable rather than unlikely.\\n\\n60s is the operator's stated default for this lane and is deliberately not tuned against anything: a tick against an unchanged frontier is an idempotent reconcile that observes live tmux sessions and does nothing (belt_run_once_note), so the cost of a too-fast cadence is an observation, not an action. AccuracySec=5s keeps systemd from coalescing the timer into a distant wakeup window, which is the default behavior and would otherwise make the effective cadence unpredictable.\\n\\nOnBootSec=2min rather than firing immediately: at boot the tree sync, the serve unit and tailscale are all converging, and a belt tick that spawns before the repo tree is in place produces SpawnFailed rows for a condition that resolves itself within seconds. The delay costs one cadence period and removes a class of self-inflicted refusals from the receipt." data belt_tick_cadence: String = "60s" -fn emit_belt_service_unit_doc(spec: DeploymentSpec) -> Doc { +fn live_deploy_belt_service_unit_file(spec: DeploymentSpec) -> SystemdUnitFile { let svc = spec.service - doc_text(text: serialize_systemd_unit_file(file: ServiceUnitFile { + ServiceUnitFile { unit: [ Description { text: "gunbc roadmap belt tick (one reconcile pass over the ready frontier)" as NonEmptyStr }, After { target: "network-online.target" as NonEmptyStr }, @@ -398,7 +404,11 @@ fn emit_belt_service_unit_doc(spec: DeploymentSpec) -> Doc { ], "") as NonEmptyStr }, ], install: NotInstallable, - })) + } +} + +fn emit_belt_service_unit_doc(spec: DeploymentSpec) -> Doc { + doc_text(text: serialize_systemd_unit_file(file: live_deploy_belt_service_unit_file(spec: spec))) } // THE PUBLICATION HELPER: THE ONLY UNIT IN THIS DEPLOYMENT THAT BINDS A CREDENTIAL, AND THE ONLY @@ -525,8 +535,8 @@ fn apply_publication_spool_steps(spec: DeploymentSpec) -> List { ] } -fn emit_belt_timer_unit_doc(spec: DeploymentSpec) -> Doc { - doc_text(text: serialize_systemd_unit_file(file: TimerUnitFile { +fn live_deploy_belt_timer_unit_file(spec: DeploymentSpec) -> SystemdUnitFile { + TimerUnitFile { unit: [Description { text: "gunbc roadmap belt tick cadence" as NonEmptyStr }], timer: [ TimerUnit { target: deployment_belt_service_unit_name(names: spec.names) }, @@ -535,7 +545,11 @@ fn emit_belt_timer_unit_doc(spec: DeploymentSpec) -> Doc { AccuracySec { duration: "5s" as NonEmptyStr }, ], install: Installable { directives: [WantedBy { target: "timers.target" as NonEmptyStr }] }, - })) + } +} + +fn emit_belt_timer_unit_doc(spec: DeploymentSpec) -> Doc { + doc_text(text: serialize_systemd_unit_file(file: live_deploy_belt_timer_unit_file(spec: spec))) } // THE DEPLOYED TREE'S REMOTE, CONVERGED BY ITS OWNER. @@ -679,7 +693,6 @@ fn apply_belt_timer_unit_write_step(spec: DeploymentSpec) -> PipelineStep { // future non-degenerate observed provider, exercised only by the synthetic spine witness. key_of // returns stable member IDENTITY (artifact path / dependency package); value_eq never fires in the // degenerate poles (no Modified — one side is always empty). - type DeploymentStepMemberAt = NonEmptyStr fn deployment_step_key(step: DeploymentStep) -> NonEmptyStr { @@ -729,7 +742,6 @@ fn deployment_retract_plan(spec: DeploymentSpec) -> MembershipPlan PipelineStep { deploy_raw(command: deploy_stage_write_command( stage_name: spec.service.unit_name as String, @@ -782,7 +794,6 @@ fn apply_tree_sync_unit_write_step(spec: DeploymentSpec, tree_path: String) -> P // gunbc.live_deploy.intent) the roadmap-unit capture already owns — no second diagnosis format. On // failure the tail is appended to stderr and the step still fails (exit 1): a diagnosis, never an // escape hatch (DESIGN 5 forbids proceeding as if the refusal had not fired). - fn tree_sync_restart_step_with_diagnosis(spec: DeploymentSpec) -> PipelineStep { deploy_raw(command: join([ systemctl_restart_command(unit: spec.names.tree_sync_unit_name, privileged: true), @@ -804,7 +815,6 @@ fn tree_sync_restart_step_with_diagnosis(spec: DeploymentSpec) -> PipelineStep { // all.\n\nThe argv is built by extdeps.tailscale.serve rather than concatenated here, so the apply // and the teardown cannot disagree about the flags — which is the property the upstream `off` form // actually requires. - fn tailscale_serve_apply_step(spec: DeploymentSpec) -> PipelineStep { deploy_raw(command: shell_privileged_command_text_of_argv( argv: concat(["tailscale"], tailscale_serve_enable_argv(endpoint: spec.names.tailscale_serve_endpoint)), @@ -865,7 +875,6 @@ fn install_d_owned_command(spec: DeploymentSpec, path: String) -> String { // in the owned roster to begin with.\n\nprivileged is consumed only by the package arm because it // describes how apt is invoked; install -d is privileged in both contexts and takes the same form // either way. - fn ensure_dependency_steps(dep: DeploymentDependencyStep, privileged: Bool) -> List { match dep.subject { EnsuredPackage { package } => [ensure_apt_package_step(pkg: package, privileged: privileged)] @@ -883,7 +892,6 @@ fn ensure_dependency_steps(dep: DeploymentDependencyStep, privileged: Bool) -> L // does not resolve, and the failure would look like a permissions bug rather than a modeling gap. // dissolve-on = PosixUser (or a PosixGroup beside it) carries the group name, at which point this // projects it instead of reusing the user's. - fn install_d_managed_command(dir: ManagedDirectory) -> String { install_directory_owned_command( mode: managed_directory_mode_octal(d: dir) as NonEmptyStr, @@ -1053,7 +1061,6 @@ fn emit_artifact_upsert(art: DeploymentArtifactStep, spec: DeploymentSpec, revis // remove this store even by mistake -- the property the previous `rm -d` arm tried to approximate // by refusing on a non-empty directory is now structural, and the awkward consequence that arm // carried (a full retract could never complete once the store existed) simply does not arise. - data belt_receipt_dir_bootstrap_note: String = "THE BELT MEMBER CARRIES THE install -d FOR THE DIRECTORY ITS TICK WRITES INTO, which is the orchestration-is-derived-from-the-member rule emit_deploy_member_effect_note already states (a ServerScript carries the install -d it writes into). The belt tick publishes its receipt to instance_receipts_dir(instance_root) and the workflow UI reads that receipt to answer whether a tick ran; with no directory owned by the service principal the write is EACCES, which is what every instance was in fact doing — belt-last-tick.json was absent from BOTH srv1 roots when read on 2026-08-19, so this receipt has never been written anywhere, not merely on the new slot.\n\nTHIS ALSO RESTORES A BOOTSTRAP THAT WENT DEAD RATHER THAN ADDING A NEW ONE. fleet_converge_receipt_atomic_publish_note FORMERLY named the deploy install-d on fleet_converge_receipt_dir as riding FleetConvergeTimerUnit apply (that clause is itself now corrected in gunbc.host_layout, so this sentence describes the prior revision rather than the current text), and that member is a retired refusal poison (the cadence moved to on-demand plan/apply, sleek-heron-218), so nothing has bootstrapped a receipts directory on any fresh instance root since. Green has no receipts directory at all; production's exists only because it predates the retirement. Riding the belt member is correct rather than convenient: the belt is the consumer that needs the directory on every instance it is installed on, so the bootstrap travels with a member that is unconditionally present wherever a receipt gets written.\n\nTHERE IS DELIBERATELY NO TEARDOWN ARM. Receipts are produced artifacts, and devboot_root_realization_note draws exactly this line — a deploy must never delete what it produced — so the directory is bootstrapped and never removed, and a retract that also destroyed the converge receipts sharing the directory would be the failure that note exists to prevent. install -d is idempotent, so converging an existing correctly-owned directory (production's) emits the same command and changes nothing.\n\nRESIDUE, NAMED: the belt writes its receipt in place rather than through the same-directory staging + rename that gunbc.fleet_converge_cli uses. In-place write is correct here because the directory is service-user-owned and the file is not a stale root-owned artifact, but it is NOT crash-safe — a tick interrupted mid-write leaves a truncated receipt, which renders as BeltPassOutcomeUnreadable rather than as a wrong verdict. Adopting the atomic publish is the strictly better construction and is not done here; it is stated so the weaker one is a decision on the record rather than an oversight." data teardown_worktree_rmdir_note: String = "DispatchWorktreeRoot teardown is rm -d (empty-directory removal via the granted rm — the unprivileged principal has no rmdir grant and no write on the root-owned parent), NEVER rm -rf: a non-empty worktree root means live agent worktrees exist, so the retract FAILS LOUDLY there instead of silently destroying sessions (DESIGN 5 — the refusal is the empty-dir precondition itself, identical to the former rmdir). GunbcSourceTree teardown runs AFTER the worktree rm -d succeeds, so the repo the worktrees are registered in is never deleted from under them." @@ -1142,7 +1149,6 @@ fn emit_artifact_teardown(art: DeploymentArtifactStep, spec: DeploymentSpec) -> // anything. That arm REFUSES loudly (emit_deploy_teardown_dependency_refusal — a typed/located // poison step), it does NOT fabricate a no-op (DESIGN §5: an unreachable arm refuses, never a // silent pass). - data live_deploy_replace_unreachable_poison: String = "__GUNBC_DEPLOY_UNREACHABLE__ EffectReplace reached the deploy emit dispatch. Both deploy poles are degenerate — apply observes the empty set, retract desires the empty set — so no Modified hunk can arise and no replace can be emitted. Reaching this arm means a NON-degenerate observed provider landed without its realization being modeled: a replace on a host resource is an in-place update only if the address survived, and a STAGED replacement with declared intermediates otherwise (gunbc.change_realization). Emitting an unconditional overwrite here would be the srv4 failure exactly — a transition that believed it had nothing to retire. This marker is not valid deploy shell, so the golden drift gate reds. member=" fn emit_deploy_replace_unreachable_refusal(step: DeploymentStep) -> List { @@ -1219,7 +1225,6 @@ fn apply_intent_from_effects( // teardowns in the first place, and there is no ordering left to get right. Keeping the partition // would preserve the shape of a fix for a defect that no longer has a way to occur, which is // exactly the dead-scaffold class. - fn retract_unsupported_client_poison(observed: String) -> String { concat( "__GUNBC_DEPLOY_REFUSED__ live-deploy retract NOT EMITTED: this target's tailscale client is ", @@ -1367,7 +1372,6 @@ fn deployment_ensure_dependency_script(dep: DeploymentDependencyStep) -> String // that introduces a hand-concatenated quote at any call site and goes red on the parse rather than // on a string shape. The shared authority itself stays independently witnessed at // shell_quote_escapes_an_embedded_apostrophe, which does not depend on this module having a caller. - data live_deploy_emit_shell_dissolution_trigger: Disposition = Scaffold { dissolves_to: RealizationDispatch, bind: DeclarationRef { @@ -1431,7 +1435,6 @@ data live_deploy_remote_mutation_service_op_realization_dissolution_trigger: Dis // digest read-back run on the DAG reconcile spine after mutation (live_deploy.readiness transport // curl poll, then live_deploy.apply digest read-back); the apply script ships mutation only (no // in-script curl read-back). - data live_deploy_intent_dependency_graph_emit_dissolution_trigger: Disposition = Scaffold { dissolves_to: RealizationDispatch, bind: DeclarationRef { diff --git a/dag/gunbc/live_deploy/release_binding.dag b/dag/gunbc/live_deploy/release_binding.dag index 7284b89d378..c18bfa3af78 100644 --- a/dag/gunbc/live_deploy/release_binding.dag +++ b/dag/gunbc/live_deploy/release_binding.dag @@ -112,7 +112,7 @@ fn deployed_release_binding( deployed_tree_unobservable_text(cause: c), ], ""), } - DeployedTreeObserved { revision: tree_revision, content: _ } => + DeployedTreeObserved { revision: tree_revision, tree: _, content: _ } => match identity { RunningReleaseUnidentified { cause: _ } => ReleaseBindingUnobserved { diff --git a/dag/gunbc/live_deploy/unit_standing.dag b/dag/gunbc/live_deploy/unit_standing.dag new file mode 100644 index 00000000000..9ed62688567 --- /dev/null +++ b/dag/gunbc/live_deploy/unit_standing.dag @@ -0,0 +1,545 @@ +module gunbc.live_deploy.unit_standing + +import std.types { String, Bool, NonEmptyStr, Int, List, list_length } +import std.algebra { trim } +import std.measure { Microsecond, microsecond_count } +import extdeps.systemd.systemctl +import extdeps.systemd { + SystemdUnitProperty, systemd_duration_usec, AccuracyUSec, + LoadState, UnitFileState, ActiveState, SubState, Result, ExecMainStatus, ExecStartProperty, User, WorkingDirectoryProperty, Unit, TimersMonotonic, + systemd_unit_property_wire, +} +import extdeps.systemd.unit_file { + SystemdUnitFile, ServiceUnitFile, TimerUnitFile, SliceUnitFile, + SystemdServiceDirective, SystemdTimerDirective, + OnBootSec, + OnUnitInactiveSec, + AccuracySec, +} + +// EXACT SYSTEMD SERVICE AND TIMER STANDING, MODELED ON WHAT THE EMITTED UNITS ACTUALLY ARE. +// Readiness plus a fresh tick receipt cannot prove the belt timer is enabled now: a service can be +// invoked by hand, write one fresh receipt, and sit behind a disabled timer. The RLM-2 ruling +// (section 7) therefore keeps unit standing in scope and rejects the naive 'service and timer both +// enabled and active', because gunbc.live_deploy.emit deliberately gives the three units different +// semantics -- the dashboard serve service is Type=simple, the belt service is Type=oneshot with no +// [Install] section, and the belt timer is what is enabled under timers.target. The positive +// standing is one row per unit, and the discriminating POSITIVE control is that a correctly +// completed, currently INACTIVE oneshot stays green. +// +// THE OBSERVER IS `systemctl show --property=X --value` (extdeps.systemd.systemctl ShowProperty), +// system scope, which this module widened with the properties it needs rather than adding a second +// show surface. There is still no modeled system-scope `is-enabled` operation (systemctl.dag's own +// note); UnitFileState carries the same fact through the show surface, which is the read that +// already existed. +// +// WHAT IS COMPARED EXACTLY, member by member, stated so any residue would be visible. +// ExecStart, User and WorkingDirectory are compared exactly against the directives of R's emitted +// unit file (gunbc.live_deploy.emit live_deploy_serve_unit_file / +// live_deploy_belt_service_unit_file), reading systemd's `{ path=... ; argv[]=... ; ... }` +// rendering by its `argv[]=` segment. The timer's target unit is compared exactly through the Unit +// property. The timer's cadence VALUES are compared exactly too: OnBootUSec and OnUnitInactiveUSec +// from TimersMonotonic and the AccuracyUSec property are each normalized through +// extdeps.systemd systemd_duration_usec (systemd.time(7), microsecond resolution) and compared +// against the Microsecond expectations derived from R's emitted timer unit -- '60s' and '1min' +// are one value, and a spelling that stops normalizing refuses rather than passing structurally. +// This dissolves the former declared residue (cadence compared by entry presence only), whose +// trigger -- an extdeps.systemd duration model -- is the mechanism that landed beside this text. +// +// EVERY REFUSAL NAMES THE UNIT AND THE PROPERTY that disagreed; an unreadable property is a +// refusal, never a pass by vacancy. +type UnitPropertyRead + = UnitPropertyValue { value: String } + | UnitPropertyUnreadable { unit: String, property: String } + +fn read_unit_property(unit: NonEmptyStr, property: SystemdUnitProperty) -> UnitPropertyRead { + let got = systemd.Systemctl.ShowProperty(unit: unit, property: property) + if got.success { + UnitPropertyValue { value: trim(s: got.value) } + } else { + UnitPropertyUnreadable { unit: unit as String, property: systemd_unit_property_wire(property: property) as String } + } +} + +type ServiceUnitObserved { + load_state: String + unit_file_state: String + active_state: String + sub_state: String + result: String + exec_main_status: String + exec_start: String + user: String + working_directory: String +} + +type TimerUnitObserved { + load_state: String + unit_file_state: String + active_state: String + sub_state: String + unit: String + timers_monotonic: String + accuracy_usec_value: String +} + +type ServiceUnitObservation + = ServiceUnitObservedOk { observed: ServiceUnitObserved } + | ServiceUnitUnobservable { unit: String, property: String } + +type TimerUnitObservation + = TimerUnitObservedOk { observed: TimerUnitObserved } + | TimerUnitUnobservable { unit: String, property: String } + +fn observe_service_unit(unit: NonEmptyStr) -> ServiceUnitObservation { + match read_unit_property(unit: unit, property: LoadState) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: load_state } => + match read_unit_property(unit: unit, property: UnitFileState) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: unit_file_state } => + match read_unit_property(unit: unit, property: ActiveState) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: active_state } => + match read_unit_property(unit: unit, property: SubState) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: sub_state } => + match read_unit_property(unit: unit, property: Result) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: result } => + match read_unit_property(unit: unit, property: ExecMainStatus) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: exec_main_status } => + match read_unit_property(unit: unit, property: ExecStartProperty) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: exec_start } => + match read_unit_property(unit: unit, property: User) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: user } => + match read_unit_property(unit: unit, property: WorkingDirectoryProperty) { + UnitPropertyUnreadable { unit: u, property: p } => ServiceUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: wd } => + ServiceUnitObservedOk { + observed: ServiceUnitObserved { + load_state: load_state, + unit_file_state: unit_file_state, + active_state: active_state, + sub_state: sub_state, + result: result, + exec_main_status: exec_main_status, + exec_start: exec_start, + user: user, + working_directory: wd, + }, + } + } + } + } + } + } + } + } + } + } +} + +fn observe_timer_unit(unit: NonEmptyStr) -> TimerUnitObservation { + match read_unit_property(unit: unit, property: LoadState) { + UnitPropertyUnreadable { unit: u, property: p } => TimerUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: load_state } => + match read_unit_property(unit: unit, property: UnitFileState) { + UnitPropertyUnreadable { unit: u, property: p } => TimerUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: unit_file_state } => + match read_unit_property(unit: unit, property: ActiveState) { + UnitPropertyUnreadable { unit: u, property: p } => TimerUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: active_state } => + match read_unit_property(unit: unit, property: SubState) { + UnitPropertyUnreadable { unit: u, property: p } => TimerUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: sub_state } => + match read_unit_property(unit: unit, property: Unit) { + UnitPropertyUnreadable { unit: u, property: p } => TimerUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: target } => + match read_unit_property(unit: unit, property: TimersMonotonic) { + UnitPropertyUnreadable { unit: u, property: p } => TimerUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: timers } => + match read_unit_property(unit: unit, property: AccuracyUSec) { + UnitPropertyUnreadable { unit: u, property: p } => TimerUnitUnobservable { unit: u, property: p } + UnitPropertyValue { value: accuracy } => + TimerUnitObservedOk { + observed: TimerUnitObserved { + load_state: load_state, + unit_file_state: unit_file_state, + active_state: active_state, + sub_state: sub_state, + unit: target, + timers_monotonic: timers, + accuracy_usec_value: accuracy, + }, + } + } + } + } + } + } + } + } +} + +// THE EXPECTATION IS DERIVED FROM R'S EMITTED UNIT FILE, never typed beside it. +type ServiceUnitExpectation { + exec_start: String + user: String + working_directory: String +} + +type ServiceUnitExpectationDerivation + = ServiceExpectationDerived { expectation: ServiceUnitExpectation } + | ServiceExpectationUnderivable { detail: String } + +fn service_directive_exec_start(directives: List) -> String? { + fold(directives, init: none, f: (acc, d) => match acc { + Present { value: v } => Present { value: v } + Absent => match d { + ExecStart { command: c } => Present { value: c as String } + _ => none + } + }) +} + +fn service_directive_user(directives: List) -> String? { + fold(directives, init: none, f: (acc, d) => match acc { + Present { value: v } => Present { value: v } + Absent => match d { + ServiceUser { name: n } => Present { value: n as String } + _ => none + } + }) +} + +fn service_directive_working_directory(directives: List) -> String? { + fold(directives, init: none, f: (acc, d) => match acc { + Present { value: v } => Present { value: v } + Absent => match d { + WorkingDirectory { path: p } => Present { value: p as String } + _ => none + } + }) +} + +fn service_unit_expectation_of(file: SystemdUnitFile) -> ServiceUnitExpectationDerivation { + match file { + ServiceUnitFile { unit: _, service: directives, install: _ } => + match service_directive_exec_start(directives: directives) { + Absent => ServiceExpectationUnderivable { detail: "emitted service unit carries no ExecStart" } + Present { value: exec_start } => + match service_directive_user(directives: directives) { + Absent => ServiceExpectationUnderivable { detail: "emitted service unit carries no User" } + Present { value: user } => + match service_directive_working_directory(directives: directives) { + Absent => ServiceExpectationUnderivable { detail: "emitted service unit carries no WorkingDirectory" } + Present { value: wd } => + ServiceExpectationDerived { expectation: ServiceUnitExpectation { exec_start: exec_start, user: user, working_directory: wd } } + } + } + } + TimerUnitFile { unit: _, timer: _, install: _ } => ServiceExpectationUnderivable { detail: "expected a service unit file, got a timer" } + SliceUnitFile { unit: _, slice: _, install: _ } => ServiceExpectationUnderivable { detail: "expected a service unit file, got a slice" } + } +} + +type TimerUnitExpectation { + target_unit: String + on_boot_usec: Microsecond + on_unit_inactive_usec: Microsecond + accuracy_usec: Microsecond +} + +// The value rendered after `Name=` in a systemctl show composite like +// `{ OnBootUSec=2min ; next_elapse=... }, { OnUnitInactiveUSec=1min ; ... }`: the token between +// the prefix and the next ` ;`, `,` or `}` boundary, normalized through systemd.time(7). +fn show_rendered_duration_usec(rendering: String, prefix: String) -> Microsecond? { + let parts = split(s: rendering, delimiter: prefix) + if list_length(items: parts) < 2 { + none + } else { + let tail = fold(parts, init: "", f: (a, seg) => seg) + let token = fold(split(s: tail, delimiter: " ; "), init: "", f: (acc, seg) => if acc == "" { seg } else { acc }) + systemd_duration_usec(text: trim(s: fold(split(s: token, delimiter: "}"), init: "", f: (acc, seg) => if acc == "" { seg } else { acc }))) + } +} + +fn timer_on_boot_duration(directives: List) -> Microsecond? { + fold(directives, init: none, f: (acc, d) => + match acc { + Present { value: v } => Present { value: v } + Absent => match d { OnBootSec { duration: t } => systemd_duration_usec(text: t as String) _ => none } + }) +} + +fn timer_on_unit_inactive_duration(directives: List) -> Microsecond? { + fold(directives, init: none, f: (acc, d) => + match acc { + Present { value: v } => Present { value: v } + Absent => match d { OnUnitInactiveSec { duration: t } => systemd_duration_usec(text: t as String) _ => none } + }) +} + +fn timer_accuracy_duration(directives: List) -> Microsecond? { + fold(directives, init: none, f: (acc, d) => + match acc { + Present { value: v } => Present { value: v } + Absent => match d { AccuracySec { duration: t } => systemd_duration_usec(text: t as String) _ => none } + }) +} + +type TimerUnitExpectationDerivation + = TimerExpectationDerived { expectation: TimerUnitExpectation } + | TimerExpectationUnderivable { detail: String } + +fn timer_directive_target(directives: List) -> String? { + fold(directives, init: none, f: (acc, d) => match acc { + Present { value: v } => Present { value: v } + Absent => match d { + TimerUnit { target: t } => Present { value: t as String } + _ => none + } + }) +} + +fn timer_unit_expectation_of(file: SystemdUnitFile) -> TimerUnitExpectationDerivation { + match file { + TimerUnitFile { unit: _, timer: directives, install: _ } => + match timer_directive_target(directives: directives) { + Absent => TimerExpectationUnderivable { detail: "emitted timer unit carries no Unit= target" } + Present { value: t } => + match timer_on_boot_duration(directives: directives) { + Absent => TimerExpectationUnderivable { detail: "emitted timer unit carries no normalizable OnBootSec" } + Present { value: boot } => + match timer_on_unit_inactive_duration(directives: directives) { + Absent => TimerExpectationUnderivable { detail: "emitted timer unit carries no normalizable OnUnitInactiveSec" } + Present { value: inactive } => + match timer_accuracy_duration(directives: directives) { + Absent => TimerExpectationUnderivable { detail: "emitted timer unit carries no normalizable AccuracySec" } + Present { value: accuracy } => + TimerExpectationDerived { expectation: TimerUnitExpectation { + target_unit: t, on_boot_usec: boot, on_unit_inactive_usec: inactive, accuracy_usec: accuracy, + } } + } + } + } + } + ServiceUnitFile { unit: _, service: _, install: _ } => TimerExpectationUnderivable { detail: "expected a timer unit file, got a service" } + SliceUnitFile { unit: _, slice: _, install: _ } => TimerExpectationUnderivable { detail: "expected a timer unit file, got a slice" } + } +} + +// systemctl show renders ExecStart as `{ path=P ; argv[]=A B C ; ignore_errors=no ; ... }`. The +// argv[] segment is the command line the unit file declared, which is what the emitted ExecStart +// directive is, so that segment is the exact comparand. +type ExecStartRead + = ExecStartArgv { argv_text: String } + | ExecStartUnparseable { value: String } + +fn exec_start_argv_of_show_value(value: String) -> ExecStartRead { + let parts = split(s: value, delimiter: "argv[]=") + if list_length(items: parts) < 2 { + ExecStartUnparseable { value: value } + } else { + let tail = fold(parts, init: "", f: fn(_, seg) { seg }) + let argv_text = fold(split(s: tail, delimiter: " ; "), init: "", f: fn(acc, seg) { if acc == "" { seg } else { acc } }) + if argv_text == "" { ExecStartUnparseable { value: value } } else { ExecStartArgv { argv_text: trim(s: argv_text) } } + } +} + +type DashboardServiceStanding + = DashboardServiceConverged { unit: String } + | DashboardServiceRefused { unit: String, property: String, expected: String, observed: String } + +type BeltTimerStanding + = BeltTimerConverged { unit: String } + | BeltTimerRefused { unit: String, property: String, expected: String, observed: String } + +type BeltOneshotStanding + = BeltOneshotConverged { unit: String, active_state: String } + | BeltOneshotRefused { unit: String, property: String, expected: String, observed: String } + +fn judge_exec_start(unit: String, expected: String, observed_show: String) -> DashboardServiceStanding? { + match exec_start_argv_of_show_value(value: observed_show) { + ExecStartUnparseable { value: v } => + Present { value: DashboardServiceRefused { unit: unit, property: "ExecStart", expected: expected, observed: v } } + ExecStartArgv { argv_text: a } => + if a == expected { none } else { + Present { value: DashboardServiceRefused { unit: unit, property: "ExecStart", expected: expected, observed: a } } + } + } +} + +fn judge_dashboard_service(unit: String, expect: ServiceUnitExpectation, o: ServiceUnitObserved) -> DashboardServiceStanding { + if o.load_state != "loaded" { + DashboardServiceRefused { unit: unit, property: "LoadState", expected: "loaded", observed: o.load_state } + } else if o.unit_file_state != "enabled" { + DashboardServiceRefused { unit: unit, property: "UnitFileState", expected: "enabled", observed: o.unit_file_state } + } else if o.active_state != "active" { + DashboardServiceRefused { unit: unit, property: "ActiveState", expected: "active", observed: o.active_state } + } else if o.user != expect.user { + DashboardServiceRefused { unit: unit, property: "User", expected: expect.user, observed: o.user } + } else if o.working_directory != expect.working_directory { + DashboardServiceRefused { unit: unit, property: "WorkingDirectory", expected: expect.working_directory, observed: o.working_directory } + } else { + match judge_exec_start(unit: unit, expected: expect.exec_start, observed_show: o.exec_start) { + Present { value: refused } => refused + Absent => DashboardServiceConverged { unit: unit } + } + } +} + +fn judge_belt_timer(unit: String, expect: TimerUnitExpectation, o: TimerUnitObserved) -> BeltTimerStanding { + if o.load_state != "loaded" { + BeltTimerRefused { unit: unit, property: "LoadState", expected: "loaded", observed: o.load_state } + } else if o.unit_file_state != "enabled" { + BeltTimerRefused { unit: unit, property: "UnitFileState", expected: "enabled", observed: o.unit_file_state } + } else if o.active_state != "active" { + BeltTimerRefused { unit: unit, property: "ActiveState", expected: "active", observed: o.active_state } + } else if o.sub_state != "waiting" && o.sub_state != "running" { + BeltTimerRefused { unit: unit, property: "SubState", expected: "waiting|running", observed: o.sub_state } + } else if o.unit != expect.target_unit { + BeltTimerRefused { unit: unit, property: "Unit", expected: expect.target_unit, observed: o.unit } + } else { + match show_rendered_duration_usec(rendering: o.timers_monotonic, prefix: "OnBootUSec=") { + Absent => BeltTimerRefused { unit: unit, property: "TimersMonotonic", expected: "a normalizable OnBootUSec entry", observed: o.timers_monotonic } + Present { value: boot } => + if microsecond_count(boot) != microsecond_count(expect.on_boot_usec) { + BeltTimerRefused { unit: unit, property: "OnBootUSec", expected: to_string(microsecond_count(expect.on_boot_usec)), observed: to_string(microsecond_count(boot)) } + } else { + match show_rendered_duration_usec(rendering: o.timers_monotonic, prefix: "OnUnitInactiveUSec=") { + Absent => BeltTimerRefused { unit: unit, property: "TimersMonotonic", expected: "a normalizable OnUnitInactiveUSec entry", observed: o.timers_monotonic } + Present { value: inactive } => + if microsecond_count(inactive) != microsecond_count(expect.on_unit_inactive_usec) { + BeltTimerRefused { unit: unit, property: "OnUnitInactiveUSec", expected: to_string(microsecond_count(expect.on_unit_inactive_usec)), observed: to_string(microsecond_count(inactive)) } + } else { + match systemd_duration_usec(text: trim(s: o.accuracy_usec_value)) { + Absent => BeltTimerRefused { unit: unit, property: "AccuracyUSec", expected: "a normalizable duration", observed: o.accuracy_usec_value } + Present { value: accuracy } => + if microsecond_count(accuracy) != microsecond_count(expect.accuracy_usec) { + BeltTimerRefused { unit: unit, property: "AccuracyUSec", expected: to_string(microsecond_count(expect.accuracy_usec)), observed: to_string(microsecond_count(accuracy)) } + } else { + BeltTimerConverged { unit: unit } + } + } + } + } + } + } + } +} + +// THE ONESHOT IS NOT SEPARATELY ENABLED (it has no [Install] section, so its UnitFileState is +// `static`), and it is normally INACTIVE between ticks. Both are the correct state and both are +// accepted; what is required is that its last run SUCCEEDED and that what it runs is exactly what R +// emitted. +fn judge_belt_oneshot(unit: String, expect: ServiceUnitExpectation, o: ServiceUnitObserved) -> BeltOneshotStanding { + if o.load_state != "loaded" { + BeltOneshotRefused { unit: unit, property: "LoadState", expected: "loaded", observed: o.load_state } + } else if o.unit_file_state != "static" { + BeltOneshotRefused { unit: unit, property: "UnitFileState", expected: "static", observed: o.unit_file_state } + } else if o.active_state != "inactive" && o.active_state != "active" && o.active_state != "activating" { + BeltOneshotRefused { unit: unit, property: "ActiveState", expected: "inactive|active|activating", observed: o.active_state } + } else if o.result != "success" { + BeltOneshotRefused { unit: unit, property: "Result", expected: "success", observed: o.result } + } else if o.exec_main_status != "0" { + BeltOneshotRefused { unit: unit, property: "ExecMainStatus", expected: "0", observed: o.exec_main_status } + } else if o.user != expect.user { + BeltOneshotRefused { unit: unit, property: "User", expected: expect.user, observed: o.user } + } else if o.working_directory != expect.working_directory { + BeltOneshotRefused { unit: unit, property: "WorkingDirectory", expected: expect.working_directory, observed: o.working_directory } + } else { + match exec_start_argv_of_show_value(value: o.exec_start) { + ExecStartUnparseable { value: v } => + BeltOneshotRefused { unit: unit, property: "ExecStart", expected: expect.exec_start, observed: v } + ExecStartArgv { argv_text: a } => + if a == expect.exec_start { + BeltOneshotConverged { unit: unit, active_state: o.active_state } + } else { + BeltOneshotRefused { unit: unit, property: "ExecStart", expected: expect.exec_start, observed: a } + } + } + } +} + +type LaunchUnitStanding { + dashboard: DashboardServiceStanding + timer: BeltTimerStanding + oneshot: BeltOneshotStanding +} + +fn dashboard_service_standing_text(s: DashboardServiceStanding) -> String { + match s { + DashboardServiceConverged { unit: u } => join(["dashboard service ", u, " converged"], "") + DashboardServiceRefused { unit: u, property: p, expected: e, observed: o } => + join(["dashboard service ", u, " ", p, " expected ", e, " observed ", o], "") + } +} + +fn belt_timer_standing_text(s: BeltTimerStanding) -> String { + match s { + BeltTimerConverged { unit: u } => join(["belt timer ", u, " converged"], "") + BeltTimerRefused { unit: u, property: p, expected: e, observed: o } => + join(["belt timer ", u, " ", p, " expected ", e, " observed ", o], "") + } +} + +fn belt_oneshot_standing_text(s: BeltOneshotStanding) -> String { + match s { + BeltOneshotConverged { unit: u, active_state: a } => join(["belt service ", u, " converged (", a, ")"], "") + BeltOneshotRefused { unit: u, property: p, expected: e, observed: o } => + join(["belt service ", u, " ", p, " expected ", e, " observed ", o], "") + } +} + +fn launch_unit_standing_text(s: LaunchUnitStanding) -> String { + join([ + dashboard_service_standing_text(s: s.dashboard), "; ", + belt_timer_standing_text(s: s.timer), "; ", + belt_oneshot_standing_text(s: s.oneshot), + ], "") +} + +// THE LIVE READ, one per unit, each judged against the expectation derived from R's emission. +fn observe_dashboard_service_standing(unit: NonEmptyStr, file: SystemdUnitFile) -> DashboardServiceStanding { + match service_unit_expectation_of(file: file) { + ServiceExpectationUnderivable { detail: d } => + DashboardServiceRefused { unit: unit as String, property: "expectation", expected: "derivable from emitted unit", observed: d } + ServiceExpectationDerived { expectation: e } => + match observe_service_unit(unit: unit) { + ServiceUnitUnobservable { unit: u, property: p } => + DashboardServiceRefused { unit: u, property: p, expected: "readable", observed: "systemctl show refused" } + ServiceUnitObservedOk { observed: o } => judge_dashboard_service(unit: unit as String, expect: e, o: o) + } + } +} + +fn observe_belt_timer_standing(unit: NonEmptyStr, file: SystemdUnitFile) -> BeltTimerStanding { + match timer_unit_expectation_of(file: file) { + TimerExpectationUnderivable { detail: d } => + BeltTimerRefused { unit: unit as String, property: "expectation", expected: "derivable from emitted unit", observed: d } + TimerExpectationDerived { expectation: e } => + match observe_timer_unit(unit: unit) { + TimerUnitUnobservable { unit: u, property: p } => + BeltTimerRefused { unit: u, property: p, expected: "readable", observed: "systemctl show refused" } + TimerUnitObservedOk { observed: o } => judge_belt_timer(unit: unit as String, expect: e, o: o) + } + } +} + +fn observe_belt_oneshot_standing(unit: NonEmptyStr, file: SystemdUnitFile) -> BeltOneshotStanding { + match service_unit_expectation_of(file: file) { + ServiceExpectationUnderivable { detail: d } => + BeltOneshotRefused { unit: unit as String, property: "expectation", expected: "derivable from emitted unit", observed: d } + ServiceExpectationDerived { expectation: e } => + match observe_service_unit(unit: unit) { + ServiceUnitUnobservable { unit: u, property: p } => + BeltOneshotRefused { unit: u, property: p, expected: "readable", observed: "systemctl show refused" } + ServiceUnitObservedOk { observed: o } => judge_belt_oneshot(unit: unit as String, expect: e, o: o) + } + } +} diff --git a/dag/gunbc/roadmap/roadmap_belt_actuate.dag b/dag/gunbc/roadmap/roadmap_belt_actuate.dag index f3a57790275..05c7392bf2a 100644 --- a/dag/gunbc/roadmap/roadmap_belt_actuate.dag +++ b/dag/gunbc/roadmap/roadmap_belt_actuate.dag @@ -13,7 +13,7 @@ import gunbc.roadmap_authority { authored_merged_prs, } import gunbc.roadmap_belt { - SpawnMode, Paused, ManualReady, AutomaticReady, spawn_mode_label, + SpawnMode, Paused, ManualReady, AutomaticReady, spawn_mode_label, spawn_mode_of_label, BeltCapacity, BeltReconcile, belt_reconcile, @@ -347,6 +347,7 @@ import gunbc.roadmap_dispatch_actuator { host_exec_argv } import gunbc.roadmap_provider_events { ProviderCompleted, provider_execution_activity } import extdeps.version { VersionIdentity } import std.content_hash { + content_hash_tagged_structural, ContentHash, serialize_content_hash, content_hash_atom, @@ -416,7 +417,6 @@ import std.decl_ref { DeclarationRef, WholeDeclaration } // move together. Fail-closed: which-miss or auth uncertainty refuses before git worktree add; a // worktree failure short-circuits tmux spawn; a nonzero provider process after tmux accepted is // runtime evidence, not spawn evidence, never absorbed into Spawned. - data belt_actuate_placement_dissolution_trigger: Disposition = Scaffold { dissolves_to: RealizationDispatch, bind: DeclarationRef { @@ -454,7 +454,6 @@ type BeltExecOutcome // shell.Which.Check (crisp-wren-896, PR #8590): srv1's OperatingSystemSurface has // distro_or_product: none, so `which` was never an established fact; command -v's strictly weaker // assumption dominates. - // This module's probes are annotated OutcomeIsData (operator ruling 2026-07-25): their exit codes // are ANSWERS the belt adjudicates, not faults. belt_program_available's which-miss is the 'program // missing' pole belt_program_available_note keeps distinct from 'present but exited nonzero'; @@ -465,7 +464,6 @@ type BeltExecOutcome // availability probe (consumed as the refuse/fail discriminator, never discarded), BeltExecOutcome, // BeltObserve — never unit. Dispatch failure is not an exit code, stays anomalous, and does not // travel this arm. - fn belt_program_available(program: String) -> Bool { shell.PosixCommandV.Check(command: program as NonEmptyStr, expect: OutcomeIsData).exists } @@ -496,7 +494,6 @@ fn belt_exec(cmd: HostExecArgv, workdir: String) -> BeltExecOutcome { // tmux-new-session ordering rides this: a failed git worktree add never spawns a tmux session on a // missing worktree. Discriminating control: without the ExecOk-guarded short-circuit, [false, echo] // would run echo and go green. - fn belt_exec_all(cmds: List, workdir: String) -> BeltExecOutcome { fold(cmds, init: ExecOk { stdout: "" }, f: fn(acc, cmd) { match acc { @@ -552,7 +549,6 @@ type AttemptStateInitialization // current attempt is constructive proof that admission, workspace creation and event-capture // initialization preceded launch; the progress route validates the admitted receipt rather than // inferring stages from a tmux name. - fn belt_attempt_state_initialize_for_instance( instance: HostDashboardInstance, node: RoadmapNode, @@ -671,7 +667,6 @@ fn belt_attempt_state_initialize_for_instance( // the spawn (refusing on a verification-side deficit trades a whole attempt for a receipt) but is // never silent: the reason is persisted to its own file, so verification reports a located cause, // not an absent pin, and the deficit stays countable at the Verify lamp. - type AttemptOraclePinCapture = OraclePinRecorded { identity: ContentHash, @@ -687,7 +682,6 @@ type AttemptOraclePinCapture // as exercised, the PRIMITIVES they call never. The corpus had zero filter_map call sites before. // flat_map is the same fold with a registered implementation; a one-or-zero element list is exactly // the Present/none the contract described. - fn belt_oracle_refs_for_contract( contract: WorkItemExecutionContract, ) -> List { @@ -1012,14 +1006,12 @@ fn belt_persist_spawn_failure_for_instance( // carried as the typed LaunchRefusal (never flattened into a SpawnFailed step string) so the manual // route, tick receipt and page project one identity and reason. Not a failure: a ManualReady tick // reports one per Ready node it would have started, and the pass outcome stays Recorded. - // Spawned and SpawnFailed carry the exact LaunchIdentity that admitted the attempt (review // 5059520727 finding 4: every surface matched `LaunchAdmitted { identity: _ }` and discarded it). // The identity is threaded from the admission site through every actuation step — route response, // tick outcome list, durable tick receipt (BeltTickReceipt.launches) and the attempt's state // directory (launch-identity.json, beside environment-admission.json) — so RLM-4 joins an attempt // to its admission by construction. SpawnNotAdmitted has no identity: no admission produced one. - type BeltSpawnOutcome = Spawned { node_id: String, session_name: String, provider: String, launch: LaunchIdentity } | SpawnFailed { node_id: String, step: String, detail: String, provider: String, launch: LaunchIdentity } @@ -1053,7 +1045,6 @@ fn belt_dispatch_spawn_provider_wire_label(provider: DispatchCliProvider) -> Str // refuse-with-instructions, not auto-seed — the trust store is claude's own runtime-mutable file, // so a read-modify-write APPLY arm races the upstream writer; seeding stays with the live_deploy // membership convergence (the go-live receipt's named dissolution). - type BeltClaudePreflight = ClaudePreflightOk | ClaudePreflightRefused { step: String, detail: String } @@ -1141,7 +1132,6 @@ type BeltCodexPreflight // retained as the method receipt; auth.json is never parsed and file presence is never // authentication. Subscription exhaustion and upstream/rate failures occur only after codex exec // starts and belong to provider JSONL (`turn.failed` / `error`) plus retained process exit. - fn belt_codex_preflight_decision_observed_for_home( codex_home: String, codex_on_path: Bool, @@ -1352,7 +1342,6 @@ fn belt_provider_preflight(provider: DispatchCliProvider) -> BeltProviderPreflig // BeltFootprintObservation and belt_footprint_admission_for_instance returns // BeltFootprintAdmission, so each exit feeds a typed consumer, never unit. Dispatch failure of the // probe ITSELF stays anomalous, not silenced by the annotation. - type BeltFootprintRoot { member: String path: String @@ -1557,7 +1546,6 @@ fn belt_actuate_spawn_exec_for_instance( // guess. Worktree, branch and attempt evidence are retained for diagnosis and user work. The // located failure plus cleanup receipt is persisted under the published attempt before SpawnFailed // returns. - fn belt_actuate_spawn_exec_modeled( instance: HostDashboardInstance, node: RoadmapNode, @@ -1837,7 +1825,6 @@ fn belt_actuate_teardown(session_name: String) -> BeltTeardownOutcome { // teardowns ObservedMembers (carry the live session). The cross arms are unreachable by // construction; if reached they REFUSE (typed SpawnFailed/TeardownFailed), never a fabricated // action — DESIGN §5 (no spawn without a node model, no teardown without an observed session). - data belt_session_uuid_salt: NonEmptyStr = "belt-session-uuid-v1" as NonEmptyStr // The UUID is the Claude provider's --session-id input (Claude requires a valid UUID). It is @@ -1846,7 +1833,6 @@ data belt_session_uuid_salt: NonEmptyStr = "belt-session-uuid-v1" as NonEmptyStr // redispatch fix 2026-07-25): (node_id x the spawn's clock observation) reshaped to RFC-4122 v4; // the earlier node-deterministic derivation made every node one-shot at the Claude layer. Reconcile // idempotency never keys on it — the node-grain tmux session name is the observation key. - fn belt_uuid_stamped_id(node_id: String, probed_at: String) -> NonEmptyStr { join([node_id, "\n", probed_at], "") as NonEmptyStr } @@ -1937,7 +1923,6 @@ type BeltObserve // located evidence, never a blind spawn/reap. First-tick bootstrap works under either no-server // spelling the classifier admits. The decision is a pure fn so every arm is witnessed // (belt_observe_from_result witnesses); belt_observe only threads live WitnessBin.Run fields in. - fn belt_ls_stdout_refusal_reason( line_number: Int, raw: String, @@ -1995,15 +1980,25 @@ fn belt_observe_from_result( // classify_tmux_session_list, which the live observer uses; dissolve-on: those parse witnesses // re-pointed at classify_tmux_session_list with the refusal cases preserved one-for-one, at which // point this function and this note go together. - type BeltPassOutcome = BeltPassRecorded | BeltPassDeferred { reason: String } | BeltPassRefused { reason: String } | BeltPassFailed { reason: String } +// THE EXECUTION STANDING (RLM-2 ruling section 6): a receipt names the instance that ticked, the +// revision the instance was serving, and the mode the tick ran under -- or says why it could not +// bind those -- so a reader can tell a fresh tick of THIS deployment from any other file that +// parses. `receipt_identity` is DERIVED from the members (belt_tick_receipt_identity_hex), written +// on the wire and verified on decode; it is not a stored field, because a stored copy would be a +// second authority for what the members already say. +type BeltTickExecutionStanding + = TickExecuted { instance_id: String, deployed_revision: String, spawn_mode: SpawnMode } + | TickExecutionWithheld { refusal: String } + type BeltTickReceipt { observed_at: String, + execution: BeltTickExecutionStanding, launches: List, spawn_pass: BeltPassOutcome, teardown_pass: BeltPassOutcome, @@ -2031,8 +2026,7 @@ type BeltTickReceiptRead // DispatchStateHostShared, so blue and green resolve it to ONE path and a receipt there would // answer for two instances. A note describing a home the implementation did not and could not // safely use is the stale-authority class of DESIGN section 3. - -data belt_tick_receipt_schema: String = "roadmap-belt-tick-receipt/v3" +data belt_tick_receipt_schema: String = "roadmap-belt-tick-receipt/v4" // THE RECEIPT HAD NO WRITABLE HOME AND HAS NEVER BEEN WRITTEN ON ANY INSTANCE. The path was // /belt-last-tick.json; every instance_root on srv1 is root:root 0755 while the belt @@ -2163,15 +2157,30 @@ fn belt_tick_receipt_decode(raw: String) -> BeltTickReceiptRead { BeltTickLaunchesUnreadable { reason: r } => BeltTickReceiptUnreadable { reason: r } BeltTickLaunchesDecoded { launches } => - BeltTickReceiptPresent { - receipt: BeltTickReceipt { - observed_at: observed_at, - launches: launches, - spawn_pass: spawn_pass, - teardown_pass: teardown_pass, - verify_pass: verify_pass, - publish_pass: publish_pass, - }, + match belt_tick_execution_from_member(doc: doc) { + BeltTickExecutionUnreadable { reason: r } => + BeltTickReceiptUnreadable { reason: r } + BeltTickExecutionDecoded { execution } => { + let receipt = BeltTickReceipt { + observed_at: observed_at, + execution: execution, + launches: launches, + spawn_pass: spawn_pass, + teardown_pass: teardown_pass, + verify_pass: verify_pass, + publish_pass: publish_pass, + } + match json_object_unique_member(v: doc, key: "receipt_identity") { + JsonMemberFound { value: JsonString { value: claimed } } => + if claimed != belt_tick_receipt_identity_hex(receipt: receipt) { + BeltTickReceiptUnreadable { reason: "belt tick receipt identity does not match its members" } + } else { + BeltTickReceiptPresent { receipt: receipt } + } + _ => + BeltTickReceiptUnreadable { reason: "belt tick receipt receipt_identity is missing" } + } + } } } } @@ -2254,10 +2263,102 @@ fn belt_tick_receipt_read_for_instance( ) } +fn belt_tick_execution_json(e: BeltTickExecutionStanding) -> JsonValue { + match e { + TickExecuted { instance_id: i, deployed_revision: r, spawn_mode: m } => + json_object(members: [ + json_kv(key: "standing", value: json_string(s: "executed")), + json_kv(key: "instance_id", value: json_string(s: i)), + json_kv(key: "deployed_revision", value: json_string(s: r)), + json_kv(key: "spawn_mode", value: json_string(s: spawn_mode_label(m: m))), + ]) + TickExecutionWithheld { refusal: why } => + json_object(members: [ + json_kv(key: "standing", value: json_string(s: "withheld")), + json_kv(key: "refusal", value: json_string(s: why)), + ]) + } +} + +fn belt_tick_execution_text(e: BeltTickExecutionStanding) -> String { + match e { + TickExecuted { instance_id: i, deployed_revision: r, spawn_mode: m } => + join(["executed|", i, "|", r, "|", spawn_mode_label(m: m)], "") + TickExecutionWithheld { refusal: why } => join(["withheld|", why], "") + } +} + +fn belt_tick_receipt_identity_text(receipt: BeltTickReceipt) -> String { + join([ + receipt.observed_at, "|", + belt_tick_execution_text(e: receipt.execution), "|", + join(receipt.launches |> map(l => serialize_json(v: launch_identity_json(id: l))), ","), "|", + belt_pass_outcome_key(o: receipt.spawn_pass), "|", + belt_pass_outcome_key(o: receipt.teardown_pass), "|", + belt_pass_outcome_key(o: receipt.verify_pass), "|", + belt_pass_outcome_key(o: receipt.publish_pass), + ], "") +} + +fn belt_tick_receipt_identity_hex(receipt: BeltTickReceipt) -> String { + content_hash_tagged_structural( + tag: "roadmap-belt-tick-receipt-v4" as NonEmptyStr, + payload: content_hash_atom(value: belt_tick_receipt_identity_text(receipt: receipt) as NonEmptyStr), + ).digest as String +} + +type BeltTickExecutionDecode + = BeltTickExecutionDecoded { execution: BeltTickExecutionStanding } + | BeltTickExecutionUnreadable { reason: String } + +fn belt_tick_execution_from_member(doc: JsonValue) -> BeltTickExecutionDecode { + match json_object_unique_member(v: doc, key: "execution") { + JsonMemberFound { value: inner } => + match json_object_unique_member(v: inner, key: "standing") { + JsonMemberFound { value: JsonString { value: standing } } => + match standing { + "executed" => + match json_object_unique_member(v: inner, key: "instance_id") { + JsonMemberFound { value: JsonString { value: instance_id } } => + match json_object_unique_member(v: inner, key: "deployed_revision") { + JsonMemberFound { value: JsonString { value: revision } } => + match json_object_unique_member(v: inner, key: "spawn_mode") { + JsonMemberFound { value: JsonString { value: mode_label } } => + match spawn_mode_of_label(label: mode_label) { + Absent => BeltTickExecutionUnreadable { reason: join(["belt tick receipt execution spawn_mode is unknown: ", mode_label], "") } + Present { value: mode } => + BeltTickExecutionDecoded { + execution: TickExecuted { instance_id: instance_id, deployed_revision: revision, spawn_mode: mode }, + } + } + _ => BeltTickExecutionUnreadable { reason: "belt tick receipt execution spawn_mode is missing" } + } + _ => BeltTickExecutionUnreadable { reason: "belt tick receipt execution deployed_revision is missing" } + } + _ => BeltTickExecutionUnreadable { reason: "belt tick receipt execution instance_id is missing" } + } + "withheld" => + match json_object_unique_member(v: inner, key: "refusal") { + JsonMemberFound { value: JsonString { value: why } } => + BeltTickExecutionDecoded { execution: TickExecutionWithheld { refusal: why } } + _ => BeltTickExecutionUnreadable { reason: "belt tick receipt execution withheld without refusal" } + } + _ => BeltTickExecutionUnreadable { reason: join(["belt tick receipt execution standing is unknown: ", standing], "") } + } + _ => BeltTickExecutionUnreadable { reason: "belt tick receipt execution standing is missing" } + } + JsonMemberAbsent => BeltTickExecutionUnreadable { reason: "belt tick receipt execution is missing" } + JsonMemberDuplicated { count: _ } => BeltTickExecutionUnreadable { reason: "belt tick receipt execution is duplicated" } + JsonMemberNotAnObject => BeltTickExecutionUnreadable { reason: "belt tick receipt is not an object" } + } +} + fn belt_tick_receipt_json_value(receipt: BeltTickReceipt) -> JsonValue { json_object(members: [ json_kv(key: "schema", value: json_string(s: belt_tick_receipt_schema)), json_kv(key: "observed_at", value: json_string(s: receipt.observed_at)), + json_kv(key: "execution", value: belt_tick_execution_json(e: receipt.execution)), + json_kv(key: "receipt_identity", value: json_string(s: belt_tick_receipt_identity_hex(receipt: receipt))), json_kv(key: "launches", value: json_array(elements: receipt.launches |> map(l => launch_identity_json(id: l)))), json_kv(key: "spawn_pass", value: belt_pass_outcome_json(o: receipt.spawn_pass)), json_kv(key: "teardown_pass", value: belt_pass_outcome_json(o: receipt.teardown_pass)), @@ -2369,7 +2470,6 @@ fn belt_tick_receipt_write_for_instance( // and is simply deferred. VERIFY NO LONGER SHORT-CIRCUITS ON SESSION OBSERVE REFUSAL: a tmux-list // refusal blocks spawn/reap only; verify reads the attempt ledger and terminal provider/process // evidence on its own authority (operator P1, 2026-08-02). - // The tick's node-independent launch refusal, when the host refused before any node reached its own // gates: the same LaunchRefusal (gunbc.roadmap_launch_admission launch_host_refusal over the staged // standing) every per-node SpawnNotAdmitted in spawn_outcomes carries, so the receipt's spawn_pass @@ -2377,7 +2477,6 @@ fn belt_tick_receipt_write_for_instance( // through to the session set (a per-node refusal may still stand in spawn_outcomes). Replaces the // tick-local `observe_refused: Bool + reason: String` pair review 5059520727 found beside the // shared vocabulary. - // A deployment transition is the deploy's ATOMIC WHOLESALE refusal (gunbc.deploy_transition): under // it nothing on the host actuates — spawn, teardown, verification, publication. The tick records it // as TickPassesWithheld with the transition refusal, and the durable receipt derives every pass as @@ -2385,7 +2484,6 @@ fn belt_tick_receipt_write_for_instance( // folded to BeltPassRecorded — persisting a false `recorded` for passes the driver never ran. A // host refusal that is NOT a transition (revision drift, an unobservable session set) withholds // only the launch passes; verification and publication still run and record on their own evidence. - type BeltTickWholesale = TickPassesRun | TickPassesWithheld { refusal: LaunchRefusal } @@ -2417,7 +2515,6 @@ fn belt_tick_empty_reconcile(state: SpawnMode) -> BeltReconcile { // node as Unchanged -> noop, so re-ticking never double-spawns (the observed set closes the loop // the pure decision cannot). ObserveRefused blocks spawn/reap only — verify and publish run on // their own evidence authorities (operator P1, 2026-08-02). - // THE TIMER IS A DISPATCH ACTUATOR TOO (review artifact 51237, both P0s). The dead-pane fix and the // revision gate reached only the click path. This tick passed the whole observed session list into // belt_reconcile, which maps EVERY present row to an observed member and counts live.length() as @@ -2452,7 +2549,6 @@ fn belt_tick_empty_reconcile(state: SpawnMode) -> BeltReconcile { // (running + stale) landed in two buckets and the reconciler acted on an ambiguity dispatch would // have refused. The partition is now node-aware — more than one row is conflicted whatever the rows // say — so both actuation paths refuse the same ambiguity. - type BeltTickSessionPartition { running: List stale: List @@ -2474,7 +2570,6 @@ fn belt_tick_node_row_count(live: List, node_id: String) -> // Node-awareness stays here rather than in belt_session_liveness_from_process, because multiplicity // is a fact about a NODE's rows and that function sees one row's process. // belt_node_session_liveness makes the same distinction on the dispatch path. - fn belt_tick_session_class( live: List, s: DispatchLiveSession, @@ -2538,7 +2633,6 @@ data belt_publish_transition_inhibited_reason: String = "publication deferred: a // half-rewritten by something that died, so all four passes consume an incoherent release. // Publishing a pull request off a receipt read from a half-converged tree is not a smaller mistake // than spawning into it — it is the one that leaves the house. - // THE REAL TIMER DRIVER IS observer -> launch pass -> actuation. // belt_launch_host_standing_for_instance is the ONE staged observer (shared with the page and POST // /dispatch); belt_tick_launch_pass is the timer's whole launch decision over that standing and is @@ -2551,7 +2645,6 @@ data belt_publish_transition_inhibited_reason: String = "publication deferred: a // reached it. Verification and publication are not launches and run on every arm EXCEPT a // deployment transition halt — the deploy's atomic wholesale refusal (gunbc.deploy_transition), // under which nothing on the host actuates, verify and publish included. - type BeltLaunchPass { reconcile: BeltReconcile, host_refusal: LaunchRefusal?, @@ -2705,7 +2798,6 @@ fn belt_tick_for_instance( // (belt_transition_admission_for_instance refuses the whole tick before observation) and the // revision gate are already closed above, so the standing handed to the admission carries them as // admitted facts of THIS tick, not a second read. - // The timer's per-candidate verdict from the ONE admission, before any actuation: a decided launch // carries the admitted LaunchIdentity, a withheld one the refusal. belt_tick_admit is the pure fold // every candidate passes in deterministic (plan) order, `reserved` counting launches already @@ -2713,7 +2805,6 @@ fn belt_tick_for_instance( // belt_tick_spawn_decided actuates exactly the decided ones. Splitting fold from actuation lets the // multi-candidate control assert the fold on three Ready rows against one slot without entering the // actuator. - type BeltLaunchDecision = BeltLaunchDecided { member: DispatchMember, launch: LaunchIdentity } | BeltLaunchWithheld { node_id: String, refusal: LaunchRefusal } @@ -2811,7 +2902,6 @@ fn belt_tick_default_workdir( // deferred figure report is a field of it. Replaces four variant-to-Bool predicates (belt_spawn_ok // / _failed / _not_admitted and the NoCapacity filter) that each re-eliminated the outcome // coproduct for one arm — the predicate-dissolution shape review 57498 refused. - type BeltSpawnTally { spawned: Int failed: Int @@ -2854,7 +2944,6 @@ fn belt_tick_teardown_ok_count(t: BeltTickResult) -> Int { // dispatch-selection, never a defaulted provider), then verifies and publishes attempts on their // independent evidence authorities. Idempotent: a spawned session is observed live next tick and // reconciles to noop. - fn belt_run_once_in( instance: HostDashboardInstance, spawn_workdir: String, @@ -2995,7 +3084,6 @@ fn belt_verify_outcome_json(o: BeltVerifyOutcome) -> JsonValue { // as well as four passes — the obligation is the receipt, not a green — and counting passes would // turn the receipt into a quality metric that invites wanting fewer refusals. Write failures are // counted separately: they are the belt failing, not the validation. - fn belt_tick_verify_recorded_count(t: BeltTickResult) -> Int { count(t.verify_outcomes |> filter(o => match o { VerifyRecorded { node_id: _, verdict_key: _, detail: _ } => true @@ -3025,7 +3113,6 @@ type BeltAttempts // never widened to zero; exit 0 with empty output IS a genuine zero (for-each-ref's documented // no-match behavior). Runs in belt_actuate_workdir — the repo-root authority the spawn's worktree // add uses, because attempt branches live where spawns mint them. - fn belt_attempts_observe_for_instance(instance: HostDashboardInstance) -> BeltAttempts { let cmd = git_for_each_ref_dispatch_argv_for_instance(instance: instance) if belt_program_available(program: cmd.program) { @@ -3103,7 +3190,6 @@ type BeltWorkflowAttempts // observes alive/dead/exit for all attempts. A git refusal refuses the route. A pane-command or // parse refusal stays located inside each attempt, so terminal provider evidence remains usable // while incomplete attempts become refused. - fn belt_attempt_panes_observe_for_instance( instance: HostDashboardInstance, ) -> BeltAttemptPanes { @@ -3176,7 +3262,6 @@ fn belt_attempt_panes_observe_for_instance( // NOT erase known presence — every present session maps to process-unobserved and // cleanup-unavailable with the located pane reason. The UI can say `session present · process // unknown` instead of fabricating liveness or hiding the container. - fn belt_sessions_observe_for_instance( instance: HostDashboardInstance, ) -> BeltObserve { @@ -3410,7 +3495,6 @@ fn provider_event_projection_error( // mint consumes a successful directory listing; this module carries neither a prose copy of that // law nor a second spelling of List's membership encoding. Listing refusal, listed-but-unreadable, // and disagreeing observations all project to ReceiptSourceUnreadable. - fn belt_publication_receipt_source( instance: HostDashboardInstance, node_id: RoadmapNodeId, @@ -3837,11 +3921,9 @@ fn belt_workflow_progress_for_attempt_ref( // taken against a tree the agent has since touched. Agent completion is read from the reconciled // provider state, so a turn completed while its pane still runs, or with a nonzero exit, never // reaches verification — the obligation order is preserved, not restated. - // Validations run in a detached checkout of the observed head, never the mutable implementation // worktree. The checkout path is head-addressed and idempotent: a tree at the same revision is // reused; one at a different revision refuses rather than validating the wrong commit. - type BeltVerifyOutcome = VerifyDeferred { node_id: String, reason: String } | VerifyRecorded { @@ -3861,7 +3943,6 @@ type BeltVerifyOutcome // the evidence: absent means run, unreadable means stop, so the collapse let an I/O fault // regenerate a verdict against a worktree that may have moved. That seam no longer uses this // function; it reads with status preserved and decodes. - fn belt_read_or_empty(path: String) -> String { let read = Filesystem.Read(path: path) if read.success { trim(read.content) } else { "" } @@ -3877,7 +3958,6 @@ type BeltValidationRun { // merge-base(head, target_generation) in the detached verification checkout, writes the receipt at // verification/..diff-window.json, and binds GUNBC_DIFF_WINDOW_PATH only for // validations that consume an affected-set window. - fn git_target_generation_observation_of(outcome: BeltExecOutcome) -> GitTargetGenerationObservation { match outcome { ExecOk { stdout } => @@ -4771,7 +4851,6 @@ fn belt_workflow_attempts_observe_for_instance( // because progress cannot import presentation without a cycle; the member-list seam // (workflow_attempt_progress_json_members) lets the belt extend the object without re-stating or // re-matching it. - fn belt_workflow_attempt_presentation_json( progress: WorkflowAttemptProgress, ) -> JsonValue { @@ -4926,7 +5005,6 @@ type BeltStopResult // process refuses. A retained dead pane is a different operation: clear removes tmux metadata after // the process exited, so its historical wrapper command is never misclassified as a live foreign // process. Teardown failure remains its own typed 502 arm. - fn belt_stop_node_for_instance( instance: HostDashboardInstance, node_id: String, @@ -5103,7 +5181,6 @@ fn belt_stop_json(node_id: String) -> String { // rows uses dispatch_tmux_session_scope_for_live_session from the session worker's configured // provider facts — never a production-selection re-query that would fabricate eligibility or a // partial row when standing refuses. - fn belt_sessions_json_value(o: BeltObserve) -> JsonValue { match o { ObserveRefused { reason: r } => @@ -5197,7 +5274,6 @@ data belt_run_once_cli_note: String = "The ProcessExit entrypoint — this is wh // belt_run_once and belt_run_once_json keep the literal default: they are inspection surfaces // invoked by hand against production, not the scheduled entrypoint, and belt_run_once_in is the one // implementation both route through so the parameterized and default paths cannot drift. - fn belt_run_once_cli() -> ProcessExit { match shell.Env.Get(name: belt_spawn_workdir_env_var).value { Absent => @@ -5223,6 +5299,10 @@ fn belt_run_once_cli_in(spawn_workdir: String) -> ProcessExit { let tick_receipt = belt_tick_receipt_from_result( result: t, observed_at: belt_tick_observed_at(), + execution: belt_tick_execution_standing_for_instance( + instance: instance, + mode: roadmap_belt_production_spawn_mode, + ), ) match belt_tick_receipt_write_for_instance(instance: instance, receipt: tick_receipt) { BeltPassFailed { reason: r } => @@ -5258,7 +5338,6 @@ fn belt_run_once_cli_in(spawn_workdir: String) -> ProcessExit { // belt_lookup_node (pure roadmap read), belt_sessions_observe_for_instance (the process-aware // observation the sessions route already used) and belt_actuate_spawn — no forked spawn path, no // second observer. - type BeltNodeLookup = NodeFound { node: RoadmapNode } | NodeMissing @@ -5311,7 +5390,6 @@ fn belt_lookup_node(node_id: String) -> BeltNodeLookup { // (worker_process_for_session) already existed with the refusal semantics; dispatch admission just // read the ls-only observation instead. Both actuation call sites — tick and dispatch — now route // through the observation the sessions UI already used. - type BeltSessionLiveness = BeltSessionRunning { session_name: String, command: String } | BeltSessionStaleTerminal { session_name: String, detail: String } @@ -5357,13 +5435,11 @@ fn belt_session_liveness_from_process( // Two sessions for one node is not a tie to break but a state whose remedy differs from every other // arm — neither spawn nor reuse is safe until someone decides which history is real. So it refuses // and NAMES every session; a bare 'ambiguous' leaves the operator to find the panes. - // belt_node_session_live IS DELETED. It answered Bool over a four-state lifecycle, mapping // StaleTerminal, LivenessUnobserved and Absent all to false — three states with different remedies // per the module's own liveness note (clean up; refuse and count; spawn). A Boolean erasing the // distinction invites bypassing the coproduct, and it had no production caller once dispatch // matched BeltSessionLiveness directly. Consumers match the coproduct. - data belt_dispatch_wire_contract_exemplar_provider: String = "codex" // DISPATCH READS BACK THE DEPLOYED REVISION BEFORE IT ACTS, because every fact this path consumes — @@ -5386,7 +5462,6 @@ data belt_dispatch_wire_contract_exemplar_provider: String = "codex" // The repo path is `instance.repo_root`, not the `srv1_gunbc_repo_root` constant // `fleet_converge_cli` uses: the gate is a fact about the instance dispatched, and hardcoding srv1 // would mint a second path authority answering for the wrong host on any other instance. - // THE TRANSITION GATE DOMINATES THE REVISION GATE; the order is the safety property. A repository // convergence advances the base ref BEFORE it transitions the worktree, so a converge that died in // between leaves the ref at the admitted revision over a tree that never moved — and @@ -5408,7 +5483,6 @@ fn belt_transition_admission_for_instance(instance: HostDashboardInstance) -> Ac // (inline, after the tick-wide transition and revision refusals) the belt tick all hand this to // gunbc.roadmap_launch_admission, so `drift refuses UI availability and backend actuation with the // same reason` holds by construction: one standing, one decision. - // THE ONE STAGED OBSERVER behind the page, POST /dispatch and the timer. It reads in gate order and // stops at the first refusal: instance posture is a model fact (no effect); the transition file is // read only for an actuating instance; the fleet revision only under an admitted transition; tmux @@ -5416,7 +5490,6 @@ fn belt_transition_admission_for_instance(instance: HostDashboardInstance) -> Ac // so `tmux listed under an inhibited transition` is not a state it can produce (DESIGN 4b: no // constructor). The refusal in a halt is derived by the admission's own gate functions // (launch_host_halt_transition, launch_host_stage_revision), never spelled here. - fn belt_launch_host_standing_for_instance_with_capacity( instance: HostDashboardInstance, mode: SpawnMode, @@ -5459,7 +5532,6 @@ fn belt_launch_sessions_for_instance(instance: HostDashboardInstance) -> LaunchS // when launch_admission_staged answered LaunchStageNeedsSessions for the posted node; the page only // when at least one row answered so. A halted, observe-only or unobserved standing is returned // unchanged — nothing to complete, nothing read. - fn belt_launch_host_standing_complete(instance: HostDashboardInstance, standing: LaunchHostStanding) -> LaunchHostStanding { match standing { LaunchHostObserved { instance_id: _, actuation: _, transition: _, revision: _, sessions, capacity: _, mode: _ } => @@ -5520,7 +5592,6 @@ fn belt_launch_host_standing(instance: HostDashboardInstance) -> LaunchHostStand // check) has no path left. The node model the spawn needs is looked up AFTER admission through the // projection the admission read; an admitted node cannot fail this lookup, and a projection refusal // between the two reads is a typed SpawnFailed, never a spawn. - fn belt_dispatch_node_for_instance( instance: HostDashboardInstance, node_id: String, @@ -5533,7 +5604,6 @@ fn belt_dispatch_node_for_instance( // model-only gates are decided first; only LaunchStageNeedsSessions lists tmux, so an unknown, // contractless or blocked node is refused with tmux UNREACHABLE (under claim_batch's hermetic route // an effect here is a route refusal, not a verdict — the control's oracle). - fn belt_dispatch_node_staged( instance: HostDashboardInstance, standing: LaunchHostStanding, @@ -5631,7 +5701,6 @@ fn belt_dispatch_result_ok(r: BeltSpawnOutcome) -> Bool { // belt_dispatch_result_json_value; belt_dispatch_ok_status_labels is the ok-subset the page derives // its JS predicate from, glued to belt_dispatch_result_ok by witness (a label moved across the // ok/refusal line without updating both surfaces goes RED). - fn belt_dispatch_status_label(r: BeltSpawnOutcome) -> String { match r { Spawned { node_id: _, session_name: _, provider: _, launch: _ } => "spawned" @@ -5654,7 +5723,6 @@ fn belt_dispatch_disabled_status_label() -> String { // of an answer. This fold is the SINGLE ok-authority — belt_dispatch_result_ok and // belt_dispatch_ok_status_labels project it; the former hand copies (a Bool match, a hand list, a // label predicate — three spellings of one fact) were the half-born-band defect this dissolves. - type DispatchBand = BandOk | BandInProgress @@ -5752,7 +5820,6 @@ fn belt_dispatch_label_band_key(wire_label: String) -> String { // so the button's state set cannot drift from the served contract. This is what the dispatch-button // incident (2026-07-23) needed: one authority for the wire vocabulary, consumed by the JSON body, // the page ok-predicate and the component state totality. - fn belt_dispatch_result_exemplars() -> List { concat( [ @@ -5782,14 +5849,12 @@ fn belt_dispatch_label_is_ok(wire_label: String) -> Bool { // client script builder (gunbc.roadmap_component) and the emitted server (node_http_server_emit) // read one authority; moved off roadmap_page to break the page-to-component import cycle the // TsProgram builder would otherwise create. - data belt_dispatch_path_prefix: String = "/dispatch/" // Live production-selection introspection only — NOT the dispatch wire JSON authority. // belt_dispatch_result_json_value reads the provider stamped on DispatchSpawned/DispatchSpawnFailed // at construction time, so exemplars, sandbox echoes and post-selection receipts stay honest when // standing refuses production re-query. - fn belt_dispatch_provider_label() -> String { match dispatch_actuator_selection(node_sizing: Unsized) { DispatchActuatorSelectionOk { provider, effort: _, process_fingerprint: _ } => @@ -5810,7 +5875,6 @@ data belt_dispatch_env_var: NonEmptyStr = "GUNBC_BELT_NODE_ID" as NonEmptyStr // DispatchAlreadyLive -> ExitSuccess (the click achieved or already holds the desired state); every // refusal or failure (node_not_found, already_done, observe_refused, spawn_failed) -> exit_failure // with the JSON receipt on stderr, so the server relays the exact cause. - fn belt_dispatch_node_cli() -> ProcessExit { match shell.Env.Get(name: belt_dispatch_env_var).value { Absent => @@ -5860,7 +5924,6 @@ fn belt_dispatch_node_cli() -> ProcessExit { // must not hold a publication token. gunbc.roadmap_publication_helper carries that argument; // PublishRequested is the state between the two, a named outcome rather than a silence for the same // reason every other arm here is. - type BeltPublishOutcome = PublishRecorded { node_id: String, @@ -5882,7 +5945,6 @@ type BeltPublishOutcome // where every caller passes the same literal — spelling-repeated-N-times, not one authority. When a // roadmap node can target a base other than the repository default, this reads that fact from the // node; the receipt already records what was used, so the change is visible in the evidence. - fn belt_publication_subject_for( node_id: String, attempt_key: String, @@ -6178,12 +6240,42 @@ fn belt_verification_presentation_detail( } } +// THE EXECUTION STANDING IS OBSERVED AT THE MINT SITE from the instance the tick ran for and the +// revision standing of that instance's tree -- the same fleet_revision_standing read the dispatch +// preflight makes -- and the mode is the production row the tick itself consumed. A drifted or +// unobservable revision withholds the binding rather than recording a revision the tick did not +// serve. +fn belt_tick_execution_standing_for_instance( + instance: HostDashboardInstance, + mode: SpawnMode, +) -> BeltTickExecutionStanding { + match fleet_revision_standing(repo: instance.repo_root) { + RevisionConverged { revision: r } => + TickExecuted { + instance_id: instance.instance_id as String, + deployed_revision: git_object_id_wire_hex(oid: r) as String, + spawn_mode: mode, + } + RevisionDrifted { desired: d, local: l } => + TickExecutionWithheld { + refusal: join([ + "revision drifted: desired ", git_object_id_wire_hex(oid: d) as String, + " local ", git_object_id_wire_hex(oid: l) as String, + ], ""), + } + RevisionUnobserved { cause: c } => + TickExecutionWithheld { refusal: join(["revision unobserved: ", c], "") } + } +} + fn belt_tick_receipt_from_result( result: BeltTickResult, observed_at: String, + execution: BeltTickExecutionStanding, ) -> BeltTickReceipt { BeltTickReceipt { observed_at: observed_at, + execution: execution, launches: belt_tick_launch_identities(outcomes: result.spawn_outcomes), spawn_pass: (match result.wholesale { TickPassesWithheld { refusal } => BeltPassRefused { reason: launch_refusal_reason(r: refusal) } @@ -6253,7 +6345,6 @@ fn belt_publish_attempts() -> List { // OBSERVE-ONLY INSTANCES REFUSE, like dispatch: publication performs a network read and a // filesystem write against an instance's own attempt state, so an observe-only instance must do // neither — and the refusal names the posture rather than reporting an absence. - fn belt_publish_node_for_instance( instance: HostDashboardInstance, node_id: String, @@ -6314,7 +6405,6 @@ fn belt_publish_node_for_instance( // deferral says publication MEANS nothing yet, while a request says the question is well formed, // asked, and will be answered by the publication helper on its next run. Different destinations for // the operator, so different codes. - fn belt_publish_outcome_key(o: BeltPublishOutcome) -> String { match o { PublishRecorded { node_id: _, head_sha: _, outcome_key: _ } => "recorded" diff --git a/dag/gunbc/roadmap/roadmap_launch_deployment_cli.dag b/dag/gunbc/roadmap/roadmap_launch_deployment_cli.dag new file mode 100644 index 00000000000..9adbb7d1aaf --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_launch_deployment_cli.dag @@ -0,0 +1,627 @@ +module gunbc.roadmap_launch_deployment_cli + +import std.types { String, Bool, NonEmptyStr, Int, List, FilePath, CommitSha } +import std.algebra { trim } +import std.process { ProcessExit, ExitSuccess, ExitFailure, exit_failure } +import std.resources { Network } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.git +import extdeps.git.object_store { git_object_id_from_untagged_hex, git_object_id_wire_hex } +import extdeps.exec.command { LocalExec } +import extdeps.tools.mkdir { mkdir_parents_command } +import extdeps.tools.sha256sum { sha256sum_file_digest_via_shell, Sha256FileDigest, Sha256FileDigestUnavailable } +import extdeps.access.posix_effective_principal { + EffectivePosixPrincipalRead, + effective_posix_principal_observation, + EffectivePosixPrincipalObserved, EffectivePosixPrincipalObservationRefused, + effective_posix_principal_name, +} +import extdeps.access.posix_effective_principal_read_op { effective_posix_principal_read } +import product.placement_supply { HostIdentity } +import gunbc.command_runner { run_shell_commands } +import gunbc.clock_read { clock_now_probed_at_or_unknown } +import gunbc.host_layout { srv1_gunbc_repo_root, instance_receipts_dir } +import gunbc.actions_run_binding { + ActionsRunBinding, observe_actions_run_binding, ActionsRunBound, ActionsRunUnbound, + read_expected_revision, ExpectedRevisionPresent, ExpectedRevisionAbsent, + admit_run_revision, RunRevisionAdmitted, RunRevisionRefused, + actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, + run_binding_names_workflow_file, +} +import gunbc.fleet_converge_receipt { + fleet_converge_plan_receipt_path, fleet_converge_apply_receipt_path, + read_fleet_converge_plan_receipt_at, FleetConvergePlanReceiptDecoded, FleetConvergePlanReceiptUnreadable, + read_fleet_converge_apply_receipt_at, FleetConvergeApplyReceiptDecoded, FleetConvergeApplyReceiptUnreadable, +} +import gunbc.fleet_converge_plan_cli { observe_host_short_wet } +import gunbc.live_deploy.apply_receipt { + live_deploy_apply_receipt_artifact_path, + read_live_deploy_apply_receipt_at, LiveDeployApplyReceiptDecoded, LiveDeployApplyReceiptUnreadable, + rlm_plan_run_id_env_name, rlm_apply_run_id_env_name, + observe_candidate_root_tree_oid, CandidateTreeOidObserved, CandidateTreeOidUnobservable, +} +import gunbc.fleet_desired_observe { + observe_fleet_desired_revision, FleetDesiredAdmitted, FleetDesiredAbsent, FleetDesiredUndecodable, FleetDesiredUnobserved, + RevisionConverged, RevisionDrifted, RevisionUnobserved, +} +import gunbc.fleet_ssh_locus { prepare_fleet_ssh_agent_context, FleetSshContextReady, FleetSshContextRefused, fleet_locus_ssh_target } +import gunbc.fleet_known_hosts_anchor { FleetSshExecutionContext } +import gunbc.live_deploy.deployed_tree_observation { + DeployedTreeObservation, DeployedTreeObserved, DeployedTreeUnobservable, + deployed_tree_locus, observe_deployed_tree, deployed_root_tree_standing, deployed_tree_unobservable_text, +} +import gunbc.live_deploy.release_binding { deployed_release_binding, ReleaseBindingUnobserved } +import gunbc.roadmap_site_surface_observe { materialize_live_roadmap_site_surface_bundle } +import gunbc.running_release_identity { RunningReleaseIdentified, RunningReleaseUnidentified } +import gunbc.live_deploy.readiness { running_release_unidentified_detail } +import gunbc.live_deploy.spec { deployment_spec_srv1, deployment_belt_service_unit_name, deployment_belt_timer_unit_name, deployment_tailnet_url } +import gunbc.live_deploy.emit { + RevisionBoundAtEmission, live_deploy_serve_unit_file, live_deploy_belt_service_unit_file, live_deploy_belt_timer_unit_file, +} +import gunbc.live_deploy.unit_standing { + LaunchUnitStanding, observe_dashboard_service_standing, observe_belt_timer_standing, observe_belt_oneshot_standing, +} +import gunbc.deploy_transition { ActuationStanding, ActuationPermitted, ActuationInhibited, ActuationStandingUnreadable, transition_actuation_standing } +import gunbc.roadmap_dashboard_instance { HostDashboardInstance, srv1_live_dashboard_instance } +import gunbc.roadmap_belt_actuate { + belt_tick_receipt_read_for_instance, BeltTickReceiptPresent, BeltTickReceiptAbsent, BeltTickReceiptUnreadable, + belt_footprint_admission_for_instance, belt_provider_preflight_for_instance, + BeltProviderPreflight, ProviderPreflightRefused, +} +import gunbc.roadmap_dispatch_actuator { dispatch_actuator_selection, DispatchActuatorSelectionOk, DispatchActuatorSelectionRefused } +import gunbc.roadmap_model { Unsized } +import gunbc.dispatch_preflight { dispatch_preflight_for_instance, PreflightAdmitted, PreflightRefused } +import gunbc.roadmap_launch_admission { launch_admission_contract_digest_hex } +import gunbc.roadmap_launch_deployment_observe { + observe_healthz, HealthzServed, HealthzRefused, + observe_instance_document, InstanceDocumentServed, InstanceDocumentRefused, + observe_workflow_document, WorkflowDocumentServed, WorkflowDocumentRefused, + belt_tick_freshness, + observe_healthz_routed, + observe_instance_document_routed, + observe_workflow_document_routed, +} +import gunbc.roadmap_launch_deployment_receipt { + ReceiptWorkflowRefStanding, ReceiptWorkflowRefNamesFile, ReceiptWorkflowRefForeign, + RoadmapLaunchDeploymentSubject, RoadmapLaunchDeploymentFacts, RoadmapLaunchDeploymentVerdict, + DesiredRevisionObserved, DesiredRevisionUnobserved, + PlanReceiptRead, PlanReceiptUnreadable, ApplyReceiptRead, ApplyReceiptUnreadable, + DashboardReceiptRead, DashboardReceiptUnreadable, DashboardReceiptFact, + ReadinessServed, ReadinessRefused, + TickObserved, TickUnobserved, TickFact, + DispatchPreflightFact, DispatchPreflightAdmittedFact, DispatchPreflightRefusedFact, PrincipalFact, + WorkflowServedFact, WorkflowRefusedFact, InstanceServedFact, InstanceRefusedFact, + PrincipalObserved, PrincipalUnobserved, HostObserved, HostUnobserved, + WindowSnapshot, observation_window_fact, + roadmap_launch_deployment_verdict, roadmap_launch_deployment_receipt_document, verdict_blockers, verdict_receipt_identity, + RoadmapLaunchDeploymentVerdict, DeploymentComplete, DeploymentIncomplete, + RunProvenanceFact, + RunProvenanceObserved, + RunProvenanceUnobserved, + PredecessorRunProvenance, +} +import gunbc.generated_artifact { artifact_path, FleetConvergeYamlArtifact } +import gunbc.repository { gunbc_repository } +import extdeps.github.workflow_runs { workflow_run_conclusion_wire_label } + +// The one fleet workflow file, named through the generated-artifact authority so the subject and +// every witness cite the same path. +fn rlm_workflow_file_path() -> String { + artifact_path(a: FleetConvergeYamlArtifact) +} + +// THE RECEIPT MODE'S WET ENTRY (fleet-converge mode rlm_launch_deployment_receipt). It is the +// fourth run of the RLM-2 transaction and it MUTATES NOTHING on the host except the receipt it +// persists: every other read is an observation. Order: bind this run (repository, workflow, run id, +// GITHUB_SHA == RLM_EXPECTED_REVISION); fix the subject from the operator's inputs and this +// checkout; open the observation window; read the three predecessor receipts the workflow +// downloaded; take every fact; close the window; fold; persist Complete OR Incomplete; exit zero +// only for Complete that was persisted. +// +// PERSISTENCE IS CONTENT-ADDRESSED AND NO-OVERWRITE (ruling section 10). The canonical document is +// written once to a staging path in the run's workspace, its SHA-256 is computed over those exact +// bytes by sha256sum, and the same bytes are written with Filesystem.WriteOwnerOnly (O_EXCL) to +// /launch-deployment/.json. A pre-existing file at that path with +// the same digest is the SAME document -- the address is the content -- and is accepted after a +// readback digest comparison; a pre-existing file whose digest differs is a refusal, because a +// collision at a content address is precisely the corruption the address exists to make visible. +// The staging copy is what the workflow uploads as the run artifact, so the manager can compare +// host and GitHub copies by digest. +// +// THE PRINCIPAL IS OBSERVED, NOT ASSUMED. Footprint, provider and dispatch preflights are only +// meaningful as the belt's effective service principal (gunbc.roadmap_belt_actuate +// belt_footprint_admission_note), so the receipt run records `whoami` and the join refuses when it +// is not the emitted ServiceUser. If srv1's runner principal is not that user, the wet run produces +// an Incomplete receipt naming `principal`, and the repair is a modeled execution-as-principal +// step, not a relaxation here. +// +// THE PROVIDER PREFLIGHT runs for the provider production would select for an unsized node under +// the instance's own inventory (gunbc.roadmap_dispatch_actuator dispatch_actuator_selection), which +// is the belt's own selection path; a selection refusal is recorded as the provider fact's refusal +// rather than skipped. +data rlm1_merge_commit: String = "0beef8a2c1a035f4f1f0507e77075bdeaba74cba" +data rlm1_contract_digest_frozen: String = "8b884d701d7a356e" +data rlm_dashboard_run_id_env_name: String = "RLM_DASHBOARD_RUN_ID" +data rlm_receipt_host: String = "srv1" +data rlm_receipt_vantage: String = "." +data rlm_receipt_ssh_attempt_raw: String = "rlm-launch-deployment-receipt" +data rlm_receipt_staging_dir: String = "target/rlm-launch-deployment-receipt" +data rlm_receipt_staging_path: String = "target/rlm-launch-deployment-receipt/receipt.json" +data rlm_receipt_staging_digest_path: String = "target/rlm-launch-deployment-receipt/receipt.sha256" +data rlm_receipt_host_subdir: String = "launch-deployment" + +fn rlm_receipt_host_dir(instance: HostDashboardInstance) -> String { + join([instance_receipts_dir(instance_root: instance.instance_root as String), "/", rlm_receipt_host_subdir], "") +} + +fn rlm_receipt_host_path(instance: HostDashboardInstance, digest_hex: String) -> String { + join([rlm_receipt_host_dir(instance: instance), "/", digest_hex, ".json"], "") +} + +// ---------------------------------------------------------------- revision reads +type RemoteRefRead + = RemoteRefHex { hex: String } + | RemoteRefUnreadable { detail: String } + +data main_ref_name: String = "refs/heads/main" + +fn read_remote_main_hex() -> RemoteRefRead { + let read = git.Core.LsRemoteRefInRepo(repo: rlm_receipt_vantage as FilePath, remote: "origin", ref_name: main_ref_name) + if read.exit_code != 0 { + RemoteRefUnreadable { detail: join(["git ls-remote origin ", main_ref_name, " exited ", to_string(read.exit_code), ": ", read.stderr], "") } + } else { + let line = trim(s: read.advertised) + if line == "" { + RemoteRefUnreadable { detail: join(["origin advertises no ", main_ref_name], "") } + } else { + match git_object_id_from_untagged_hex(hex: split(s: line, delimiter: "\t").first()) { + Present { value: oid } => RemoteRefHex { hex: git_object_id_wire_hex(oid: oid) as String } + Absent => RemoteRefUnreadable { detail: join(["origin advertises an undecodable ", main_ref_name, ": ", line], "") } + } + } + } +} + +fn read_desired_hex() -> RemoteRefRead { + match observe_fleet_desired_revision(repo: rlm_receipt_vantage as FilePath) { + FleetDesiredAdmitted { revision: r } => RemoteRefHex { hex: git_object_id_wire_hex(oid: r) as String } + FleetDesiredAbsent => RemoteRefUnreadable { detail: "origin advertises no refs/fleet/desired" } + FleetDesiredUndecodable { advertised: a } => RemoteRefUnreadable { detail: join(["refs/fleet/desired undecodable: ", a], "") } + FleetDesiredUnobserved { cause: c } => RemoteRefUnreadable { detail: join(["refs/fleet/desired unobserved: ", c], "") } + } +} + +fn remote_ref_text(r: RemoteRefRead) -> String { + match r { + RemoteRefHex { hex: h } => h + RemoteRefUnreadable { detail: d } => join(["unreadable: ", d], "") + } +} + +// ---------------------------------------------------------------- window snapshot +fn transition_text(s: ActuationStanding) -> String { + match s { + ActuationPermitted => "permitted" + ActuationInhibited { record: _ } => "inhibited" + ActuationStandingUnreadable { cause: c } => join(["unreadable: ", c], "") + } +} + +fn deployed_tree_hexes(o: DeployedTreeObservation) -> List { + match o { + DeployedTreeObserved { revision: r, tree: t, content: _ } => [git_object_id_wire_hex(oid: r) as String, git_object_id_wire_hex(oid: t) as String] + DeployedTreeUnobservable { cause: c } => [join(["unobserved: ", deployed_tree_unobservable_text(cause: c)], ""), "unobserved"] + } +} + +fn running_release_hex_routed(base: String?) -> String { + match base { + Absent => "refused: tailnet route underivable" + Present { value: b } => + match observe_healthz_routed(base_url: b) { + HealthzRefused { detail: d } => join(["refused: ", d], "") + HealthzServed { body: _, identity: id } => + match id { + RunningReleaseIdentified { observation: o } => o.revision as String + RunningReleaseUnidentified { cause: c } => join(["unidentified: ", running_release_unidentified_detail(cause: c)], "") + } + } + } +} + +fn active_instance_text_routed(base: String?) -> String { + match base { + Absent => "refused: tailnet route underivable" + Present { value: b } => + match observe_instance_document_routed(base_url: b) { + InstanceDocumentServed { document: d } => d.instance_id + InstanceDocumentRefused { detail: d } => join(["refused: ", d], "") + } + } +} + +func window_snapshot(context: FleetSshExecutionContext, instance: HostDashboardInstance, routed_base: String?) -> WindowSnapshot + uses net: Network +{ + let tree = observe_deployed_tree(locus: deployed_tree_locus( + context: context, + target: fleet_locus_ssh_target(host: rlm_receipt_host as HostIdentity), + repo: srv1_gunbc_repo_root as String as NonEmptyStr, + )) + let hexes = deployed_tree_hexes(o: tree) + WindowSnapshot { + main_hex: remote_ref_text(r: read_remote_main_hex()), + desired_hex: remote_ref_text(r: read_desired_hex()), + deployed_revision_hex: hexes.first(), + deployed_tree_hex: hexes.last(), + running_release_hex: running_release_hex_routed(base: routed_base), + transition: transition_text(s: transition_actuation_standing(instance_root: instance.instance_root as String)), + active_instance: active_instance_text_routed(base: routed_base), + } +} + +// ---------------------------------------------------------------- persistence +type ReceiptPersistence + = ReceiptPersisted { host_path: String, digest_hex: String } + | ReceiptPersistenceRefused { detail: String } + +fn receipt_digest_at(path: String) -> String? { + match sha256sum_file_digest_via_shell(path: path as NonEmptyStr) { + Sha256FileDigest { digest: d } => Present { value: d.hex as String } + Sha256FileDigestUnavailable { path: _, reason: _ } => none + } +} + +func persist_receipt(instance: HostDashboardInstance, document: String) -> ReceiptPersistence + uses net: Network +{ + match run_shell_commands(commands: [mkdir_parents_command(path: rlm_receipt_staging_dir)], transport: LocalExec) { + ExitFailure { code: _, reason: why } => ReceiptPersistenceRefused { detail: join(["staging mkdir refused: ", why], "") } + ExitSuccess => { + let staged = Filesystem.Write(path: rlm_receipt_staging_path, content: document) + if !staged.success { + ReceiptPersistenceRefused { detail: join(["staging write refused: ", staged.error], "") } + } else { + match receipt_digest_at(path: rlm_receipt_staging_path) { + Absent => ReceiptPersistenceRefused { detail: "sha256sum over the staged receipt refused" } + Present { value: digest } => { + let wrote_digest = Filesystem.Write(path: rlm_receipt_staging_digest_path, content: join([digest, "\n"], "")) + if !wrote_digest.success { + ReceiptPersistenceRefused { detail: join(["staging digest write refused: ", wrote_digest.error], "") } + } else { + match run_shell_commands(commands: [mkdir_parents_command(path: rlm_receipt_host_dir(instance: instance))], transport: LocalExec) { + ExitFailure { code: _, reason: why } => ReceiptPersistenceRefused { detail: join(["host receipt dir mkdir refused: ", why], "") } + ExitSuccess => { + let host_path = rlm_receipt_host_path(instance: instance, digest_hex: digest) + let wrote = Filesystem.WriteOwnerOnly(path: host_path, content: document) + match receipt_digest_at(path: host_path) { + Absent => + ReceiptPersistenceRefused { detail: join(["host receipt at ", host_path, " is unreadable after write: ", wrote.error], "") } + Present { value: readback } => + if readback != digest { + ReceiptPersistenceRefused { detail: join(["host receipt at ", host_path, " hashes to ", readback, ", not its own address ", digest, " (content-address collision; write success=", if wrote.success { "true" } else { "false" }, ")"], "") } + } else { + ReceiptPersisted { host_path: host_path, digest_hex: digest } + } + } + } + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- facts +fn read_run_id_input(name: String) -> String? { + match actions_variable_read(name: name) { + ActionsVariablePresent { value: v } => Present { value: v } + ActionsVariableAbsent { variable: _, detail: _ } => none + } +} + +fn principal_fact() -> PrincipalFact { + match effective_posix_principal_observation(outcome: effective_posix_principal_read(read: EffectivePosixPrincipalRead {})) { + EffectivePosixPrincipalObserved { observation: o } => PrincipalObserved { name: effective_posix_principal_name(observation: o) as String } + EffectivePosixPrincipalObservationRefused { read: _, reason: r } => PrincipalUnobserved { detail: r } + } +} + +fn provider_fact(instance: HostDashboardInstance) -> BeltProviderPreflight { + match dispatch_actuator_selection(node_sizing: Unsized) { + DispatchActuatorSelectionRefused { reason: r } => ProviderPreflightRefused { step: "dispatch-selection", detail: r as String } + DispatchActuatorSelectionOk { provider: p, effort: _, process_fingerprint: _ } => belt_provider_preflight_for_instance(instance: instance, provider: p) + } +} + +fn dispatch_preflight_fact(instance: HostDashboardInstance) -> DispatchPreflightFact { + match dispatch_preflight_for_instance(instance: instance) { + PreflightAdmitted { report: r } => + match r.revision { + RevisionConverged { revision: rev } => DispatchPreflightAdmittedFact { instance_id: r.instance_id, revision_hex: git_object_id_wire_hex(oid: rev) as String } + RevisionDrifted { desired: d, local: l } => DispatchPreflightRefusedFact { refused_axis_count: 1, detail: join(["revision drifted desired=", git_object_id_wire_hex(oid: d) as String, " local=", git_object_id_wire_hex(oid: l) as String], "") } + RevisionUnobserved { cause: c } => DispatchPreflightRefusedFact { refused_axis_count: 1, detail: join(["revision unobserved: ", c], "") } + } + PreflightRefused { report: _, refused_axis_count: n } => DispatchPreflightRefusedFact { refused_axis_count: n, detail: "dispatch preflight refused" } + } +} + +fn tick_fact(instance: HostDashboardInstance, dashboard: DashboardReceiptFact, now: String) -> TickFact { + match belt_tick_receipt_read_for_instance(instance: instance) { + BeltTickReceiptAbsent => TickUnobserved { detail: "no belt tick receipt on the instance" } + BeltTickReceiptUnreadable { reason: r } => TickUnobserved { detail: join(["belt tick receipt unreadable: ", r], "") } + BeltTickReceiptPresent { receipt: t } => + match dashboard { + DashboardReceiptUnreadable { reason: r } => TickUnobserved { detail: join(["tick freshness cannot be judged without the dashboard receipt: ", r], "") } + DashboardReceiptRead { receipt: d } => + TickObserved { receipt: t, freshness: belt_tick_freshness(observed_at: t.observed_at, deploy_completed_at: d.completed_at, now: now) } + } + } +} + +func unit_standing_fact(revision: String) -> LaunchUnitStanding + uses net: Network +{ + let spec = deployment_spec_srv1() + LaunchUnitStanding { + dashboard: observe_dashboard_service_standing( + unit: spec.names.unit_name, + file: live_deploy_serve_unit_file(spec: spec, revision: RevisionBoundAtEmission { revision: revision as CommitSha }), + ), + timer: observe_belt_timer_standing( + unit: deployment_belt_timer_unit_name(names: spec.names), + file: live_deploy_belt_timer_unit_file(spec: spec), + ), + oneshot: observe_belt_oneshot_standing( + unit: deployment_belt_service_unit_name(names: spec.names), + file: live_deploy_belt_service_unit_file(spec: spec), + ), + } +} + +// ---------------------------------------------------------------- the entry +func rlm_launch_deployment_receipt_wet() -> ProcessExit + uses net: Network +{ + match observe_actions_run_binding() { + ActionsRunUnbound { variable: v, detail: d } => + exit_failure(reason: join(["rlm_launch_deployment_receipt: RunUnbound — ", v, " ", d, "; the receipt mode runs only inside the fleet-converge workflow"], "")) + ActionsRunBound { binding: run } => + match read_expected_revision() { + ExpectedRevisionAbsent { variable: v, detail: d } => + exit_failure(reason: join(["rlm_launch_deployment_receipt: ExpectedRevisionAbsent — ", v, " ", d], "")) + ExpectedRevisionPresent { revision: expected } => + match admit_run_revision(binding: run, expected: expected) { + RunRevisionRefused { expected: e, observed: o } => + exit_failure(reason: join(["rlm_launch_deployment_receipt: RunRevisionNotExpected — this run executes ", o, " but R is ", e], "")) + RunRevisionAdmitted { revision: r } => + if run.repository != gunbc_repository.full_name { + exit_failure(reason: join(["rlm_launch_deployment_receipt: RunRepositoryNotCanonical — this run executes in ", run.repository, ", not ", gunbc_repository.full_name], "")) + } else if !run_binding_names_workflow_file(binding: run, workflow_path: rlm_workflow_file_path()) { + exit_failure(reason: join(["rlm_launch_deployment_receipt: RunWorkflowRefNotNamed — GITHUB_WORKFLOW_REF ", run.workflow_ref, " does not name ", rlm_workflow_file_path()], "")) + } else if launch_admission_contract_digest_hex() != rlm1_contract_digest_frozen { + exit_failure(reason: join(["rlm_launch_deployment_receipt: Rlm1ContractDigestDrift — R computes launch admission contract ", launch_admission_contract_digest_hex(), ", the frozen RLM-1 join is ", rlm1_contract_digest_frozen], "")) + } else { + match read_run_id_input(name: rlm_plan_run_id_env_name) { + Absent => exit_failure(reason: join(["rlm_launch_deployment_receipt: ", rlm_plan_run_id_env_name, " is unset or empty"], "")) + Present { value: plan_run_id } => + match read_run_id_input(name: rlm_apply_run_id_env_name) { + Absent => exit_failure(reason: join(["rlm_launch_deployment_receipt: ", rlm_apply_run_id_env_name, " is unset or empty"], "")) + Present { value: apply_run_id } => + match read_run_id_input(name: rlm_dashboard_run_id_env_name) { + Absent => exit_failure(reason: join(["rlm_launch_deployment_receipt: ", rlm_dashboard_run_id_env_name, " is unset or empty"], "")) + Present { value: dashboard_run_id } => + match observe_candidate_root_tree_oid(repo: rlm_receipt_vantage) { + CandidateTreeOidUnobservable { detail: d } => + exit_failure(reason: join(["rlm_launch_deployment_receipt: CandidateTreeUnobservable — ", d], "")) + CandidateTreeOidObserved { oid_hex: candidate_tree } => + match prepare_fleet_ssh_agent_context(attempt_raw: rlm_receipt_ssh_attempt_raw) { + FleetSshContextRefused { cause: c } => + exit_failure(reason: join(["rlm_launch_deployment_receipt: FleetSshContextRefused — ", c], "")) + FleetSshContextReady { context: context, receipt: _ } => + rlm_launch_deployment_receipt_bound( + run: run, + revision: r, + candidate_tree: candidate_tree, + plan_run_id: plan_run_id, + apply_run_id: apply_run_id, + dashboard_run_id: dashboard_run_id, + context: context, + ) + } + } + } + } + } + } + } + } + } +} + +// The routed production base URL, DERIVED from the deployment authority; Absent means the spec +// declares no tailnet domain, and the routed endpoint facts refuse rather than fall back to +// localhost (review 5061891290 P1-6 -- localhost stays complementary, never authoritative). +fn rlm_routed_base_url() -> String? { + deployment_tailnet_url(spec: deployment_spec_srv1()) +} + +// One predecessor run record, read back from the workflow-runs API by run id (review 5061891290 +// P1-4). A missing conclusion (run not completed) or missing path refuses as its own wire text. +func rlm_predecessor_run(run_id: String) -> PredecessorRunProvenance + uses net: Network +{ + let got = github.WorkflowRuns.GetRun( + auth_token: "", + owner: gunbc_repository.owner, + repo: gunbc_repository.name, + run_id: run_id, + ) + PredecessorRunProvenance { + run_id: run_id, + conclusion_wire: (match got.run.conclusion { + Present { value: c } => workflow_run_conclusion_wire_label(conclusion: c) + Absent => "null (run not completed)" + }), + head_sha: got.run.head_sha as String, + workflow_path: (match got.run.path { Present { value: wp } => wp Absent => "(path absent from run record)" }), + } +} + +func rlm_run_provenance_fact(run: ActionsRunBinding, plan_run_id: String, apply_run_id: String, dashboard_run_id: String) -> RunProvenanceFact + uses net: Network +{ + RunProvenanceObserved { + receipt_repository: run.repository, + plan: rlm_predecessor_run(run_id: plan_run_id), + apply: rlm_predecessor_run(run_id: apply_run_id), + dashboard: rlm_predecessor_run(run_id: dashboard_run_id), + receipt_workflow_ref: (if run_binding_names_workflow_file(binding: run, workflow_path: rlm_workflow_file_path()) { ReceiptWorkflowRefNamesFile } else { ReceiptWorkflowRefForeign { workflow_ref: run.workflow_ref } }), + } +} + +func rlm_launch_deployment_receipt_bound( + run: ActionsRunBinding, + revision: String, + candidate_tree: String, + plan_run_id: String, + apply_run_id: String, + dashboard_run_id: String, + context: FleetSshExecutionContext, +) -> ProcessExit + uses net: Network +{ + let instance = srv1_live_dashboard_instance() + let spec = deployment_spec_srv1() + let subject = RoadmapLaunchDeploymentSubject { + rlm1_merge_commit: rlm1_merge_commit, + rlm1_contract_digest: rlm1_contract_digest_frozen, + revision: revision, + candidate_tree_oid: candidate_tree, + host: rlm_receipt_host, + active_instance: instance.instance_id as String, + service_principal: spec.service.service_user as String, + repository: gunbc_repository.full_name, + workflow_path: rlm_workflow_file_path(), + plan_run_id: plan_run_id, + apply_run_id: apply_run_id, + dashboard_run_id: dashboard_run_id, + receipt_run_id: run.run_id, + } + let routed_base = rlm_routed_base_url() + let opened = window_snapshot(context: context, instance: instance, routed_base: routed_base) + let main_hex = read_remote_main_hex() + let desired_hex = read_desired_hex() + let plan = match read_fleet_converge_plan_receipt_at(path: fleet_converge_plan_receipt_path) { + FleetConvergePlanReceiptDecoded { receipt: p } => PlanReceiptRead { receipt: p } + FleetConvergePlanReceiptUnreadable { reason: why } => PlanReceiptUnreadable { reason: why } + } + let apply = match read_fleet_converge_apply_receipt_at(path: fleet_converge_apply_receipt_path) { + FleetConvergeApplyReceiptDecoded { receipt: a } => ApplyReceiptRead { receipt: a } + FleetConvergeApplyReceiptUnreadable { reason: why } => ApplyReceiptUnreadable { reason: why } + } + let dashboard = match read_live_deploy_apply_receipt_at(path: live_deploy_apply_receipt_artifact_path) { + LiveDeployApplyReceiptDecoded { receipt: d } => DashboardReceiptRead { receipt: d } + LiveDeployApplyReceiptUnreadable { reason: why } => DashboardReceiptUnreadable { reason: why } + } + let tree = observe_deployed_tree(locus: deployed_tree_locus( + context: context, + target: fleet_locus_ssh_target(host: rlm_receipt_host as HostIdentity), + repo: srv1_gunbc_repo_root as String as NonEmptyStr, + )) + let healthz_routed = match routed_base { + Absent => HealthzRefused { detail: "tailnet route underivable: the deployment spec declares no tailnet domain" } + Present { value: base } => observe_healthz_routed(base_url: base) + } + let binding = match healthz_routed { + HealthzServed { body: _, identity: id } => + deployed_release_binding( + observation: tree, + identity: id, + expected_surface: materialize_live_roadmap_site_surface_bundle().bundle_identity, + ) + HealthzRefused { detail: d } => ReleaseBindingUnobserved { cause: join(["routed healthz probe refused: ", d], "") } + } + let readiness = match healthz_routed { + HealthzRefused { detail: d } => ReadinessRefused { detail: d } + HealthzServed { body: _, identity: id } => + match id { + RunningReleaseIdentified { observation: o } => ReadinessServed { revision: o.revision as String, surface: o.surface_bundle_identity.digest as String } + RunningReleaseUnidentified { cause: c } => ReadinessRefused { detail: running_release_unidentified_detail(cause: c) } + } + } + let now = clock_now_probed_at_or_unknown() as String + let workflow = match (match routed_base { + Absent => WorkflowDocumentRefused { detail: "tailnet route underivable: the deployment spec declares no tailnet domain" } + Present { value: base } => observe_workflow_document_routed(base_url: base) + }) { + WorkflowDocumentServed { document: d } => WorkflowServedFact { observe_refused: d.observe_refused, tick: d.belt_tick } + WorkflowDocumentRefused { detail: d } => WorkflowRefusedFact { detail: d } + } + let instance_doc = match (match routed_base { + Absent => InstanceDocumentRefused { detail: "tailnet route underivable: the deployment spec declares no tailnet domain" } + Present { value: base } => observe_instance_document_routed(base_url: base) + }) { + InstanceDocumentServed { document: d } => InstanceServedFact { instance_id: d.instance_id, host_identity: d.host_identity, actuation: d.actuation, repo_root: d.repo_root } + InstanceDocumentRefused { detail: d } => InstanceRefusedFact { detail: d } + } + let host = match observe_host_short_wet() { + Present { value: h } => HostObserved { name: h as String } + Absent => HostUnobserved { detail: "hostname probe did not succeed or returned empty output" } + } + let facts = RoadmapLaunchDeploymentFacts { + desired_revision: (match main_hex { + RemoteRefUnreadable { detail: d } => DesiredRevisionUnobserved { detail: join(["main: ", d], "") } + RemoteRefHex { hex: m } => + match desired_hex { + RemoteRefUnreadable { detail: d } => DesiredRevisionUnobserved { detail: join(["refs/fleet/desired: ", d], "") } + RemoteRefHex { hex: d } => DesiredRevisionObserved { main_hex: m, desired_hex: d } + } + }), + plan_receipt: plan, + apply_receipt: apply, + dashboard_receipt: dashboard, + deployed_tree: deployed_root_tree_standing(candidate_tree_hex: candidate_tree, observation: tree), + release_binding: binding, + readiness: readiness, + transition: transition_actuation_standing(instance_root: instance.instance_root as String), + unit_standing: unit_standing_fact(revision: revision), + tick_standing: tick_fact(instance: instance, dashboard: dashboard, now: now), + footprint: belt_footprint_admission_for_instance(instance: instance), + provider_preflight: provider_fact(instance: instance), + dispatch_preflight: dispatch_preflight_fact(instance: instance), + workflow_endpoint: workflow, + instance_endpoint: instance_doc, + principal: principal_fact(), + observed_host: host, + observation_window: observation_window_fact(opened: opened, closed: window_snapshot(context: context, instance: instance, routed_base: routed_base)), + run_provenance: rlm_run_provenance_fact(run: run, plan_run_id: plan_run_id, apply_run_id: apply_run_id, dashboard_run_id: dashboard_run_id), + } + let verdict = roadmap_launch_deployment_verdict(subject: subject, facts: facts) + let document = roadmap_launch_deployment_receipt_document(v: verdict) + match persist_receipt(instance: instance, document: document) { + ReceiptPersistenceRefused { detail: d } => + exit_failure(reason: join([document, "\nrlm_launch_deployment_receipt: ReceiptPersistenceRefused — ", d], "")) + ReceiptPersisted { host_path: hp, digest_hex: digest } => + match verdict { + DeploymentIncomplete { subject: _, facts: _, first_blocker: _, further_blockers: _ } => + exit_failure(reason: join([ + document, + "\nrlm_launch_deployment_receipt: DeploymentIncomplete — ", to_string(list_length(items: verdict_blockers(v: verdict))), " blocker(s); receipt persisted at ", hp, " sha256=", digest, + ], "")) + DeploymentComplete { subject: _, facts: _, proof: _ } => { + let summary = Filesystem.Write( + path: join([rlm_receipt_staging_dir, "/summary.txt"], ""), + content: join(["DeploymentComplete receipt_identity=", verdict_receipt_identity(v: verdict), " sha256=", digest, " host_path=", hp, "\n"], ""), + ) + if summary.success { + ExitSuccess + } else { + exit_failure(reason: "rlm_launch_deployment_receipt: summary write refused after a persisted Complete receipt") + } + } + } + } +} diff --git a/dag/gunbc/roadmap/roadmap_launch_deployment_observe.dag b/dag/gunbc/roadmap/roadmap_launch_deployment_observe.dag new file mode 100644 index 00000000000..894ef87f0fb --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_launch_deployment_observe.dag @@ -0,0 +1,427 @@ +module gunbc.roadmap_launch_deployment_observe + +import extdeps.systemd { systemd_duration_usec } +import std.measure { Second, second, second_count, microsecond_count } +import std.checked_arithmetic { checked_int_magnitude, CheckedNat, CheckedNatReady, CheckedNatOverflow } +import gunbc.live_deploy.emit { belt_tick_cadence } + +import std.types { String, Bool, NonEmptyStr, Int, List } +import std.algebra { trim } +import extdeps.http.client +import extdeps.languages.json.emit { JsonValue, JsonString, JsonBool, serialize_json } +import extdeps.languages.json.parse { + parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, + json_object_unique_member, + JsonMemberFound, JsonMemberAbsent, JsonMemberDuplicated, JsonMemberNotAnObject, +} +import gunbc.actions_run_binding { json_required_string_member, JsonStringMemberFound, JsonStringMemberRefused } +import gunbc.running_release_identity { RunningReleaseIdentity, observe_running_release_identity } +import gunbc.roadmap_belt_actuate { + BeltTickReceipt, BeltTickReceiptRead, BeltTickReceiptPresent, BeltTickReceiptAbsent, BeltTickReceiptUnreadable, + belt_tick_receipt_decode, +} + +// LIVE HTTP OBSERVATIONS OF THE DEPLOYED PUBLIC ROUTES, DECODED INTO THE TYPES THE ROUTES +// SERIALIZE. The RLM-2 ruling (section 5) rejects renderers as evidence: +// gunbc.roadmap_site_surface_render healthz_body_with_release_identity CONSTRUCTS the expected +// /healthz body and gunbc.roadmap_serve serve_workflow_response_for_instance CONSTRUCTS the +// /workflow.json response, so calling either in the receipt process proves what the code would say, +// not what the routed service said. This module performs the GETs (extdeps.http.client +// GetLocalhostBounded, the same operation gunbc.live_deploy.readiness uses for its local probe) and +// decodes the bytes that came back. +// +// THE DECODERS ARE THE INVERSE OF THE PRODUCERS, member for member: /healthz through +// gunbc.running_release_identity observe_running_release_identity (the existing inverse); +// /instance.json through the members gunbc.roadmap_serve dashboard_instance_json writes; +// /workflow.json through the schema and belt_tick members gunbc.roadmap_belt_actuate +// belt_workflow_attempts_json_value_for_instance writes, with the nested tick decoded by the +// receipt's own decoder so a v3 or damaged embedded tick is Unreadable here exactly as it is on +// disk. HTTP 200 is not sufficient for /workflow.json: a valid document whose belt_tick is absent +// or unreadable refuses RLM-2 rather than reading as Idle. +// +// TICK FRESHNESS is decided on two clock readings the receipt run does not control -- the tick's +// own observed_at and the dashboard receipt's completed_at, both rendered by extdeps.clock Now on +// srv1 as `%Y-%m-%dT%H:%M:%SZ` -- against the receipt run's own now. The bound is derived from the +// emitted timer cadence (gunbc.live_deploy.emit belt_tick_cadence, 60s) times a fixed multiplier, +// never typed as an independent number. +// ---------------------------------------------------------------- HTTP GET +type HttpBodyRead + = HttpBodyServed { body: String } + | HttpBodyRefused { url: String, detail: String } + +fn local_route_url(port: Int, path: String) -> String { + join(["http://127.0.0.1:", to_string(port), path], "") +} + +fn observe_local_route(port: Int, path: String) -> HttpBodyRead { + let url = local_route_url(port: port, path: path) + let got = http.Client.GetLocalhostBounded(url: url as NonEmptyStr) + if got.success { + HttpBodyServed { body: got.body } + } else { + HttpBodyRefused { url: url, detail: "http client GET failed" } + } +} + +// THE ROUTED READ IS THE AUTHORITATIVE ONE (review 5061891290 P1-6): localhost proves the local +// process, not that the deployed production route serves the release. The base URL is DERIVED from +// the deployment authority (gunbc.live_deploy.spec deployment_tailnet_url), never typed here. +fn observe_routed_route(base_url: String, path: String) -> HttpBodyRead { + let url = concat(base_url, path) + let got = http.Client.GetBounded(url: url as NonEmptyStr) + if got.success { + HttpBodyServed { body: got.body } + } else { + HttpBodyRefused { url: url, detail: "http client GET failed" } + } +} + +data healthz_route_path: String = "/healthz" +data instance_route_path: String = "/instance.json" +data workflow_route_path: String = "/workflow.json" + +// ---------------------------------------------------------------- /healthz +type HealthzObservation + = HealthzServed { body: String, identity: RunningReleaseIdentity } + | HealthzRefused { detail: String } + +fn healthz_observation_of_read(read: HttpBodyRead) -> HealthzObservation { + match read { + HttpBodyRefused { url: u, detail: d } => HealthzRefused { detail: join([u, ": ", d], "") } + HttpBodyServed { body: b } => HealthzServed { body: b, identity: observe_running_release_identity(healthz_body: b) } + } +} + +fn observe_healthz(port: Int) -> HealthzObservation { + healthz_observation_of_read(read: observe_local_route(port: port, path: healthz_route_path)) +} + +fn observe_healthz_routed(base_url: String) -> HealthzObservation { + healthz_observation_of_read(read: observe_routed_route(base_url: base_url, path: healthz_route_path)) +} + +// ---------------------------------------------------------------- /instance.json +data dashboard_instance_document_schema: String = "roadmap-dashboard-instance/v1" + +type DashboardInstanceDocument { + instance_id: String + host_identity: String + actuation: String + repo_root: String + listen_port: String +} + +type DashboardInstanceObservation + = InstanceDocumentServed { document: DashboardInstanceDocument } + | InstanceDocumentRefused { detail: String } + +fn dashboard_instance_document_decode(raw: String) -> DashboardInstanceObservation { + match parse_json_document(s: raw) { + JsonDocumentUnreadable { gap: gap } => + InstanceDocumentRefused { detail: join(["/instance.json is ", json_document_gap_text(gap: gap)], "") } + JsonDocumentParsed { value: doc } => + match json_required_string_member(doc: doc, key: "schema") { + JsonStringMemberRefused { reason: r } => InstanceDocumentRefused { detail: r } + JsonStringMemberFound { value: schema } => + if schema != dashboard_instance_document_schema { + InstanceDocumentRefused { detail: join(["/instance.json schema is not ", dashboard_instance_document_schema, ": ", schema], "") } + } else { + match json_required_string_member(doc: doc, key: "instance_id") { + JsonStringMemberRefused { reason: r } => InstanceDocumentRefused { detail: r } + JsonStringMemberFound { value: instance_id } => + match json_required_string_member(doc: doc, key: "host_identity") { + JsonStringMemberRefused { reason: r } => InstanceDocumentRefused { detail: r } + JsonStringMemberFound { value: host_identity } => + match json_required_string_member(doc: doc, key: "actuation") { + JsonStringMemberRefused { reason: r } => InstanceDocumentRefused { detail: r } + JsonStringMemberFound { value: actuation } => + match json_required_string_member(doc: doc, key: "repo_root") { + JsonStringMemberRefused { reason: r } => InstanceDocumentRefused { detail: r } + JsonStringMemberFound { value: repo_root } => + match json_object_unique_member(v: doc, key: "listen_port") { + JsonMemberFound { value: port_value } => + InstanceDocumentServed { + document: DashboardInstanceDocument { + instance_id: instance_id, + host_identity: host_identity, + actuation: actuation, + repo_root: repo_root, + listen_port: serialize_json(v: port_value), + }, + } + JsonMemberAbsent => InstanceDocumentRefused { detail: "listen_port is missing" } + JsonMemberDuplicated { count: _ } => InstanceDocumentRefused { detail: "listen_port is duplicated" } + JsonMemberNotAnObject => InstanceDocumentRefused { detail: "/instance.json is not an object" } + } + } + } + } + } + } + } + } +} + +fn observe_instance_document(port: Int) -> DashboardInstanceObservation { + match observe_local_route(port: port, path: instance_route_path) { + HttpBodyRefused { url: u, detail: d } => InstanceDocumentRefused { detail: join([u, ": ", d], "") } + HttpBodyServed { body: b } => dashboard_instance_document_decode(raw: b) + } +} + +fn observe_instance_document_routed(base_url: String) -> DashboardInstanceObservation { + match observe_routed_route(base_url: base_url, path: instance_route_path) { + HttpBodyRefused { url: u, detail: d } => InstanceDocumentRefused { detail: join([u, ": ", d], "") } + HttpBodyServed { body: b } => dashboard_instance_document_decode(raw: b) + } +} + +// ---------------------------------------------------------------- /workflow.json +data roadmap_workflow_document_schema: String = "roadmap-workflow/v1" + +type RoadmapWorkflowDocument { + observe_refused: Bool + belt_tick: BeltTickReceiptRead +} + +type RoadmapWorkflowObservation + = WorkflowDocumentServed { document: RoadmapWorkflowDocument } + | WorkflowDocumentRefused { detail: String } + +fn workflow_belt_tick_of_member(doc: JsonValue) -> BeltTickReceiptRead { + match json_object_unique_member(v: doc, key: "belt_tick") { + JsonMemberFound { value: tick } => + match json_object_unique_member(v: tick, key: "present") { + JsonMemberFound { value: JsonBool { value: present } } => + if present { + match json_object_unique_member(v: tick, key: "receipt") { + JsonMemberFound { value: receipt } => belt_tick_receipt_decode(raw: serialize_json(v: receipt)) + JsonMemberAbsent => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick is present but carries no receipt" } + JsonMemberDuplicated { count: _ } => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick receipt is duplicated" } + JsonMemberNotAnObject => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick is not an object" } + } + } else { + match json_object_unique_member(v: tick, key: "unreadable_reason") { + JsonMemberFound { value: JsonString { value: why } } => BeltTickReceiptUnreadable { reason: why } + JsonMemberFound { value: _ } => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick unreadable_reason is not a string" } + JsonMemberAbsent => BeltTickReceiptAbsent + JsonMemberDuplicated { count: _ } => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick unreadable_reason is duplicated" } + JsonMemberNotAnObject => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick is not an object" } + } + } + JsonMemberFound { value: _ } => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick present is not a boolean" } + JsonMemberAbsent => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick carries no present member" } + JsonMemberDuplicated { count: _ } => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick present is duplicated" } + JsonMemberNotAnObject => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick is not an object" } + } + JsonMemberAbsent => BeltTickReceiptUnreadable { reason: "/workflow.json carries no belt_tick member" } + JsonMemberDuplicated { count: _ } => BeltTickReceiptUnreadable { reason: "/workflow.json belt_tick is duplicated" } + JsonMemberNotAnObject => BeltTickReceiptUnreadable { reason: "/workflow.json is not an object" } + } +} + +fn roadmap_workflow_document_decode(raw: String) -> RoadmapWorkflowObservation { + match parse_json_document(s: raw) { + JsonDocumentUnreadable { gap: gap } => + WorkflowDocumentRefused { detail: join(["/workflow.json is ", json_document_gap_text(gap: gap)], "") } + JsonDocumentParsed { value: doc } => + match json_required_string_member(doc: doc, key: "schema") { + JsonStringMemberRefused { reason: r } => WorkflowDocumentRefused { detail: r } + JsonStringMemberFound { value: schema } => + if schema != roadmap_workflow_document_schema { + WorkflowDocumentRefused { detail: join(["/workflow.json schema is not ", roadmap_workflow_document_schema, ": ", schema], "") } + } else { + match json_object_unique_member(v: doc, key: "observe_refused") { + JsonMemberFound { value: JsonBool { value: refused } } => + WorkflowDocumentServed { + document: RoadmapWorkflowDocument { + observe_refused: refused, + belt_tick: workflow_belt_tick_of_member(doc: doc), + }, + } + JsonMemberFound { value: _ } => WorkflowDocumentRefused { detail: "/workflow.json observe_refused is not a boolean" } + JsonMemberAbsent => WorkflowDocumentRefused { detail: "/workflow.json observe_refused is missing" } + JsonMemberDuplicated { count: _ } => WorkflowDocumentRefused { detail: "/workflow.json observe_refused is duplicated" } + JsonMemberNotAnObject => WorkflowDocumentRefused { detail: "/workflow.json is not an object" } + } + } + } + } +} + +fn observe_workflow_document(port: Int) -> RoadmapWorkflowObservation { + match observe_local_route(port: port, path: workflow_route_path) { + HttpBodyRefused { url: u, detail: d } => WorkflowDocumentRefused { detail: join([u, ": ", d], "") } + HttpBodyServed { body: b } => roadmap_workflow_document_decode(raw: b) + } +} + +fn observe_workflow_document_routed(base_url: String) -> RoadmapWorkflowObservation { + match observe_routed_route(base_url: base_url, path: workflow_route_path) { + HttpBodyRefused { url: u, detail: d } => WorkflowDocumentRefused { detail: join([u, ": ", d], "") } + HttpBodyServed { body: b } => roadmap_workflow_document_decode(raw: b) + } +} + +// ---------------------------------------------------------------- instants +// `%Y-%m-%dT%H:%M:%SZ` to seconds since 1970-01-01T00:00:00Z. Days-from-civil is the proleptic +// Gregorian algorithm (Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms"), exact for +// every date the format can spell. Anything that is not exactly that shape refuses. +fn two_digits_at(s: String, i: Int) -> Int? { + parse_int(s: substring(s: s, start: i, end: i + 2)) +} + +fn days_from_civil(y: Int, m: Int, d: Int) -> Int { + let yy = if m <= 2 { y - 1 } else { y } + let era = (if yy >= 0 { yy } else { yy - 399 }) / 400 + let yoe = yy - era * 400 + let mp = if m > 2 { m - 3 } else { m + 9 } + let doy = (153 * mp + 2) / 5 + d - 1 + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy + era * 146097 + doe - 719468 +} + +// systemd-independent Gregorian day-in-month bound with the leap rule; a calendar-impossible day +// (Feb 30, Apr 31) must refuse rather than normalize silently through days_from_civil (review 57773). +fn gregorian_days_in_month(y: Int, m: Int) -> Int { + if m == 2 { + if y - (y / 4) * 4 == 0 && (y - (y / 100) * 100 != 0 || y - (y / 400) * 400 == 0) { 29 } else { 28 } + } else if m == 4 || m == 6 || m == 9 || m == 11 { 30 } else { 31 } +} + +fn iso8601_utc_epoch_seconds(text: String) -> Second? { + let t = trim(s: text) + if string_length(s: t) != 20 { + none + } else if substring(s: t, start: 4, end: 5) != "-" || substring(s: t, start: 7, end: 8) != "-" + || substring(s: t, start: 10, end: 11) != "T" || substring(s: t, start: 13, end: 14) != ":" + || substring(s: t, start: 16, end: 17) != ":" || substring(s: t, start: 19, end: 20) != "Z" { + none + } else { + match parse_int(s: substring(s: t, start: 0, end: 4)) { + Absent => none + Present { value: y } => + match two_digits_at(s: t, i: 5) { + Absent => none + Present { value: m } => + match two_digits_at(s: t, i: 8) { + Absent => none + Present { value: d } => + match two_digits_at(s: t, i: 11) { + Absent => none + Present { value: hh } => + match two_digits_at(s: t, i: 14) { + Absent => none + Present { value: mm } => + match two_digits_at(s: t, i: 17) { + Absent => none + Present { value: ss } => + if m < 1 || m > 12 || d < 1 || d > gregorian_days_in_month(y: y, m: m) || hh > 23 || mm > 59 || ss > 60 { + none + } else { + { + let epoch = days_from_civil(y: y, m: m, d: d) * 86400 + hh * 3600 + mm * 60 + ss + if epoch < 0 { + none + } else { + match checked_int_magnitude(a: epoch) { + CheckedNatOverflow { cause: _ } => none + CheckedNatReady { value: n } => Present { value: second(count: n) } + } + } + } + } + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- tick freshness +// THE BOUND IS DERIVED FROM THE EMITTED CADENCE: the belt timer fires OnUnitInactiveSec after each +// tick completes, so a live timer produces a receipt at most one cadence plus one tick duration +// old. Three cadences is the admission; a receipt older than that came from a timer that is not +// running now, whatever the unit file says. +// Derived from the ONE emitted cadence authority (gunbc.live_deploy.emit belt_tick_cadence), +// normalized through systemd.time(7); a spelling that stops normalizing refuses freshness rather +// than silently keeping an out-of-date number here. +fn belt_tick_cadence_seconds_derived() -> Second? { + match systemd_duration_usec(text: belt_tick_cadence) { + Absent => none + Present { value: usec } => + match checked_int_magnitude(a: microsecond_count(usec) / 1000000) { + CheckedNatOverflow { cause: _ } => none + CheckedNatReady { value: n } => Present { value: second(count: n) } + } + } +} +data belt_tick_freshness_cadence_multiplier: Int = 3 + +fn belt_tick_freshness_bound_seconds() -> Second? { + match belt_tick_cadence_seconds_derived() { + Absent => none + Present { value: cadence } => + match checked_int_magnitude(a: second_count(cadence) * belt_tick_freshness_cadence_multiplier) { + CheckedNatOverflow { cause: _ } => none + CheckedNatReady { value: n } => Present { value: second(count: n) } + } + } +} + +type TickFreshness + = TickFresh { age_seconds: Second } + | TickStale { age_seconds: Second, bound_seconds: Second } + | TickBeforeDeploy { observed_at: String, deploy_completed_at: String } + | TickInstantUnparseable { which: String, text: String } + | TickCadenceUnnormalizable { text: String } + +fn belt_tick_freshness(observed_at: String, deploy_completed_at: String, now: String) -> TickFreshness { + match iso8601_utc_epoch_seconds(text: observed_at) { + Absent => TickInstantUnparseable { which: "tick observed_at", text: observed_at } + Present { value: tick_s } => + match iso8601_utc_epoch_seconds(text: deploy_completed_at) { + Absent => TickInstantUnparseable { which: "dashboard receipt completed_at", text: deploy_completed_at } + Present { value: deploy_s } => + match iso8601_utc_epoch_seconds(text: now) { + Absent => TickInstantUnparseable { which: "receipt run now", text: now } + Present { value: now_s } => + if second_count(tick_s) < second_count(deploy_s) { + TickBeforeDeploy { observed_at: observed_at, deploy_completed_at: deploy_completed_at } + } else { + match belt_tick_freshness_bound_seconds() { + Absent => TickCadenceUnnormalizable { text: belt_tick_cadence } + Present { value: bound } => { + let age = second_count(now_s) - second_count(tick_s) + if age < 0 { + TickInstantUnparseable { which: "receipt run now (earlier than the tick observed_at)", text: now } + } else { + match checked_int_magnitude(a: age) { + CheckedNatOverflow { cause: _ } => TickInstantUnparseable { which: "tick age", text: to_string(age) } + CheckedNatReady { value: age_n } => + if age > second_count(bound) { + TickStale { age_seconds: second(count: age_n), bound_seconds: bound } + } else { + TickFresh { age_seconds: second(count: age_n) } + } + } + } + } + } + } + } + } + } +} + +fn tick_freshness_text(f: TickFreshness) -> String { + match f { + TickFresh { age_seconds: a } => join(["fresh (", to_string(second_count(a)), "s old)"], "") + TickStale { age_seconds: a, bound_seconds: b } => join(["stale: ", to_string(second_count(a)), "s old, bound ", to_string(second_count(b)), "s"], "") + TickBeforeDeploy { observed_at: o, deploy_completed_at: d } => join(["observed at ", o, ", before the dashboard deployment completed at ", d], "") + TickInstantUnparseable { which: w, text: t } => join([w, " is not a %Y-%m-%dT%H:%M:%SZ instant: ", t], "") + TickCadenceUnnormalizable { text: t } => join(["the emitted timer cadence does not normalize under systemd.time(7): ", t], "") + } +} diff --git a/dag/gunbc/roadmap/roadmap_launch_deployment_receipt.dag b/dag/gunbc/roadmap/roadmap_launch_deployment_receipt.dag new file mode 100644 index 00000000000..0575c0935f0 --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_launch_deployment_receipt.dag @@ -0,0 +1,987 @@ +module gunbc.roadmap_launch_deployment_receipt + +import std.types { String, Bool, NonEmptyStr, Int, List } +import std.measure { second_count } +import std.content_hash { content_hash_atom, content_hash_tagged_structural } +import extdeps.languages.json.emit { JsonValue, JsonKeyValue, json_kv, json_string, json_object, json_array, json_bool, serialize_json } +import gunbc.actions_run_binding { ActionsRunBinding, run_binding_names_workflow_file } +import gunbc.fleet_converge_plan { FullyApplied, PartiallyApplied } +import gunbc.fleet_converge_receipt { + FleetConvergePlanReceipt, FleetConvergeApplyReceipt, + fleet_converge_plan_receipt_identity_hex, fleet_converge_apply_receipt_identity_hex, + fleet_converge_plan_receipt_json, + fleet_converge_apply_receipt_json, +} +import gunbc.live_deploy.apply_receipt { + LiveDeployApplyReceipt, DecisionProceed, DecisionTerminalNoOp, DecisionRefused, + LiveDeployApplyConverged, LiveDeployApplyNotConverged, + LiveDeployReadinessObserved, LiveDeployReadinessRefused, + live_deploy_apply_receipt_identity_hex, + live_deploy_apply_receipt_json, +} +import gunbc.live_deploy.deployed_tree_observation { + DeployedRootTreeStanding, DeployedRootTreeMatches, DeployedRootTreeMismatch, DeployedRootTreeScopeDiverged, DeployedRootTreeUnobserved, + deployed_root_tree_standing_text, +} +import gunbc.live_deploy.release_binding { + DeployedReleaseBinding, ReleaseBoundToTree, ReleaseMixed, ReleaseSurfaceMixed, ReleaseBindingUnobserved, + release_binding_line, +} +import extdeps.git.object_store { git_object_id_wire_hex } +import gunbc.deploy_transition { ActuationStanding, ActuationPermitted, ActuationInhibited, ActuationStandingUnreadable } +import gunbc.live_deploy.unit_standing { + LaunchUnitStanding, + DashboardServiceConverged, DashboardServiceRefused, BeltTimerConverged, BeltTimerRefused, BeltOneshotConverged, BeltOneshotRefused, + dashboard_service_standing_text, belt_timer_standing_text, belt_oneshot_standing_text, +} +import gunbc.roadmap_belt_actuate { + BeltTickReceipt, BeltTickReceiptRead, BeltTickReceiptPresent, BeltTickReceiptAbsent, BeltTickReceiptUnreadable, + TickExecuted, TickExecutionWithheld, belt_tick_receipt_identity_hex, + BeltFootprintAdmission, FootprintAdmitted, FootprintRefused, + BeltProviderPreflight, ProviderPreflightOk, ProviderPreflightRefused, +} +import gunbc.roadmap_belt { SpawnMode, ManualReady, spawn_mode_label } +import gunbc.roadmap_launch_deployment_observe { + TickFreshness, TickFresh, TickStale, TickBeforeDeploy, TickInstantUnparseable, TickCadenceUnnormalizable, tick_freshness_text, +} + +// THE ONE JOIN THE ROADMAP LAUNCH MVP'S RLM-2 GATE PRODUCES. Every independent read RUNS -- desired +// revision, plan receipt, apply receipt, dashboard receipt, deployed root tree, release binding, +// readiness, transition, unit standing, tick standing, footprint, provider preflight, dispatch +// preflight, the three public routes, the effective principal, the observed host, and the +// observation window -- and the fact population is TOTAL: each member is a typed standing with its +// own refusal arms, none is optional, and the verdict fold consults all of them before deciding. +// The ruling (docs/plans/roadmap-launch-mvp-plan.md, RLM-2 sections 3 and 9) rejected the earlier +// `Complete | Incomplete { first blocking member }` because it discarded sibling evidence after the +// first refusal and made diagnosis depend on evaluation order; here `first_blocker` is a +// presentation projection over a nonempty blocker list, and `further_blockers` is the rest of that +// list. +// +// DeploymentComplete CANNOT BE AUTHORED. Its `proof` member is a sole_constructor record declared +// in this module, so no other module can write the literal; the only producer is +// roadmap_launch_deployment_verdict, which builds the proof exactly when the blocker fold is empty. +// A witness that wants a Complete verdict must hand the fold a fact population the fold accepts -- +// which is what the red controls do, one substituted member at a time. +// +// EVERY EQUALITY IS AGAINST THE SUBJECT, not against a sibling fact: R, the candidate tree, the +// host, the active instance, the four run ids and the service principal are fixed in the subject +// FIRST (from the operator's inputs and this run's own binding), and each fact is then held to +// them. Facts are never compared to each other where the subject can stand between them, so a +// coincidence between two wrong facts cannot green the join. +// ---------------------------------------------------------------- subject +type RoadmapLaunchDeploymentSubject { + rlm1_merge_commit: String + rlm1_contract_digest: String + revision: String + candidate_tree_oid: String + host: String + active_instance: String + service_principal: String + repository: String + workflow_path: String + plan_run_id: String + apply_run_id: String + dashboard_run_id: String + receipt_run_id: String +} + +// ---------------------------------------------------------------- facts +type DesiredRevisionFact + = DesiredRevisionObserved { main_hex: String, desired_hex: String } + | DesiredRevisionUnobserved { detail: String } + +type PlanReceiptFact + = PlanReceiptRead { receipt: FleetConvergePlanReceipt } + | PlanReceiptUnreadable { reason: String } + +type ApplyReceiptFact + = ApplyReceiptRead { receipt: FleetConvergeApplyReceipt } + | ApplyReceiptUnreadable { reason: String } + +type DashboardReceiptFact + = DashboardReceiptRead { receipt: LiveDeployApplyReceipt } + | DashboardReceiptUnreadable { reason: String } + +type ReadinessFact + = ReadinessServed { revision: String, surface: String } + | ReadinessRefused { detail: String } + +type TickFact + = TickObserved { receipt: BeltTickReceipt, freshness: TickFreshness } + | TickUnobserved { detail: String } + +type DispatchPreflightFact + = DispatchPreflightAdmittedFact { instance_id: String, revision_hex: String } + | DispatchPreflightRefusedFact { refused_axis_count: Int, detail: String } + +type WorkflowEndpointFact + = WorkflowServedFact { observe_refused: Bool, tick: BeltTickReceiptRead } + | WorkflowRefusedFact { detail: String } + +type InstanceEndpointFact + = InstanceServedFact { instance_id: String, host_identity: String, actuation: String, repo_root: String } + | InstanceRefusedFact { detail: String } + +type PrincipalFact + = PrincipalObserved { name: String } + | PrincipalUnobserved { detail: String } + +type HostFact + = HostObserved { name: String } + | HostUnobserved { detail: String } + +type WindowSnapshot { + main_hex: String + desired_hex: String + deployed_revision_hex: String + deployed_tree_hex: String + running_release_hex: String + transition: String + active_instance: String +} + +type ObservationWindowFact + = WindowStable { opened: WindowSnapshot, closed: WindowSnapshot } + | WindowChanged { opened: WindowSnapshot, closed: WindowSnapshot, changed: List } + +type RoadmapLaunchDeploymentFacts { + desired_revision: DesiredRevisionFact + plan_receipt: PlanReceiptFact + apply_receipt: ApplyReceiptFact + dashboard_receipt: DashboardReceiptFact + deployed_tree: DeployedRootTreeStanding + release_binding: DeployedReleaseBinding + readiness: ReadinessFact + transition: ActuationStanding + unit_standing: LaunchUnitStanding + tick_standing: TickFact + footprint: BeltFootprintAdmission + provider_preflight: BeltProviderPreflight + dispatch_preflight: DispatchPreflightFact + workflow_endpoint: WorkflowEndpointFact + instance_endpoint: InstanceEndpointFact + principal: PrincipalFact + observed_host: HostFact + observation_window: ObservationWindowFact + run_provenance: RunProvenanceFact +} + +// THE FOUR-RUN PROVENANCE CHAIN (review 5061891290 P1-4): each predecessor run's record is read +// back from the workflow-runs API by the receipt run, and the chain holds only when every +// predecessor concluded success, ran the generated workflow file, and checked out R -- proved +// against the canonical repository row, not restated from the observed run. +type PredecessorRunProvenance { + run_id: String + conclusion_wire: String + head_sha: String + workflow_path: String +} + +// THE PROVENANCE SPLIT, STATED AT THE MODEL (review 5063097188 B2 refinement). This fact carries +// the three PREDECESSOR runs read back through the workflow-runs API -- run id, conclusion, +// head_sha, workflow path -- plus the CURRENT receipt run's binding admitted against the canonical +// repository and the generated workflow file. It deliberately does NOT carry the current run's own +// terminal conclusion: a run cannot observe its own conclusion from inside itself, and representing +// one here would be a fabricated observation. That conclusion is an explicit POST-RUN manager +// verification (the RLM-2b sequence's final step) or a later attestation, never an internal claim. +// The receipt run's workflow-ref standing, typed rather than a Bool discriminator (review 57760 +// finding 2): the refusing arm carries the OBSERVED ref, so a foreign workflow is named in the +// blocker instead of collapsing to false. +type ReceiptWorkflowRefStanding + = ReceiptWorkflowRefNamesFile + | ReceiptWorkflowRefForeign { workflow_ref: String } + +type RunProvenanceFact + = RunProvenanceObserved { + receipt_repository: String, + plan: PredecessorRunProvenance, + apply: PredecessorRunProvenance, + dashboard: PredecessorRunProvenance, + receipt_workflow_ref: ReceiptWorkflowRefStanding, + } + | RunProvenanceUnobserved { detail: String } + +// ---------------------------------------------------------------- blockers +type RoadmapLaunchDeploymentBlocker { + member: String + detail: String +} + +fn blocker(member: String, detail: String) -> List { + [RoadmapLaunchDeploymentBlocker { member: member, detail: detail }] +} + +fn window_snapshot_differences(a: WindowSnapshot, b: WindowSnapshot) -> List { + concat( + concat( + concat( + if a.main_hex != b.main_hex { ["main"] } else { [] }, + if a.desired_hex != b.desired_hex { ["refs/fleet/desired"] } else { [] }, + ), + concat( + if a.deployed_revision_hex != b.deployed_revision_hex { ["deployed revision"] } else { [] }, + if a.deployed_tree_hex != b.deployed_tree_hex { ["deployed tree"] } else { [] }, + ), + ), + concat( + concat( + if a.running_release_hex != b.running_release_hex { ["running release"] } else { [] }, + if a.transition != b.transition { ["transition standing"] } else { [] }, + ), + if a.active_instance != b.active_instance { ["active instance"] } else { [] }, + ), + ) +} + +fn observation_window_fact(opened: WindowSnapshot, closed: WindowSnapshot) -> ObservationWindowFact { + let changed = window_snapshot_differences(a: opened, b: closed) + if list_length(items: changed) == 0 { + WindowStable { opened: opened, closed: closed } + } else { + WindowChanged { opened: opened, closed: closed, changed: changed } + } +} + +fn desired_revision_blockers(s: RoadmapLaunchDeploymentSubject, f: DesiredRevisionFact) -> List { + match f { + DesiredRevisionUnobserved { detail: d } => blocker(member: "desired_revision", detail: d) + DesiredRevisionObserved { main_hex: m, desired_hex: d } => + concat( + if m != s.revision { blocker(member: "desired_revision", detail: join(["main is ", m, ", not R ", s.revision], "")) } else { [] }, + if d != s.revision { blocker(member: "desired_revision", detail: join(["refs/fleet/desired is ", d, ", not R ", s.revision], "")) } else { [] }, + ) + } +} + +fn run_binding_blockers(member: String, s: RoadmapLaunchDeploymentSubject, run: ActionsRunBinding, expected_run_id: String) -> List { + concat( + concat( + if run.run_revision != s.revision { blocker(member: member, detail: join(["run ", run.run_id, " executed at ", run.run_revision, ", not R ", s.revision], "")) } else { [] }, + if run.run_id != expected_run_id { blocker(member: member, detail: join(["receipt was minted by run ", run.run_id, " but the transaction names run ", expected_run_id], "")) } else { [] }, + ), + concat( + if run.repository != s.repository { blocker(member: member, detail: join(["run executed in ", run.repository, ", not ", s.repository], "")) } else { [] }, + if !run_binding_names_workflow_file(binding: run, workflow_path: s.workflow_path) { blocker(member: member, detail: join(["run workflow ref ", run.workflow_ref, " is not ", s.workflow_path], "")) } else { [] }, + ), + ) +} + +fn plan_receipt_blockers(s: RoadmapLaunchDeploymentSubject, f: PlanReceiptFact) -> List { + match f { + PlanReceiptUnreadable { reason: r } => blocker(member: "plan_receipt", detail: r) + PlanReceiptRead { receipt: p } => + concat( + run_binding_blockers(member: "plan_receipt", s: s, run: p.run, expected_run_id: s.plan_run_id), + if p.observed_host != s.host { blocker(member: "plan_receipt", detail: join(["plan observed host ", p.observed_host, ", not ", s.host], "")) } else { [] }, + ) + } +} + +fn apply_receipt_blockers(s: RoadmapLaunchDeploymentSubject, plan: PlanReceiptFact, f: ApplyReceiptFact) -> List { + match f { + ApplyReceiptUnreadable { reason: r } => blocker(member: "apply_receipt", detail: r) + ApplyReceiptRead { receipt: a } => + concat( + concat( + run_binding_blockers(member: "apply_receipt", s: s, run: a.run, expected_run_id: s.apply_run_id), + concat( + if a.plan_run_id != s.plan_run_id { blocker(member: "apply_receipt", detail: join(["apply consumed plan run ", a.plan_run_id, ", not ", s.plan_run_id], "")) } else { [] }, + if a.observed_host != s.host { blocker(member: "apply_receipt", detail: join(["apply observed host ", a.observed_host, ", not ", s.host], "")) } else { [] }, + ), + ), + concat( + if a.locked_apply_exit_code != 0 { blocker(member: "apply_receipt", detail: join(["locked apply exited ", to_string(a.locked_apply_exit_code)], "")) } else { [] }, + match plan { + PlanReceiptUnreadable { reason: _ } => [] + PlanReceiptRead { receipt: p } => + concat( + if a.plan_receipt_identity != fleet_converge_plan_receipt_identity_hex(r: p) { blocker(member: "apply_receipt", detail: "apply is bound to a plan receipt other than the one in this transaction") } else { [] }, + if a.plan_artifact_hash != p.plan_artifact_hash { blocker(member: "apply_receipt", detail: join(["apply consumed plan bundle ", a.plan_artifact_hash, ", the plan minted ", p.plan_artifact_hash], "")) } else { [] }, + ) + }, + ), + ) + } +} + +fn dashboard_receipt_blockers(s: RoadmapLaunchDeploymentSubject, plan: PlanReceiptFact, f: DashboardReceiptFact) -> List { + match f { + DashboardReceiptUnreadable { reason: r } => blocker(member: "dashboard_receipt", detail: r) + DashboardReceiptRead { receipt: d } => + concat( + concat( + run_binding_blockers(member: "dashboard_receipt", s: s, run: d.run, expected_run_id: s.dashboard_run_id), + concat( + concat( + if d.target != s.host { blocker(member: "dashboard_receipt", detail: join(["deploy targeted ", d.target, ", not ", s.host], "")) } else { [] }, + if d.expected_revision != s.revision { blocker(member: "dashboard_receipt", detail: join(["deploy expected ", d.expected_revision, ", not R ", s.revision], "")) } else { [] }, + ), + concat( + if d.candidate_revision != s.revision { blocker(member: "dashboard_receipt", detail: join(["deploy candidate was ", d.candidate_revision, ", not R ", s.revision], "")) } else { [] }, + if d.candidate_tree_oid != s.candidate_tree_oid { blocker(member: "dashboard_receipt", detail: join(["deploy candidate tree was ", d.candidate_tree_oid, ", not ", s.candidate_tree_oid], "")) } else { [] }, + ), + ), + ), + concat( + concat( + match d.target_decision { + DecisionProceed => [] + DecisionTerminalNoOp => blocker(member: "dashboard_receipt", detail: "deploy was a terminal no-op; R was not installed by this transaction") + DecisionRefused => blocker(member: "dashboard_receipt", detail: "deploy target decision refused") + }, + match d.apply_outcome { + LiveDeployApplyConverged => [] + LiveDeployApplyNotConverged { reason: why } => blocker(member: "dashboard_receipt", detail: join(["deploy did not converge: ", why], "")) + }, + ), + concat( + match d.readiness { + LiveDeployReadinessObserved { revision: r, surface: _ } => + if r != s.revision { blocker(member: "dashboard_receipt", detail: join(["post-deploy readiness served ", r, ", not R ", s.revision], "")) } else { [] } + LiveDeployReadinessRefused { detail: why } => blocker(member: "dashboard_receipt", detail: join(["post-deploy readiness refused: ", why], "")) + }, + concat( + concat( + if d.provenance.plan_run_id != s.plan_run_id { blocker(member: "dashboard_receipt", detail: join(["deploy provenance names plan run ", d.provenance.plan_run_id, ", not ", s.plan_run_id], "")) } else { [] }, + if d.provenance.apply_run_id != s.apply_run_id { blocker(member: "dashboard_receipt", detail: join(["deploy provenance names apply run ", d.provenance.apply_run_id, ", not ", s.apply_run_id], "")) } else { [] }, + ), + match plan { + PlanReceiptUnreadable { reason: _ } => [] + PlanReceiptRead { receipt: p } => + if d.provenance.plan_artifact_hash != p.plan_artifact_hash { blocker(member: "dashboard_receipt", detail: join(["deploy provenance names plan bundle ", d.provenance.plan_artifact_hash, ", the plan minted ", p.plan_artifact_hash], "")) } else { [] } + }, + ), + ), + ), + ) + } +} + +fn deployed_tree_blockers(s: RoadmapLaunchDeploymentSubject, f: DeployedRootTreeStanding) -> List { + match f { + DeployedRootTreeMatches { tree_hex: t } => + if t != s.candidate_tree_oid { blocker(member: "deployed_tree", detail: join(["deployed root tree ", t, " is not the subject's candidate tree ", s.candidate_tree_oid], "")) } else { [] } + DeployedRootTreeMismatch { candidate_hex: _, deployed_hex: _ } => blocker(member: "deployed_tree", detail: deployed_root_tree_standing_text(s: f)) + DeployedRootTreeScopeDiverged { tree_hex: _, changed_paths: _ } => blocker(member: "deployed_tree", detail: deployed_root_tree_standing_text(s: f)) + DeployedRootTreeUnobserved { cause: _ } => blocker(member: "deployed_tree", detail: deployed_root_tree_standing_text(s: f)) + } +} + +fn release_binding_blockers(s: RoadmapLaunchDeploymentSubject, f: DeployedReleaseBinding) -> List { + match f { + ReleaseBoundToTree { revision: r, surface: _ } => + if (git_object_id_wire_hex(oid: r) as String) != s.revision { blocker(member: "release_binding", detail: join(["release bound to ", git_object_id_wire_hex(oid: r) as String, ", not R ", s.revision], "")) } else { [] } + ReleaseMixed { tree_revision: _, process_revision: _ } => blocker(member: "release_binding", detail: release_binding_line(binding: f)) + ReleaseSurfaceMixed { revision: _, expected_surface: _, process_surface: _ } => blocker(member: "release_binding", detail: release_binding_line(binding: f)) + ReleaseBindingUnobserved { cause: _ } => blocker(member: "release_binding", detail: release_binding_line(binding: f)) + } +} + +fn readiness_blockers(s: RoadmapLaunchDeploymentSubject, f: ReadinessFact) -> List { + match f { + ReadinessServed { revision: r, surface: _ } => + if r != s.revision { blocker(member: "readiness", detail: join(["/healthz serves ", r, ", not R ", s.revision], "")) } else { [] } + ReadinessRefused { detail: d } => blocker(member: "readiness", detail: d) + } +} + +fn transition_blockers(f: ActuationStanding) -> List { + match f { + ActuationPermitted => [] + ActuationInhibited { record: _ } => blocker(member: "transition", detail: "a transition inhibition record is present on the instance root") + ActuationStandingUnreadable { cause: c } => blocker(member: "transition", detail: join(["transition standing unreadable: ", c], "")) + } +} + +// Matched per unit rather than through a Bool fold (predicate dissolution, review 57664): each +// refused unit contributes the refusal's own rendering, so the blocker names WHICH unit and WHY +// instead of discarding that into a joined line behind a Bool. +fn unit_standing_blockers(f: LaunchUnitStanding) -> List { + let dashboard = match f.dashboard { + DashboardServiceConverged { unit: _ } => [] + DashboardServiceRefused { unit: _, property: _, expected: _, observed: _ } => blocker(member: "unit_standing", detail: dashboard_service_standing_text(s: f.dashboard)) + } + let timer = match f.timer { + BeltTimerConverged { unit: _ } => [] + BeltTimerRefused { unit: _, property: _, expected: _, observed: _ } => blocker(member: "unit_standing", detail: belt_timer_standing_text(s: f.timer)) + } + let oneshot = match f.oneshot { + BeltOneshotConverged { unit: _, active_state: _ } => [] + BeltOneshotRefused { unit: _, property: _, expected: _, observed: _ } => blocker(member: "unit_standing", detail: belt_oneshot_standing_text(s: f.oneshot)) + } + concat(concat(dashboard, timer), oneshot) +} + +fn tick_blockers(s: RoadmapLaunchDeploymentSubject, f: TickFact) -> List { + match f { + TickUnobserved { detail: d } => blocker(member: "tick_standing", detail: d) + TickObserved { receipt: r, freshness: fr } => + concat( + match r.execution { + TickExecutionWithheld { refusal: why } => blocker(member: "tick_standing", detail: join(["tick execution withheld: ", why], "")) + TickExecuted { instance_id: i, deployed_revision: rev, spawn_mode: m } => + concat( + concat( + if i != s.active_instance { blocker(member: "tick_standing", detail: join(["tick ran on instance ", i, ", not ", s.active_instance], "")) } else { [] }, + if rev != s.revision { blocker(member: "tick_standing", detail: join(["tick ran at ", rev, ", not R ", s.revision], "")) } else { [] }, + ), + match m { + ManualReady => [] + _ => blocker(member: "tick_standing", detail: join(["tick ran under ", spawn_mode_label(m: m), ", not manual_ready"], "")) + }, + ) + }, + match fr { + TickFresh { age_seconds: _ } => [] + TickStale { age_seconds: _, bound_seconds: _ } => blocker(member: "tick_standing", detail: tick_freshness_text(f: fr)) + TickBeforeDeploy { observed_at: _, deploy_completed_at: _ } => blocker(member: "tick_standing", detail: tick_freshness_text(f: fr)) + TickInstantUnparseable { which: _, text: _ } => blocker(member: "tick_standing", detail: tick_freshness_text(f: fr)) + TickCadenceUnnormalizable { text: _ } => blocker(member: "tick_standing", detail: tick_freshness_text(f: fr)) + }, + ) + } +} + +fn footprint_blockers(f: BeltFootprintAdmission) -> List { + match f { + FootprintAdmitted => [] + FootprintRefused { step: st, detail: d } => blocker(member: "footprint", detail: join([st, ": ", d], "")) + } +} + +fn provider_blockers(f: BeltProviderPreflight) -> List { + match f { + ProviderPreflightOk => [] + ProviderPreflightRefused { step: st, detail: d } => blocker(member: "provider_preflight", detail: join([st, ": ", d], "")) + } +} + +fn dispatch_preflight_blockers(s: RoadmapLaunchDeploymentSubject, f: DispatchPreflightFact) -> List { + match f { + DispatchPreflightRefusedFact { refused_axis_count: n, detail: d } => + blocker(member: "dispatch_preflight", detail: join([to_string(n), " axis refused: ", d], "")) + DispatchPreflightAdmittedFact { instance_id: i, revision_hex: r } => + concat( + if i != s.active_instance { blocker(member: "dispatch_preflight", detail: join(["preflight ran for instance ", i, ", not ", s.active_instance], "")) } else { [] }, + if r != s.revision { blocker(member: "dispatch_preflight", detail: join(["preflight observed revision ", r, ", not R ", s.revision], "")) } else { [] }, + ) + } +} + +fn workflow_endpoint_blockers(tick: TickFact, f: WorkflowEndpointFact) -> List { + match f { + WorkflowRefusedFact { detail: d } => blocker(member: "workflow_endpoint", detail: d) + WorkflowServedFact { observe_refused: refused, tick: served } => + concat( + if refused { blocker(member: "workflow_endpoint", detail: "/workflow.json reports its attempt ledger as unobservable") } else { [] }, + match served { + BeltTickReceiptAbsent => blocker(member: "workflow_endpoint", detail: "/workflow.json carries no belt tick receipt") + BeltTickReceiptUnreadable { reason: r } => blocker(member: "workflow_endpoint", detail: join(["/workflow.json belt tick unreadable: ", r], "")) + BeltTickReceiptPresent { receipt: served_receipt } => + match tick { + TickUnobserved { detail: _ } => [] + TickObserved { receipt: on_disk, freshness: _ } => + if belt_tick_receipt_identity_hex(receipt: served_receipt) != belt_tick_receipt_identity_hex(receipt: on_disk) { + blocker(member: "workflow_endpoint", detail: "/workflow.json embeds a tick receipt other than the one on the instance") + } else { [] } + } + }, + ) + } +} + +fn instance_endpoint_blockers(s: RoadmapLaunchDeploymentSubject, f: InstanceEndpointFact) -> List { + match f { + InstanceRefusedFact { detail: d } => blocker(member: "instance_endpoint", detail: d) + InstanceServedFact { instance_id: i, host_identity: h, actuation: a, repo_root: _ } => + concat( + concat( + if i != s.active_instance { blocker(member: "instance_endpoint", detail: join(["/instance.json serves instance ", i, ", not ", s.active_instance], "")) } else { [] }, + if h != s.host { blocker(member: "instance_endpoint", detail: join(["/instance.json serves host ", h, ", not ", s.host], "")) } else { [] }, + ), + if a != "actuating" { blocker(member: "instance_endpoint", detail: join(["/instance.json actuation is ", a, ", not actuating"], "")) } else { [] }, + ) + } +} + +fn principal_blockers(s: RoadmapLaunchDeploymentSubject, f: PrincipalFact) -> List { + match f { + PrincipalUnobserved { detail: d } => blocker(member: "principal", detail: d) + PrincipalObserved { name: n } => + if n != s.service_principal { blocker(member: "principal", detail: join(["preflights ran as ", n, ", not the belt service principal ", s.service_principal], "")) } else { [] } + } +} + +fn host_blockers(s: RoadmapLaunchDeploymentSubject, f: HostFact) -> List { + match f { + HostUnobserved { detail: d } => blocker(member: "observed_host", detail: d) + HostObserved { name: n } => + if n != s.host { blocker(member: "observed_host", detail: join(["receipt run observed host ", n, ", not ", s.host], "")) } else { [] } + } +} + +fn window_blockers(f: ObservationWindowFact) -> List { + match f { + WindowStable { opened: _, closed: _ } => [] + WindowChanged { opened: _, closed: _, changed: c } => blocker(member: "observation_window", detail: join(["production state changed during capture: ", join(c, ", ")], "")) + } +} + +// THE TOTAL FOLD: every member consulted, in declaration order, blockers accumulated. +fn predecessor_run_blockers(which: String, s: RoadmapLaunchDeploymentSubject, expected_run_id: String, p: PredecessorRunProvenance) -> List { + concat( + concat( + if p.run_id != expected_run_id { blocker(member: "run_provenance", detail: join([which, " run record is ", p.run_id, ", not the subject's ", expected_run_id], "")) } else { [] }, + if p.conclusion_wire != "success" { blocker(member: "run_provenance", detail: join([which, " run ", p.run_id, " concluded ", p.conclusion_wire, ", not success"], "")) } else { [] }, + ), + concat( + if p.head_sha != s.revision { blocker(member: "run_provenance", detail: join([which, " run ", p.run_id, " ran at ", p.head_sha, ", not R=", s.revision], "")) } else { [] }, + if p.workflow_path != s.workflow_path { blocker(member: "run_provenance", detail: join([which, " run ", p.run_id, " ran workflow ", p.workflow_path, ", not ", s.workflow_path], "")) } else { [] }, + ), + ) +} + +fn run_provenance_blockers(s: RoadmapLaunchDeploymentSubject, f: RunProvenanceFact) -> List { + match f { + RunProvenanceUnobserved { detail: d } => blocker(member: "run_provenance", detail: join(["unobserved: ", d], "")) + RunProvenanceObserved { receipt_repository: rr, plan: p, apply: a, dashboard: d, receipt_workflow_ref: named } => + concat( + concat( + predecessor_run_blockers(which: "plan", s: s, expected_run_id: s.plan_run_id, p: p), + predecessor_run_blockers(which: "apply", s: s, expected_run_id: s.apply_run_id, p: a), + ), + concat( + predecessor_run_blockers(which: "dashboard", s: s, expected_run_id: s.dashboard_run_id, p: d), + concat( + if rr != s.repository { blocker(member: "run_provenance", detail: join(["the receipt run executes in ", rr, ", not the canonical ", s.repository], "")) } else { [] }, + match named { + ReceiptWorkflowRefNamesFile => [] + ReceiptWorkflowRefForeign { workflow_ref: wref } => blocker(member: "run_provenance", detail: join(["the receipt run's own GITHUB_WORKFLOW_REF ", wref, " does not name the generated workflow file"], "")) + }, + ), + ), + ) + } +} + +// The apply OUTCOME discriminators (review 5061891290 P0-2 and P0-1): a PartiallyApplied fleet +// apply, or one admitted against a revision other than R, can never certify DeploymentComplete -- +// exit code 0 is deliberately not the fleet terminal. +fn dashboard_provenance_blockers(plan: PlanReceiptFact, apply: ApplyReceiptFact, f: DashboardReceiptFact) -> List { + match f { + DashboardReceiptUnreadable { reason: _ } => [] + DashboardReceiptRead { receipt: d } => + concat( + match plan { + PlanReceiptUnreadable { reason: _ } => [] + PlanReceiptRead { receipt: p } => + if d.provenance.plan_receipt_identity != fleet_converge_plan_receipt_identity_hex(r: p) { + blocker(member: "dashboard_receipt", detail: join(["dashboard cited plan receipt ", d.provenance.plan_receipt_identity, ", not the plan receipt this join read (", fleet_converge_plan_receipt_identity_hex(r: p), ")"], "")) + } else { [] } + }, + match apply { + ApplyReceiptUnreadable { reason: _ } => [] + ApplyReceiptRead { receipt: a } => + if d.provenance.apply_receipt_identity != fleet_converge_apply_receipt_identity_hex(r: a) { + blocker(member: "dashboard_receipt", detail: join(["dashboard cited apply receipt ", d.provenance.apply_receipt_identity, ", not the apply receipt this join read (", fleet_converge_apply_receipt_identity_hex(r: a), ")"], "")) + } else { [] } + }, + ) + } +} + +fn apply_outcome_blockers(s: RoadmapLaunchDeploymentSubject, f: ApplyReceiptFact) -> List { + match f { + ApplyReceiptUnreadable { reason: _ } => [] + ApplyReceiptRead { receipt: a } => + concat( + match a.terminal { + FullyApplied => [] + PartiallyApplied { refused_axis_count: n, detail: d } => + blocker(member: "apply_receipt", detail: join(["fleet apply terminal is PartiallyApplied (", to_string(n), " refused axes): ", d], "")) + }, + if a.expected_revision_admitted != s.revision { + blocker(member: "apply_receipt", detail: join(["apply admitted expected revision ", a.expected_revision_admitted, ", not R=", s.revision], "")) + } else { [] }, + ) + } +} + +fn roadmap_launch_deployment_blockers(s: RoadmapLaunchDeploymentSubject, f: RoadmapLaunchDeploymentFacts) -> List { + concat( + concat( + concat( + concat(desired_revision_blockers(s: s, f: f.desired_revision), plan_receipt_blockers(s: s, f: f.plan_receipt)), + concat(concat(apply_receipt_blockers(s: s, plan: f.plan_receipt, f: f.apply_receipt), apply_outcome_blockers(s: s, f: f.apply_receipt)), concat(dashboard_provenance_blockers(plan: f.plan_receipt, apply: f.apply_receipt, f: f.dashboard_receipt), dashboard_receipt_blockers(s: s, plan: f.plan_receipt, f: f.dashboard_receipt))), + ), + concat( + concat(deployed_tree_blockers(s: s, f: f.deployed_tree), release_binding_blockers(s: s, f: f.release_binding)), + concat(readiness_blockers(s: s, f: f.readiness), transition_blockers(f: f.transition)), + ), + ), + concat( + concat( + concat(unit_standing_blockers(f: f.unit_standing), tick_blockers(s: s, f: f.tick_standing)), + concat(footprint_blockers(f: f.footprint), provider_blockers(f: f.provider_preflight)), + ), + concat( + concat(dispatch_preflight_blockers(s: s, f: f.dispatch_preflight), workflow_endpoint_blockers(tick: f.tick_standing, f: f.workflow_endpoint)), + concat( + concat(instance_endpoint_blockers(s: s, f: f.instance_endpoint), principal_blockers(s: s, f: f.principal)), + concat(concat(host_blockers(s: s, f: f.observed_host), window_blockers(f: f.observation_window)), run_provenance_blockers(s: s, f: f.run_provenance)), + ), + ), + ), + ) +} + +// ---------------------------------------------------------------- verdict +// THE PROOF IS sole_constructor: only this module can write the literal, and this module writes +// it in exactly one place -- the arm of roadmap_launch_deployment_verdict where the blocker list is +// empty. There is no public record literal that authors DeploymentComplete. +type DeploymentCompleteProof sole_constructor { + receipt_identity: String +} + +type RoadmapLaunchDeploymentVerdict + = DeploymentComplete { subject: RoadmapLaunchDeploymentSubject, facts: RoadmapLaunchDeploymentFacts, proof: DeploymentCompleteProof } + | DeploymentIncomplete { + subject: RoadmapLaunchDeploymentSubject, + facts: RoadmapLaunchDeploymentFacts, + first_blocker: RoadmapLaunchDeploymentBlocker, + further_blockers: List, + } + +fn roadmap_launch_deployment_verdict(subject: RoadmapLaunchDeploymentSubject, facts: RoadmapLaunchDeploymentFacts) -> RoadmapLaunchDeploymentVerdict { + let blockers = roadmap_launch_deployment_blockers(s: subject, f: facts) + match list_head(xs: blockers) { + HeadAbsent => + DeploymentComplete { + subject: subject, + facts: facts, + proof: DeploymentCompleteProof { receipt_identity: roadmap_launch_deployment_receipt_identity_hex(subject: subject, facts: facts) }, + } + HeadFound { value: first } => + DeploymentIncomplete { subject: subject, facts: facts, first_blocker: first, further_blockers: blockers |> skip(1) } + } +} + +fn verdict_blockers(v: RoadmapLaunchDeploymentVerdict) -> List { + match v { + DeploymentComplete { subject: _, facts: _, proof: _ } => [] + DeploymentIncomplete { subject: _, facts: _, first_blocker: f, further_blockers: rest } => concat([f], rest) + } +} + +// ---------------------------------------------------------------- rendering +fn subject_json(s: RoadmapLaunchDeploymentSubject) -> JsonValue { + json_object(members: [ + json_kv(key: "rlm1_merge_commit", value: json_string(s: s.rlm1_merge_commit)), + json_kv(key: "rlm1_contract_digest", value: json_string(s: s.rlm1_contract_digest)), + json_kv(key: "revision", value: json_string(s: s.revision)), + json_kv(key: "candidate_tree_oid", value: json_string(s: s.candidate_tree_oid)), + json_kv(key: "host", value: json_string(s: s.host)), + json_kv(key: "active_instance", value: json_string(s: s.active_instance)), + json_kv(key: "service_principal", value: json_string(s: s.service_principal)), + json_kv(key: "repository", value: json_string(s: s.repository)), + json_kv(key: "workflow_path", value: json_string(s: s.workflow_path)), + json_kv(key: "plan_run_id", value: json_string(s: s.plan_run_id)), + json_kv(key: "apply_run_id", value: json_string(s: s.apply_run_id)), + json_kv(key: "dashboard_run_id", value: json_string(s: s.dashboard_run_id)), + json_kv(key: "receipt_run_id", value: json_string(s: s.receipt_run_id)), + ]) +} + +fn subject_identity_text(s: RoadmapLaunchDeploymentSubject) -> String { + join([ + s.rlm1_merge_commit, "|", s.rlm1_contract_digest, "|", s.revision, "|", s.candidate_tree_oid, "|", s.host, "|", + s.active_instance, "|", s.service_principal, "|", s.repository, "|", s.workflow_path, "|", + s.plan_run_id, "|", s.apply_run_id, "|", s.dashboard_run_id, "|", s.receipt_run_id, + ], "") +} + +fn window_snapshot_text(w: WindowSnapshot) -> String { + join(["main=", w.main_hex, " desired=", w.desired_hex, " deployed=", w.deployed_revision_hex, " tree=", w.deployed_tree_hex, " running=", w.running_release_hex, " transition=", w.transition, " instance=", w.active_instance], "") +} + +fn tick_receipt_identity_of_read(r: BeltTickReceiptRead) -> String { + match r { + BeltTickReceiptPresent { receipt: t } => belt_tick_receipt_identity_hex(receipt: t) + BeltTickReceiptAbsent => "absent" + BeltTickReceiptUnreadable { reason: why } => join(["unreadable: ", why], "") + } +} + +fn facts_lines(f: RoadmapLaunchDeploymentFacts) -> List { + [ + join(["desired_revision: ", match f.desired_revision { + DesiredRevisionObserved { main_hex: m, desired_hex: d } => join(["main=", m, " desired=", d], "") + DesiredRevisionUnobserved { detail: d } => join(["unobserved: ", d], "") + }], ""), + join(["plan_receipt: ", match f.plan_receipt { + PlanReceiptRead { receipt: p } => join(["identity=", fleet_converge_plan_receipt_identity_hex(r: p), " run=", p.run.run_id, " revision=", p.run.run_revision, " host=", p.observed_host, " hash=", p.plan_artifact_hash, " lease=", p.lease_key, " generation=", to_string(p.planned_generation)], "") + PlanReceiptUnreadable { reason: r } => join(["unreadable: ", r], "") + }], ""), + join(["apply_receipt: ", match f.apply_receipt { + ApplyReceiptRead { receipt: a } => join(["identity=", fleet_converge_apply_receipt_identity_hex(r: a), " run=", a.run.run_id, " revision=", a.run.run_revision, " plan_run=", a.plan_run_id, " plan_identity=", a.plan_receipt_identity, " exit=", to_string(a.locked_apply_exit_code)], "") + ApplyReceiptUnreadable { reason: r } => join(["unreadable: ", r], "") + }], ""), + join(["dashboard_receipt: ", match f.dashboard_receipt { + DashboardReceiptRead { receipt: d } => join(["identity=", live_deploy_apply_receipt_identity_hex(r: d), " run=", d.run.run_id, " revision=", d.run.run_revision, " candidate=", d.candidate_revision, " tree=", d.candidate_tree_oid, " completed_at=", d.completed_at], "") + DashboardReceiptUnreadable { reason: r } => join(["unreadable: ", r], "") + }], ""), + join(["deployed_tree: ", deployed_root_tree_standing_text(s: f.deployed_tree)], ""), + join(["release_binding: ", release_binding_line(binding: f.release_binding)], ""), + join(["readiness: ", match f.readiness { + ReadinessServed { revision: r, surface: s } => join(["revision=", r, " surface=", s], "") + ReadinessRefused { detail: d } => join(["refused: ", d], "") + }], ""), + join(["transition: ", match f.transition { + ActuationPermitted => "permitted" + ActuationInhibited { record: _ } => "inhibited" + ActuationStandingUnreadable { cause: c } => join(["unreadable: ", c], "") + }], ""), + join(["unit_standing: ", launch_unit_standing_text(s: f.unit_standing)], ""), + join(["tick_standing: ", match f.tick_standing { + TickObserved { receipt: r, freshness: fr } => join(["identity=", belt_tick_receipt_identity_hex(receipt: r), " observed_at=", r.observed_at, " ", tick_freshness_text(f: fr)], "") + TickUnobserved { detail: d } => join(["unobserved: ", d], "") + }], ""), + join(["footprint: ", match f.footprint { + FootprintAdmitted => "admitted" + FootprintRefused { step: st, detail: d } => join(["refused at ", st, ": ", d], "") + }], ""), + join(["provider_preflight: ", match f.provider_preflight { + ProviderPreflightOk => "ok" + ProviderPreflightRefused { step: st, detail: d } => join(["refused at ", st, ": ", d], "") + }], ""), + join(["dispatch_preflight: ", match f.dispatch_preflight { + DispatchPreflightAdmittedFact { instance_id: i, revision_hex: r } => join(["admitted instance=", i, " revision=", r], "") + DispatchPreflightRefusedFact { refused_axis_count: n, detail: d } => join(["refused ", to_string(n), " axes: ", d], "") + }], ""), + join(["workflow_endpoint: ", match f.workflow_endpoint { + WorkflowServedFact { observe_refused: r, tick: t } => join(["served observe_refused=", if r { "true" } else { "false" }, " tick=", tick_receipt_identity_of_read(r: t)], "") + WorkflowRefusedFact { detail: d } => join(["refused: ", d], "") + }], ""), + join(["instance_endpoint: ", match f.instance_endpoint { + InstanceServedFact { instance_id: i, host_identity: h, actuation: a, repo_root: rr } => join(["instance=", i, " host=", h, " actuation=", a, " repo_root=", rr], "") + InstanceRefusedFact { detail: d } => join(["refused: ", d], "") + }], ""), + join(["principal: ", match f.principal { + PrincipalObserved { name: n } => n + PrincipalUnobserved { detail: d } => join(["unobserved: ", d], "") + }], ""), + join(["observed_host: ", match f.observed_host { + HostObserved { name: n } => n + HostUnobserved { detail: d } => join(["unobserved: ", d], "") + }], ""), + join(["run_provenance: ", match f.run_provenance { + RunProvenanceObserved { receipt_repository: rr, plan: p, apply: a, dashboard: d, receipt_workflow_ref: named } => + join(["plan=", p.run_id, ":", p.conclusion_wire, " apply=", a.run_id, ":", a.conclusion_wire, " dashboard=", d.run_id, ":", d.conclusion_wire, " receipt_repository=", rr, " receipt_workflow_ref=", match named { ReceiptWorkflowRefNamesFile => "names-file" ReceiptWorkflowRefForeign { workflow_ref: wref } => join(["foreign:", wref], "") }], "") + RunProvenanceUnobserved { detail: d } => join(["unobserved: ", d], "") + }], ""), + join(["observation_window: ", match f.observation_window { + WindowStable { opened: o, closed: _ } => join(["stable; ", window_snapshot_text(w: o)], "") + WindowChanged { opened: o, closed: c, changed: ch } => join(["changed (", join(ch, ", "), "); opened ", window_snapshot_text(w: o), "; closed ", window_snapshot_text(w: c)], "") + }], ""), + ] +} + +// ---------------------------------------------------------------- structured facts (P1-7) +// EVERY MEMBER SERIALIZES ITS TYPED ARM AND LOAD-BEARING PAYLOADS; the receipt identity hashes +// THIS canonical structure, and the prose lines remain a separate presentation projection +// ("facts_text"), never hashed and never parsed back. +fn fact_arm(arm: String, members: List) -> JsonValue { + json_object(members: concat([json_kv(key: "arm", value: json_string(s: arm))], members)) +} + +fn window_snapshot_json(w: WindowSnapshot) -> JsonValue { + json_object(members: [ + json_kv(key: "main_hex", value: json_string(s: w.main_hex)), + json_kv(key: "desired_hex", value: json_string(s: w.desired_hex)), + json_kv(key: "deployed_revision_hex", value: json_string(s: w.deployed_revision_hex)), + json_kv(key: "deployed_tree_hex", value: json_string(s: w.deployed_tree_hex)), + json_kv(key: "running_release_hex", value: json_string(s: w.running_release_hex)), + json_kv(key: "transition", value: json_string(s: w.transition)), + json_kv(key: "active_instance", value: json_string(s: w.active_instance)), + ]) +} + +fn predecessor_run_json(p: PredecessorRunProvenance) -> JsonValue { + json_object(members: [ + json_kv(key: "run_id", value: json_string(s: p.run_id)), + json_kv(key: "conclusion", value: json_string(s: p.conclusion_wire)), + json_kv(key: "head_sha", value: json_string(s: p.head_sha)), + json_kv(key: "workflow_path", value: json_string(s: p.workflow_path)), + ]) +} + +fn facts_json(f: RoadmapLaunchDeploymentFacts) -> JsonValue { + json_object(members: [ + json_kv(key: "desired_revision", value: match f.desired_revision { + DesiredRevisionObserved { main_hex: m, desired_hex: d } => fact_arm(arm: "observed", members: [json_kv(key: "main_hex", value: json_string(s: m)), json_kv(key: "desired_hex", value: json_string(s: d))]) + DesiredRevisionUnobserved { detail: d } => fact_arm(arm: "unobserved", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "plan_receipt", value: match f.plan_receipt { + PlanReceiptRead { receipt: p } => fact_arm(arm: "read", members: [json_kv(key: "receipt", value: fleet_converge_plan_receipt_json(r: p))]) + PlanReceiptUnreadable { reason: r } => fact_arm(arm: "unreadable", members: [json_kv(key: "reason", value: json_string(s: r))]) + }), + json_kv(key: "apply_receipt", value: match f.apply_receipt { + ApplyReceiptRead { receipt: a } => fact_arm(arm: "read", members: [json_kv(key: "receipt", value: fleet_converge_apply_receipt_json(r: a))]) + ApplyReceiptUnreadable { reason: r } => fact_arm(arm: "unreadable", members: [json_kv(key: "reason", value: json_string(s: r))]) + }), + json_kv(key: "dashboard_receipt", value: match f.dashboard_receipt { + DashboardReceiptRead { receipt: d } => fact_arm(arm: "read", members: [json_kv(key: "receipt", value: live_deploy_apply_receipt_json(r: d))]) + DashboardReceiptUnreadable { reason: r } => fact_arm(arm: "unreadable", members: [json_kv(key: "reason", value: json_string(s: r))]) + }), + json_kv(key: "deployed_tree", value: match f.deployed_tree { + DeployedRootTreeMatches { tree_hex: h } => fact_arm(arm: "matches", members: [json_kv(key: "tree_hex", value: json_string(s: h))]) + DeployedRootTreeMismatch { candidate_hex: c, deployed_hex: d } => fact_arm(arm: "mismatch", members: [json_kv(key: "candidate_hex", value: json_string(s: c)), json_kv(key: "deployed_hex", value: json_string(s: d))]) + DeployedRootTreeScopeDiverged { tree_hex: h, changed_paths: ps } => fact_arm(arm: "scope_diverged", members: [json_kv(key: "tree_hex", value: json_string(s: h)), json_kv(key: "changed_paths", value: json_string(s: ps as String))]) + DeployedRootTreeUnobserved { cause: c } => fact_arm(arm: "unobserved", members: [json_kv(key: "cause", value: json_string(s: c))]) + }), + json_kv(key: "release_binding", value: match f.release_binding { + ReleaseBoundToTree { revision: r, surface: sf } => fact_arm(arm: "bound", members: [json_kv(key: "revision", value: json_string(s: git_object_id_wire_hex(oid: r))), json_kv(key: "surface", value: json_string(s: sf.digest as String))]) + ReleaseMixed { tree_revision: t, process_revision: pr } => fact_arm(arm: "mixed", members: [json_kv(key: "tree_revision", value: json_string(s: git_object_id_wire_hex(oid: t))), json_kv(key: "process_revision", value: json_string(s: pr as String))]) + ReleaseSurfaceMixed { revision: r, expected_surface: e, process_surface: pf } => fact_arm(arm: "surface_mixed", members: [json_kv(key: "revision", value: json_string(s: git_object_id_wire_hex(oid: r))), json_kv(key: "expected_surface", value: json_string(s: e.digest as String)), json_kv(key: "process_surface", value: json_string(s: pf.digest as String))]) + ReleaseBindingUnobserved { cause: c } => fact_arm(arm: "unobserved", members: [json_kv(key: "cause", value: json_string(s: c))]) + }), + json_kv(key: "readiness", value: match f.readiness { + ReadinessServed { revision: r, surface: sf } => fact_arm(arm: "served", members: [json_kv(key: "revision", value: json_string(s: r)), json_kv(key: "surface", value: json_string(s: sf))]) + ReadinessRefused { detail: d } => fact_arm(arm: "refused", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "transition", value: match f.transition { + ActuationPermitted => fact_arm(arm: "permitted", members: []) + ActuationInhibited { record: _ } => fact_arm(arm: "inhibited", members: []) + ActuationStandingUnreadable { cause: c } => fact_arm(arm: "unreadable", members: [json_kv(key: "cause", value: json_string(s: c))]) + }), + json_kv(key: "unit_standing", value: json_object(members: [ + json_kv(key: "dashboard", value: match f.unit_standing.dashboard { + DashboardServiceConverged { unit: u } => fact_arm(arm: "converged", members: [json_kv(key: "unit", value: json_string(s: u))]) + DashboardServiceRefused { unit: u, property: pr, expected: e, observed: o } => fact_arm(arm: "refused", members: [json_kv(key: "unit", value: json_string(s: u)), json_kv(key: "property", value: json_string(s: pr)), json_kv(key: "expected", value: json_string(s: e)), json_kv(key: "observed", value: json_string(s: o))]) + }), + json_kv(key: "timer", value: match f.unit_standing.timer { + BeltTimerConverged { unit: u } => fact_arm(arm: "converged", members: [json_kv(key: "unit", value: json_string(s: u))]) + BeltTimerRefused { unit: u, property: pr, expected: e, observed: o } => fact_arm(arm: "refused", members: [json_kv(key: "unit", value: json_string(s: u)), json_kv(key: "property", value: json_string(s: pr)), json_kv(key: "expected", value: json_string(s: e)), json_kv(key: "observed", value: json_string(s: o))]) + }), + json_kv(key: "oneshot", value: match f.unit_standing.oneshot { + BeltOneshotConverged { unit: u, active_state: a } => fact_arm(arm: "converged", members: [json_kv(key: "unit", value: json_string(s: u)), json_kv(key: "active_state", value: json_string(s: a))]) + BeltOneshotRefused { unit: u, property: pr, expected: e, observed: o } => fact_arm(arm: "refused", members: [json_kv(key: "unit", value: json_string(s: u)), json_kv(key: "property", value: json_string(s: pr)), json_kv(key: "expected", value: json_string(s: e)), json_kv(key: "observed", value: json_string(s: o))]) + }), + ])), + json_kv(key: "tick_standing", value: match f.tick_standing { + TickObserved { receipt: r, freshness: fr } => + fact_arm(arm: "observed", members: [ + json_kv(key: "receipt_identity", value: json_string(s: belt_tick_receipt_identity_hex(receipt: r))), + json_kv(key: "observed_at", value: json_string(s: r.observed_at)), + json_kv(key: "execution", value: match r.execution { + TickExecuted { instance_id: i, deployed_revision: rev, spawn_mode: m } => fact_arm(arm: "executed", members: [json_kv(key: "instance_id", value: json_string(s: i)), json_kv(key: "deployed_revision", value: json_string(s: rev)), json_kv(key: "spawn_mode", value: json_string(s: spawn_mode_label(m: m)))]) + TickExecutionWithheld { refusal: why } => fact_arm(arm: "withheld", members: [json_kv(key: "refusal", value: json_string(s: why))]) + }), + json_kv(key: "freshness", value: match fr { + TickFresh { age_seconds: ag } => fact_arm(arm: "fresh", members: [json_kv(key: "age_seconds", value: json_string(s: to_string(second_count(ag))))]) + TickStale { age_seconds: ag, bound_seconds: bd } => fact_arm(arm: "stale", members: [json_kv(key: "age_seconds", value: json_string(s: to_string(second_count(ag)))), json_kv(key: "bound_seconds", value: json_string(s: to_string(second_count(bd))))]) + TickBeforeDeploy { observed_at: ob, deploy_completed_at: dc } => fact_arm(arm: "before_deploy", members: [json_kv(key: "observed_at", value: json_string(s: ob)), json_kv(key: "deploy_completed_at", value: json_string(s: dc))]) + TickInstantUnparseable { which: wh, text: tx } => fact_arm(arm: "instant_unparseable", members: [json_kv(key: "which", value: json_string(s: wh)), json_kv(key: "text", value: json_string(s: tx))]) + TickCadenceUnnormalizable { text: tx } => fact_arm(arm: "cadence_unnormalizable", members: [json_kv(key: "text", value: json_string(s: tx))]) + }), + ]) + TickUnobserved { detail: d } => fact_arm(arm: "unobserved", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "footprint", value: match f.footprint { + FootprintAdmitted => fact_arm(arm: "admitted", members: []) + FootprintRefused { step: st, detail: d } => fact_arm(arm: "refused", members: [json_kv(key: "step", value: json_string(s: st)), json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "provider_preflight", value: match f.provider_preflight { + ProviderPreflightOk => fact_arm(arm: "ok", members: []) + ProviderPreflightRefused { step: st, detail: d } => fact_arm(arm: "refused", members: [json_kv(key: "step", value: json_string(s: st)), json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "dispatch_preflight", value: match f.dispatch_preflight { + DispatchPreflightAdmittedFact { instance_id: i, revision_hex: r } => fact_arm(arm: "admitted", members: [json_kv(key: "instance_id", value: json_string(s: i)), json_kv(key: "revision_hex", value: json_string(s: r))]) + DispatchPreflightRefusedFact { refused_axis_count: n, detail: d } => fact_arm(arm: "refused", members: [json_kv(key: "refused_axis_count", value: json_string(s: to_string(n))), json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "workflow_endpoint", value: match f.workflow_endpoint { + WorkflowServedFact { observe_refused: orf, tick: t } => fact_arm(arm: "served", members: [json_kv(key: "observe_refused", value: json_bool(b: orf)), json_kv(key: "tick", value: match t { BeltTickReceiptPresent { receipt: tr } => json_string(s: belt_tick_receipt_identity_hex(receipt: tr)) BeltTickReceiptAbsent => json_string(s: "absent") BeltTickReceiptUnreadable { reason: rr } => json_string(s: join(["unreadable: ", rr], "")) })]) + WorkflowRefusedFact { detail: d } => fact_arm(arm: "refused", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "instance_endpoint", value: match f.instance_endpoint { + InstanceServedFact { instance_id: i, host_identity: h, actuation: a, repo_root: rr } => fact_arm(arm: "served", members: [json_kv(key: "instance_id", value: json_string(s: i)), json_kv(key: "host_identity", value: json_string(s: h)), json_kv(key: "actuation", value: json_string(s: a)), json_kv(key: "repo_root", value: json_string(s: rr))]) + InstanceRefusedFact { detail: d } => fact_arm(arm: "refused", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "principal", value: match f.principal { + PrincipalObserved { name: n } => fact_arm(arm: "observed", members: [json_kv(key: "name", value: json_string(s: n))]) + PrincipalUnobserved { detail: d } => fact_arm(arm: "unobserved", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "observed_host", value: match f.observed_host { + HostObserved { name: n } => fact_arm(arm: "observed", members: [json_kv(key: "name", value: json_string(s: n))]) + HostUnobserved { detail: d } => fact_arm(arm: "unobserved", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + json_kv(key: "observation_window", value: match f.observation_window { + WindowStable { opened: o, closed: c } => fact_arm(arm: "stable", members: [json_kv(key: "opened", value: window_snapshot_json(w: o)), json_kv(key: "closed", value: window_snapshot_json(w: c))]) + WindowChanged { opened: o, closed: c, changed: ch } => fact_arm(arm: "changed", members: [json_kv(key: "changed", value: json_array(elements: ch |> map(x => json_string(s: x)))), json_kv(key: "opened", value: window_snapshot_json(w: o)), json_kv(key: "closed", value: window_snapshot_json(w: c))]) + }), + json_kv(key: "run_provenance", value: match f.run_provenance { + RunProvenanceObserved { receipt_repository: rr, plan: p, apply: a, dashboard: d, receipt_workflow_ref: named } => + fact_arm(arm: "observed", members: [ + json_kv(key: "receipt_repository", value: json_string(s: rr)), + json_kv(key: "plan", value: predecessor_run_json(p: p)), + json_kv(key: "apply", value: predecessor_run_json(p: a)), + json_kv(key: "dashboard", value: predecessor_run_json(p: d)), + json_kv(key: "receipt_workflow_ref", value: match named { ReceiptWorkflowRefNamesFile => fact_arm(arm: "names_file", members: []) ReceiptWorkflowRefForeign { workflow_ref: wref } => fact_arm(arm: "foreign", members: [json_kv(key: "workflow_ref", value: json_string(s: wref))]) }), + ]) + RunProvenanceUnobserved { detail: d } => fact_arm(arm: "unobserved", members: [json_kv(key: "detail", value: json_string(s: d))]) + }), + ]) +} + +fn roadmap_launch_deployment_receipt_identity_hex(subject: RoadmapLaunchDeploymentSubject, facts: RoadmapLaunchDeploymentFacts) -> String { + content_hash_tagged_structural( + tag: "roadmap-launch-deployment-receipt-v1" as NonEmptyStr, + payload: content_hash_atom(value: join([subject_identity_text(s: subject), "\n", serialize_json(v: facts_json(f: facts))], "") as NonEmptyStr), + ).digest as String +} + +data roadmap_launch_deployment_receipt_schema: String = "roadmap-launch-deployment-receipt/v1" + +fn blocker_json(b: RoadmapLaunchDeploymentBlocker) -> JsonValue { + json_object(members: [ + json_kv(key: "member", value: json_string(s: b.member)), + json_kv(key: "detail", value: json_string(s: b.detail)), + ]) +} + +fn roadmap_launch_deployment_verdict_json(v: RoadmapLaunchDeploymentVerdict) -> JsonValue { + match v { + DeploymentComplete { subject: s, facts: f, proof: p } => + json_object(members: [ + json_kv(key: "schema", value: json_string(s: roadmap_launch_deployment_receipt_schema)), + json_kv(key: "verdict", value: json_string(s: "DeploymentComplete")), + json_kv(key: "receipt_identity", value: json_string(s: p.receipt_identity)), + json_kv(key: "subject", value: subject_json(s: s)), + json_kv(key: "facts", value: facts_json(f: f)), + json_kv(key: "facts_text", value: json_array(elements: facts_lines(f: f) |> map(l => json_string(s: l)))), + json_kv(key: "blockers", value: json_array(elements: [])), + ]) + DeploymentIncomplete { subject: s, facts: f, first_blocker: first, further_blockers: rest } => + json_object(members: [ + json_kv(key: "schema", value: json_string(s: roadmap_launch_deployment_receipt_schema)), + json_kv(key: "verdict", value: json_string(s: "DeploymentIncomplete")), + json_kv(key: "receipt_identity", value: json_string(s: roadmap_launch_deployment_receipt_identity_hex(subject: s, facts: f))), + json_kv(key: "subject", value: subject_json(s: s)), + json_kv(key: "facts", value: facts_json(f: f)), + json_kv(key: "facts_text", value: json_array(elements: facts_lines(f: f) |> map(l => json_string(s: l)))), + json_kv(key: "first_blocker", value: blocker_json(b: first)), + json_kv(key: "blockers", value: json_array(elements: concat([first], rest) |> map(b => blocker_json(b: b)))), + ]) + } +} + +fn roadmap_launch_deployment_receipt_document(v: RoadmapLaunchDeploymentVerdict) -> String { + serialize_json(v: roadmap_launch_deployment_verdict_json(v: v)) +} + +fn verdict_receipt_identity(v: RoadmapLaunchDeploymentVerdict) -> String { + match v { + DeploymentComplete { subject: _, facts: _, proof: p } => p.receipt_identity + DeploymentIncomplete { subject: s, facts: f, first_blocker: _, further_blockers: _ } => roadmap_launch_deployment_receipt_identity_hex(subject: s, facts: f) + } +} diff --git a/dag/gunbc/witness/witness_floor_workflow.dag b/dag/gunbc/witness/witness_floor_workflow.dag index 9ff60b4a55c..a6a2b6e9e59 100644 --- a/dag/gunbc/witness/witness_floor_workflow.dag +++ b/dag/gunbc/witness/witness_floor_workflow.dag @@ -1701,6 +1701,7 @@ fn required_lanes_aggregate_job() -> Job { data witness_floor_workflow: Workflow = { name: witness_floor_workflow_name, + run_name: none, on: [ WorkflowDispatch { inputs: [] }, MergeGroup, diff --git a/dag/test/claim/fleet/fleet_converge_receipt_witness_test.dag b/dag/test/claim/fleet/fleet_converge_receipt_witness_test.dag new file mode 100644 index 00000000000..f55814fd590 --- /dev/null +++ b/dag/test/claim/fleet/fleet_converge_receipt_witness_test.dag @@ -0,0 +1,243 @@ +module test.claim.fleet_converge_receipt_witness + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data receipt_fixture_r: String = "0123456789abcdef0123456789abcdef01234567" +data receipt_fixture_other_r: String = "89abcdef0123456789abcdef0123456789abcdef" + +fn fixture_run(run_id: String, revision: String) -> ActionsRunBinding { + ActionsRunBinding { + repository: "gunb-ai/gunbc", + workflow_ref: "gunb-ai/gunbc/.github/workflows/fleet-converge.yml@refs/heads/main", + run_id: run_id, + run_revision: revision, + } +} + +fn fixture_plan_receipt() -> FleetConvergePlanReceipt { + FleetConvergePlanReceipt { + run: fixture_run(run_id: "1001", revision: receipt_fixture_r), + observed_host: "srv1", + scope_wire: "full_host", + member_set_fingerprint_hex: "fnv1a64:0011223344556677", + plan_artifact_hash: "a1b2c3d4e5f60718", + observed_baseline_hex: "1122334455667788", + prior_generation: 4, + planned_generation: 5, + lease_key: "fleet-converge-plan:srv1", + lease_resource_fingerprint: "fnv1a64:aaaaaaaaaaaaaaaa", + lease_owner_fingerprint: "fnv1a64:bbbbbbbbbbbbbbbb", + lease_generation: 5, + apply_terminal: FullyApplied, + } +} + +fn fixture_apply_receipt() -> FleetConvergeApplyReceipt { + FleetConvergeApplyReceipt { + run: fixture_run(run_id: "1002", revision: receipt_fixture_r), + plan_run_id: "1001", + plan_receipt_identity: fleet_converge_plan_receipt_identity_hex(r: fixture_plan_receipt()), + plan_artifact_hash: "a1b2c3d4e5f60718", + observed_host: "srv1", + prior_generation: 4, + planned_generation: 5, + expected_revision_admitted: receipt_fixture_r, + terminal: FullyApplied, + locked_apply_exit_code: 0, + } +} + +// The plan receipt round-trips through its wire, identity included. +test fn witness_plan_receipt_round_trips() -> Bool { + let r = fixture_plan_receipt() + match fleet_converge_plan_receipt_decode(raw: serialize_json(v: fleet_converge_plan_receipt_json(r: r))) { + FleetConvergePlanReceiptDecoded { receipt: back } => + fleet_converge_plan_receipt_identity_hex(r: back) == fleet_converge_plan_receipt_identity_hex(r: r) + && back.run.run_id == "1001" + && back.planned_generation == 5 + && back.lease_key == "fleet-converge-plan:srv1" + FleetConvergePlanReceiptUnreadable { reason: _ } => false + } +} + +test fn witness_apply_receipt_round_trips() -> Bool { + let r = fixture_apply_receipt() + match fleet_converge_apply_receipt_decode(raw: serialize_json(v: fleet_converge_apply_receipt_json(r: r))) { + FleetConvergeApplyReceiptDecoded { receipt: back } => + fleet_converge_apply_receipt_identity_hex(r: back) == fleet_converge_apply_receipt_identity_hex(r: r) + && back.plan_receipt_identity == fleet_converge_plan_receipt_identity_hex(r: fixture_plan_receipt()) + && back.locked_apply_exit_code == 0 + FleetConvergeApplyReceiptUnreadable { reason: _ } => false + } +} + +// (2) RED: a tampered member no longer matches the carried identity, so the receipt refuses. +test fn witness_plan_receipt_with_tampered_host_refuses() -> Bool { + let wire = serialize_json(v: fleet_converge_plan_receipt_json(r: fixture_plan_receipt())) + let tampered = replace(wire, "\"observed_host\": \"srv1\"", "\"observed_host\": \"srv2\"") + tampered != wire + && (match fleet_converge_plan_receipt_decode(raw: tampered) { + FleetConvergePlanReceiptDecoded { receipt: _ } => false + FleetConvergePlanReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") + }) +} + +test fn witness_plan_receipt_wrong_schema_refuses() -> Bool { + match fleet_converge_plan_receipt_decode(raw: "{\"schema\":\"fleet-converge-plan-receipt/v0\"}") { + FleetConvergePlanReceiptDecoded { receipt: _ } => false + FleetConvergePlanReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "schema is not") + } +} + +// (3) Apply-to-plan binding: admitted on the exact plan, and four distinct refusals. +test fn witness_apply_plan_binding_admits_the_exact_plan() -> Bool { + match admit_apply_plan_binding( + plan: fixture_plan_receipt(), + apply_run: fixture_run(run_id: "1002", revision: receipt_fixture_r), + plan_run_id_input: "1001", + plan_hash_on_disk: "a1b2c3d4e5f60718", + ) { + PlanBindingAdmitted => true + _ => false + } +} + +test fn witness_apply_plan_binding_refuses_run_id_substitution() -> Bool { + match admit_apply_plan_binding( + plan: fixture_plan_receipt(), + apply_run: fixture_run(run_id: "1002", revision: receipt_fixture_r), + plan_run_id_input: "1099", + plan_hash_on_disk: "a1b2c3d4e5f60718", + ) { + PlanBindingRunIdMismatch { planned: p, requested: r } => p == "1001" && r == "1099" + _ => false + } +} + +test fn witness_apply_plan_binding_refuses_hash_substitution() -> Bool { + match admit_apply_plan_binding( + plan: fixture_plan_receipt(), + apply_run: fixture_run(run_id: "1002", revision: receipt_fixture_r), + plan_run_id_input: "1001", + plan_hash_on_disk: "ffffffffffffffff", + ) { + PlanBindingHashMismatch { receipt: a, on_disk: b } => a == "a1b2c3d4e5f60718" && b == "ffffffffffffffff" + _ => false + } +} + +test fn witness_apply_plan_binding_refuses_a_plan_from_another_revision() -> Bool { + match admit_apply_plan_binding( + plan: fixture_plan_receipt(), + apply_run: fixture_run(run_id: "1002", revision: receipt_fixture_other_r), + plan_run_id_input: "1001", + plan_hash_on_disk: "a1b2c3d4e5f60718", + ) { + PlanBindingRevisionMismatch { plan: p, apply: a } => p == receipt_fixture_r && a == receipt_fixture_other_r + _ => false + } +} + +test fn witness_apply_plan_binding_refuses_another_repository() -> Bool { + let foreign = ActionsRunBinding { + repository: "someone/fork", + workflow_ref: "someone/fork/.github/workflows/fleet-converge.yml@refs/heads/main", + run_id: "1002", + run_revision: receipt_fixture_r, + } + match admit_apply_plan_binding(plan: fixture_plan_receipt(), apply_run: foreign, plan_run_id_input: "1001", plan_hash_on_disk: "a1b2c3d4e5f60718") { + PlanBindingRepositoryMismatch { plan: p, apply: a } => p == "gunb-ai/gunbc" && a == "someone/fork" + _ => false + } +} + +// (4) Run binding: the workflow-file predicate and the wire round trip. +test fn witness_run_binding_names_its_workflow_file() -> Bool { + let run = fixture_run(run_id: "1", revision: receipt_fixture_r) + run_binding_names_workflow_file(binding: run, workflow_path: ".github/workflows/fleet-converge.yml") + && !run_binding_names_workflow_file(binding: run, workflow_path: ".github/workflows/witnesses.yml") + && !run_binding_names_workflow_file(binding: run, workflow_path: ".github/workflows/fleet-converge.ym") +} + +test fn witness_run_binding_round_trips_and_refuses_a_short_revision() -> Bool { + let run = fixture_run(run_id: "7", revision: receipt_fixture_r) + let ok = match actions_run_binding_of_json(doc: actions_run_binding_json(binding: run)) { + ActionsRunBindingDecoded { binding: back } => back.run_id == "7" && back.run_revision == receipt_fixture_r + ActionsRunBindingUnreadable { reason: _ } => false + } + let short = ActionsRunBinding { repository: "gunb-ai/gunbc", workflow_ref: "x", run_id: "7", run_revision: "abc" } + let refused = match actions_run_binding_of_json(doc: actions_run_binding_json(binding: short)) { + ActionsRunBindingDecoded { binding: _ } => false + ActionsRunBindingUnreadable { reason: r } => string_contains(s: r, pattern: "40-digit") + } + ok && refused +} + +test fn witness_admit_run_revision_is_a_comparison_of_two_origins() -> Bool { + let run = fixture_run(run_id: "1", revision: receipt_fixture_r) + (match admit_run_revision(binding: run, expected: receipt_fixture_r) { RunRevisionAdmitted { revision: r } => r == receipt_fixture_r RunRevisionRefused { expected: _, observed: _ } => false }) + && (match admit_run_revision(binding: run, expected: receipt_fixture_other_r) { RunRevisionAdmitted { revision: _ } => false RunRevisionRefused { expected: e, observed: o } => e == receipt_fixture_other_r && o == receipt_fixture_r }) +} + +// (5) The full lease epoch is carried and identity-bound: a tampered member refuses (review +// 5061891290 P1-5), one control per member. +test fn witness_plan_receipt_with_tampered_lease_resource_fingerprint_refuses() -> Bool { + let wire = serialize_json(v: fleet_converge_plan_receipt_json(r: fixture_plan_receipt())) + let tampered = replace(wire, "fnv1a64:aaaaaaaaaaaaaaaa", "fnv1a64:cccccccccccccccc") + tampered != wire + && (match fleet_converge_plan_receipt_decode(raw: tampered) { + FleetConvergePlanReceiptDecoded { receipt: _ } => false + FleetConvergePlanReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") + }) +} + +test fn witness_plan_receipt_with_tampered_lease_owner_fingerprint_refuses() -> Bool { + let wire = serialize_json(v: fleet_converge_plan_receipt_json(r: fixture_plan_receipt())) + let tampered = replace(wire, "fnv1a64:bbbbbbbbbbbbbbbb", "fnv1a64:dddddddddddddddd") + tampered != wire + && (match fleet_converge_plan_receipt_decode(raw: tampered) { + FleetConvergePlanReceiptDecoded { receipt: _ } => false + FleetConvergePlanReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") + }) +} + +test fn witness_plan_receipt_with_tampered_lease_generation_refuses() -> Bool { + let wire = serialize_json(v: fleet_converge_plan_receipt_json(r: fixture_plan_receipt())) + let tampered = replace(wire, "\"lease_generation\": \"5\"", "\"lease_generation\": \"6\"") + tampered != wire + && (match fleet_converge_plan_receipt_decode(raw: tampered) { + FleetConvergePlanReceiptDecoded { receipt: _ } => false + FleetConvergePlanReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") + }) +} + +// (6) The typed apply terminal survives the wire in both arms, and an unknown wire refuses. +test fn witness_apply_receipt_partially_applied_round_trips() -> Bool { + let base = fixture_apply_receipt() + let r = FleetConvergeApplyReceipt { + run: base.run, plan_run_id: base.plan_run_id, plan_receipt_identity: base.plan_receipt_identity, + plan_artifact_hash: base.plan_artifact_hash, observed_host: base.observed_host, + prior_generation: base.prior_generation, planned_generation: base.planned_generation, + expected_revision_admitted: base.expected_revision_admitted, + terminal: PartiallyApplied { refused_axis_count: 2, detail: "slot=1 cap=0 timer=1 fabric-cell=0 activation=0" }, + locked_apply_exit_code: 0, + } + match fleet_converge_apply_receipt_decode(raw: serialize_json(v: fleet_converge_apply_receipt_json(r: r))) { + FleetConvergeApplyReceiptDecoded { receipt: back } => + match back.terminal { + PartiallyApplied { refused_axis_count: n, detail: d } => n == 2 && string_contains(s: d, pattern: "timer=1") + FullyApplied => false + } + FleetConvergeApplyReceiptUnreadable { reason: _ } => false + } +} + +test fn witness_apply_receipt_with_unknown_terminal_wire_refuses() -> Bool { + let wire = serialize_json(v: fleet_converge_apply_receipt_json(r: fixture_apply_receipt())) + let tampered = replace(wire, "\"terminal\": \"fully_applied\"", "\"terminal\": \"mostly_applied\"") + tampered != wire + && (match fleet_converge_apply_receipt_decode(raw: tampered) { + FleetConvergeApplyReceiptDecoded { receipt: _ } => false + FleetConvergeApplyReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") || string_contains(s: r, pattern: "terminal wire is unknown") + }) +} diff --git a/dag/test/claim/live_deploy/apply_receipt_witness_test.dag b/dag/test/claim/live_deploy/apply_receipt_witness_test.dag new file mode 100644 index 00000000000..cdaa39f3fe1 --- /dev/null +++ b/dag/test/claim/live_deploy/apply_receipt_witness_test.dag @@ -0,0 +1,109 @@ +module test.claim.live_deploy.apply_receipt_witness + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data apply_receipt_fixture_r: String = "0123456789abcdef0123456789abcdef01234567" +data apply_receipt_fixture_tree: String = "fedcba9876543210fedcba9876543210fedcba98" + +fn fixture_dashboard_receipt() -> LiveDeployApplyReceipt { + LiveDeployApplyReceipt { + run: ActionsRunBinding { + repository: "gunb-ai/gunbc", + workflow_ref: "gunb-ai/gunbc/.github/workflows/fleet-converge.yml@refs/heads/main", + run_id: "1003", + run_revision: apply_receipt_fixture_r, + }, + target: "srv1", + expected_revision: apply_receipt_fixture_r, + candidate_revision: apply_receipt_fixture_r, + candidate_surface_identity: "0011223344556677", + candidate_tree_oid: apply_receipt_fixture_tree, + target_decision: DecisionProceed, + apply_outcome: LiveDeployApplyConverged, + readiness: LiveDeployReadinessObserved { revision: apply_receipt_fixture_r, surface: "0011223344556677" }, + provenance: LiveDeployFleetProvenance { plan_run_id: "1001", apply_run_id: "1002", plan_artifact_hash: "a1b2c3d4e5f60718", plan_receipt_identity: "1111111111111111", apply_receipt_identity: "2222222222222222" }, + completed_at: "2026-08-30T12:00:00Z", + } +} + +test fn witness_dashboard_receipt_round_trips() -> Bool { + let r = fixture_dashboard_receipt() + match live_deploy_apply_receipt_decode(raw: serialize_json(v: live_deploy_apply_receipt_json(r: r))) { + LiveDeployApplyReceiptDecoded { receipt: back } => + live_deploy_apply_receipt_identity_hex(r: back) == live_deploy_apply_receipt_identity_hex(r: r) + && back.candidate_tree_oid == apply_receipt_fixture_tree + && back.completed_at == "2026-08-30T12:00:00Z" + && (match back.target_decision { DecisionProceed => true _ => false }) + && (match back.readiness { LiveDeployReadinessObserved { revision: rv, surface: _ } => rv == apply_receipt_fixture_r LiveDeployReadinessRefused { detail: _ } => false }) + LiveDeployApplyReceiptUnreadable { reason: _ } => false + } +} + +test fn witness_dashboard_receipt_not_converged_round_trips_its_reason() -> Bool { + let base = fixture_dashboard_receipt() + let r = LiveDeployApplyReceipt { + run: base.run, target: base.target, expected_revision: base.expected_revision, candidate_revision: base.candidate_revision, + candidate_surface_identity: base.candidate_surface_identity, candidate_tree_oid: base.candidate_tree_oid, + target_decision: DecisionTerminalNoOp, + apply_outcome: LiveDeployApplyNotConverged { reason: "terminal no-op: this run installed nothing" }, + readiness: LiveDeployReadinessRefused { detail: "not observed" }, + provenance: base.provenance, completed_at: base.completed_at, + } + match live_deploy_apply_receipt_decode(raw: serialize_json(v: live_deploy_apply_receipt_json(r: r))) { + LiveDeployApplyReceiptDecoded { receipt: back } => + (match back.target_decision { DecisionTerminalNoOp => true _ => false }) + && (match back.apply_outcome { LiveDeployApplyNotConverged { reason: why } => string_contains(s: why, pattern: "terminal no-op") LiveDeployApplyConverged => false }) + LiveDeployApplyReceiptUnreadable { reason: _ } => false + } +} + +// RED: a tampered candidate revision fails the identity check. +test fn witness_dashboard_receipt_with_tampered_candidate_refuses() -> Bool { + let wire = serialize_json(v: live_deploy_apply_receipt_json(r: fixture_dashboard_receipt())) + let tampered = replace(wire, join(["\"candidate_revision\": \"", apply_receipt_fixture_r, "\""], ""), "\"candidate_revision\": \"89abcdef0123456789abcdef0123456789abcdef\"") + tampered != wire + && (match live_deploy_apply_receipt_decode(raw: tampered) { + LiveDeployApplyReceiptDecoded { receipt: _ } => false + LiveDeployApplyReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") + }) +} + +test fn witness_dashboard_receipt_without_completed_at_refuses() -> Bool { + let wire = serialize_json(v: live_deploy_apply_receipt_json(r: fixture_dashboard_receipt())) + let tampered = replace(wire, "\"completed_at\"", "\"finished_at\"") + match live_deploy_apply_receipt_decode(raw: tampered) { + LiveDeployApplyReceiptDecoded { receipt: _ } => false + LiveDeployApplyReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "completed_at") + } +} + +test fn witness_target_decision_labels_round_trip() -> Bool { + (match target_decision_label_of_text(s: target_decision_label_text(d: DecisionProceed)) { Present { value: DecisionProceed } => true _ => false }) + && (match target_decision_label_of_text(s: target_decision_label_text(d: DecisionTerminalNoOp)) { Present { value: DecisionTerminalNoOp } => true _ => false }) + && (match target_decision_label_of_text(s: target_decision_label_text(d: DecisionRefused)) { Present { value: DecisionRefused } => true _ => false }) + && (match target_decision_label_of_text(s: "maybe") { Absent => true Present { value: _ } => false }) +} + +// RED (review 57765 condition, ruled by the lane manager): an UNKNOWN target_decision label on the +// wire refuses through the DECODE path as a typed, located Unreadable -- the `_ => none` parse arm +// is a refusal feeder, not an absorbing fallback, and this is its executing evidence. +test fn witness_unknown_target_decision_label_is_unreadable_not_absorbed() -> Bool { + let wire = serialize_json(v: live_deploy_apply_receipt_json(r: fixture_dashboard_receipt())) + let tampered = replace(wire, "\"target_decision\": \"proceed\"", "\"target_decision\": \"bogus\"") + tampered != wire + && (match live_deploy_apply_receipt_decode(raw: tampered) { + LiveDeployApplyReceiptDecoded { receipt: _ } => false + LiveDeployApplyReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "target_decision is unknown") + }) +} + +// RED: a substituted predecessor receipt identity fails the identity check (review 5061891290 P1-4). +test fn witness_dashboard_receipt_with_tampered_plan_receipt_identity_refuses() -> Bool { + let wire = serialize_json(v: live_deploy_apply_receipt_json(r: fixture_dashboard_receipt())) + let tampered = replace(wire, "1111111111111111", "9999999999999999") + tampered != wire + && (match live_deploy_apply_receipt_decode(raw: tampered) { + LiveDeployApplyReceiptDecoded { receipt: _ } => false + LiveDeployApplyReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") + }) +} diff --git a/dag/test/claim/live_deploy/deployed_tree_observation_witness_test.dag b/dag/test/claim/live_deploy/deployed_tree_observation_witness_test.dag index b19ccfa76ec..ea5c10ff7e9 100644 --- a/dag/test/claim/live_deploy/deployed_tree_observation_witness_test.dag +++ b/dag/test/claim/live_deploy/deployed_tree_observation_witness_test.dag @@ -317,7 +317,7 @@ test fn witness_matching_revision_and_clean_tree_is_the_only_converged_arm() -> deployed_tree_standing_is_converged( standing: standing_for( desired: a, - obs: DeployedTreeObserved { revision: a, content: DeployedScopeMatchesCommit }, + obs: DeployedTreeObserved { revision: a, tree: a, content: DeployedScopeMatchesCommit }, ), ) } @@ -333,6 +333,7 @@ test fn witness_right_revision_with_modifications_is_not_converged_and_not_drift desired: a, obs: DeployedTreeObserved { revision: a, + tree: a, content: DeployedScopeDiverged { changed_paths: "dag/gunbc/ci/ci_spec.dag" as NonEmptyStr }, }, ) { @@ -354,7 +355,7 @@ test fn witness_a_different_revision_reports_drift_carrying_both_sides() -> Bool Present { value: b } => match standing_for( desired: a, - obs: DeployedTreeObserved { revision: b, content: DeployedScopeMatchesCommit }, + obs: DeployedTreeObserved { revision: b, tree: b, content: DeployedScopeMatchesCommit }, ) { DeployedTreeRevisionDrifted { desired: d, local: l } => git_object_id_eq(left: d, right: a) && git_object_id_eq(left: l, right: b) @@ -379,6 +380,7 @@ test fn witness_drift_dominates_modification() -> Bool { desired: a, obs: DeployedTreeObserved { revision: b, + tree: b, content: DeployedScopeDiverged { changed_paths: "x" as NonEmptyStr }, }, ) { diff --git a/dag/test/claim/live_deploy/release_binding_witness_test.dag b/dag/test/claim/live_deploy/release_binding_witness_test.dag index 320266227a9..81fdb52e992 100644 --- a/dag/test/claim/live_deploy/release_binding_witness_test.dag +++ b/dag/test/claim/live_deploy/release_binding_witness_test.dag @@ -56,7 +56,7 @@ fn tree_at(hex: String) -> DeployedTreeObservation? { match oid(hex: hex) { Absent => none Present { value: o } => - Present { value: DeployedTreeObserved { revision: o, content: DeployedScopeMatchesCommit } } + Present { value: DeployedTreeObserved { revision: o, tree: o, content: DeployedScopeMatchesCommit } } } } @@ -120,6 +120,7 @@ test fn witness_modifications_do_not_change_the_binding() -> Bool { match deployed_release_binding( observation: DeployedTreeObserved { revision: a, + tree: a, content: DeployedScopeDiverged { changed_paths: "dag/x.dag" as NonEmptyStr }, }, identity: process_saying(hex: hex_a, surface: probe_surface()), diff --git a/dag/test/claim/live_deploy/unit_standing_witness_test.dag b/dag/test/claim/live_deploy/unit_standing_witness_test.dag new file mode 100644 index 00000000000..21fd681c3ab --- /dev/null +++ b/dag/test/claim/live_deploy/unit_standing_witness_test.dag @@ -0,0 +1,167 @@ +module test.claim.live_deploy.unit_standing_witness + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data unit_fixture_exec: String = "/opt/gunbc/bin/gunbc run --source-root dag --source-root src/v2 --entry dag/gunbc/roadmap/roadmap_belt_actuate.dag --function belt_run_once_cli" + +fn fixture_service_expectation() -> ServiceUnitExpectation { + ServiceUnitExpectation { exec_start: unit_fixture_exec, user: "briansrls", working_directory: "/opt/gunbc/gunbc" } +} + +fn fixture_show_exec_start(argv: String) -> String { + join(["{ path=/opt/gunbc/bin/gunbc ; argv[]=", argv, " ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }"], "") +} + +fn fixture_oneshot_observed(unit_file_state: String, active_state: String, result: String, exec_main_status: String, user: String) -> ServiceUnitObserved { + ServiceUnitObserved { + load_state: "loaded", + unit_file_state: unit_file_state, + active_state: active_state, + sub_state: if active_state == "active" { "running" } else { "dead" }, + result: result, + exec_main_status: exec_main_status, + exec_start: fixture_show_exec_start(argv: unit_fixture_exec), + user: user, + working_directory: "/opt/gunbc/gunbc", + } +} + +fn fixture_timer_expectation() -> TimerUnitExpectation { + TimerUnitExpectation { target_unit: "gunbc-belt.service", on_boot_usec: microsecond(count: 120000000), on_unit_inactive_usec: microsecond(count: 60000000), accuracy_usec: microsecond(count: 5000000) } +} + +fn fixture_timer_observed(unit_file_state: String, active_state: String, target: String) -> TimerUnitObserved { + fixture_timer_observed_with_cadence(unit_file_state: unit_file_state, active_state: active_state, target: target, on_unit_inactive: "1min") +} + +fn fixture_timer_observed_with_cadence(unit_file_state: String, active_state: String, target: String, on_unit_inactive: String) -> TimerUnitObserved { + TimerUnitObserved { + load_state: "loaded", + unit_file_state: unit_file_state, + active_state: active_state, + sub_state: if active_state == "active" { "waiting" } else { "dead" }, + unit: target, + timers_monotonic: join(["{ OnBootUSec=2min ; next_elapse=1min 30s }, { OnUnitInactiveUSec=", on_unit_inactive, " ; next_elapse=5min 2s }"], ""), + accuracy_usec_value: "5s", + } +} + +// (1) systemctl show's ExecStart rendering yields the exact argv text. +test fn witness_exec_start_argv_is_read_from_the_show_rendering() -> Bool { + match exec_start_argv_of_show_value(value: fixture_show_exec_start(argv: unit_fixture_exec)) { + ExecStartArgv { argv_text: a } => a == unit_fixture_exec + ExecStartUnparseable { value: _ } => false + } +} + +test fn witness_exec_start_without_argv_segment_is_unparseable() -> Bool { + match exec_start_argv_of_show_value(value: "") { + ExecStartArgv { argv_text: _ } => false + ExecStartUnparseable { value: _ } => true + } +} + +// (2) POSITIVE CONTROL (ruling section 7): a correctly completed, currently INACTIVE oneshot is green. +test fn witness_inactive_successful_oneshot_is_converged() -> Bool { + match judge_belt_oneshot(unit: "gunbc-belt.service", expect: fixture_service_expectation(), o: fixture_oneshot_observed(unit_file_state: "static", active_state: "inactive", result: "success", exec_main_status: "0", user: "briansrls")) { + BeltOneshotConverged { unit: u, active_state: a } => u == "gunbc-belt.service" && a == "inactive" + BeltOneshotRefused { unit: _, property: _, expected: _, observed: _ } => false + } +} + +// (3) RED: a oneshot whose last run failed refuses on Result. +test fn witness_failed_oneshot_refuses_on_result() -> Bool { + match judge_belt_oneshot(unit: "gunbc-belt.service", expect: fixture_service_expectation(), o: fixture_oneshot_observed(unit_file_state: "static", active_state: "inactive", result: "exit-code", exec_main_status: "1", user: "briansrls")) { + BeltOneshotConverged { unit: _, active_state: _ } => false + BeltOneshotRefused { unit: _, property: p, expected: _, observed: o } => p == "Result" && o == "exit-code" + } +} + +// (4) RED: the oneshot running as the wrong principal refuses on User. +test fn witness_oneshot_under_wrong_user_refuses() -> Bool { + match judge_belt_oneshot(unit: "gunbc-belt.service", expect: fixture_service_expectation(), o: fixture_oneshot_observed(unit_file_state: "static", active_state: "inactive", result: "success", exec_main_status: "0", user: "root")) { + BeltOneshotConverged { unit: _, active_state: _ } => false + BeltOneshotRefused { unit: _, property: p, expected: e, observed: o } => p == "User" && e == "briansrls" && o == "root" + } +} + +// (5) RED: a DISABLED timer refuses on UnitFileState even when everything else reads clean -- +// this is the control the ruling names beside a fresh, manually fabricated tick. +test fn witness_disabled_timer_refuses() -> Bool { + match judge_belt_timer(unit: "gunbc-belt.timer", expect: fixture_timer_expectation(), o: fixture_timer_observed(unit_file_state: "disabled", active_state: "active", target: "gunbc-belt.service")) { + BeltTimerConverged { unit: _ } => false + BeltTimerRefused { unit: _, property: p, expected: e, observed: o } => p == "UnitFileState" && e == "enabled" && o == "disabled" + } +} + +test fn witness_enabled_waiting_timer_on_the_exact_service_is_converged() -> Bool { + match judge_belt_timer(unit: "gunbc-belt.timer", expect: fixture_timer_expectation(), o: fixture_timer_observed(unit_file_state: "enabled", active_state: "active", target: "gunbc-belt.service")) { + BeltTimerConverged { unit: u } => u == "gunbc-belt.timer" + BeltTimerRefused { unit: _, property: _, expected: _, observed: _ } => false + } +} + +test fn witness_timer_targeting_another_service_refuses() -> Bool { + match judge_belt_timer(unit: "gunbc-belt.timer", expect: fixture_timer_expectation(), o: fixture_timer_observed(unit_file_state: "enabled", active_state: "active", target: "gunbc-belt-lab.service")) { + BeltTimerConverged { unit: _ } => false + BeltTimerRefused { unit: _, property: p, expected: _, observed: _ } => p == "Unit" + } +} + +// (6) The dashboard service must be enabled AND active; ExecStart is compared exactly. +test fn witness_dashboard_service_with_drifted_exec_start_refuses() -> Bool { + let o = ServiceUnitObserved { + load_state: "loaded", unit_file_state: "enabled", active_state: "active", sub_state: "running", result: "success", exec_main_status: "0", + exec_start: fixture_show_exec_start(argv: "/opt/gunbc/bin/gunbc serve --release-revision 0000000000000000000000000000000000000000"), + user: "briansrls", working_directory: "/opt/gunbc/gunbc", + } + match judge_dashboard_service(unit: "gunbc-roadmap.service", expect: ServiceUnitExpectation { exec_start: "/opt/gunbc/bin/gunbc serve --release-revision 0123456789abcdef0123456789abcdef01234567", user: "briansrls", working_directory: "/opt/gunbc/gunbc" }, o: o) { + DashboardServiceConverged { unit: _ } => false + DashboardServiceRefused { unit: _, property: p, expected: _, observed: _ } => p == "ExecStart" + } +} + +// (7) The expectation is DERIVED from the emitted unit of the srv1 spec, never typed beside it. +test fn witness_expectations_derive_from_the_emitted_srv1_units() -> Bool { + let spec = deployment_spec_srv1() + let belt = match service_unit_expectation_of(file: live_deploy_belt_service_unit_file(spec: spec)) { + ServiceExpectationDerived { expectation: e } => e.user == (spec.service.service_user as String) && string_contains(s: e.exec_start, pattern: "belt_run_once_cli") + ServiceExpectationUnderivable { detail: _ } => false + } + let timer = match timer_unit_expectation_of(file: live_deploy_belt_timer_unit_file(spec: spec)) { + TimerExpectationDerived { expectation: e } => e.target_unit == (deployment_belt_service_unit_name(names: spec.names) as String) + TimerExpectationUnderivable { detail: _ } => false + } + let mismatched = match timer_unit_expectation_of(file: live_deploy_belt_service_unit_file(spec: spec)) { + TimerExpectationDerived { expectation: _ } => false + TimerExpectationUnderivable { detail: _ } => true + } + belt && timer && mismatched +} + +// (8) systemd.time(7) normalization: "60s" == "1min" is decidable equality; unknown units refuse. +test fn witness_systemd_duration_normalizes_equal_spellings() -> Bool { + (match systemd_duration_usec(text: "60s") { Present { value: v } => microsecond_count(v) == 60000000 Absent => false }) + && (match systemd_duration_usec(text: "1min") { Present { value: v } => microsecond_count(v) == 60000000 Absent => false }) + && (match systemd_duration_usec(text: "1min 30s") { Present { value: v } => microsecond_count(v) == 90000000 Absent => false }) + && (match systemd_duration_usec(text: "5s") { Present { value: v } => microsecond_count(v) == 5000000 Absent => false }) + && (match systemd_duration_usec(text: "2 fortnights") { Present { value: _ } => false Absent => true }) + && (match systemd_duration_usec(text: "") { Present { value: _ } => false Absent => true }) +} + +// (9) RED (review 5061891290 P1-3): a timer whose EFFECTIVE cadence is not R's emitted value +// refuses on the value, even though both directive names are present and everything else is clean. +test fn witness_timer_with_wrong_cadence_value_refuses() -> Bool { + match judge_belt_timer(unit: "gunbc-belt.timer", expect: fixture_timer_expectation(), o: fixture_timer_observed_with_cadence(unit_file_state: "enabled", active_state: "active", target: "gunbc-belt.service", on_unit_inactive: "5min")) { + BeltTimerConverged { unit: _ } => false + BeltTimerRefused { unit: _, property: p, expected: e, observed: o } => p == "OnUnitInactiveUSec" && e == "60000000" && o == "300000000" + } +} + +// (10) The derived expectation carries R's cadence VALUES from the emitted unit, one authority. +test fn witness_derived_timer_expectation_carries_the_emitted_cadence() -> Bool { + match timer_unit_expectation_of(file: live_deploy_belt_timer_unit_file(spec: deployment_spec_srv1())) { + TimerExpectationDerived { expectation: e } => microsecond_count(e.on_unit_inactive_usec) == 60000000 && microsecond_count(e.on_boot_usec) == 120000000 && microsecond_count(e.accuracy_usec) == 5000000 + TimerExpectationUnderivable { detail: _ } => false + } +} diff --git a/dag/test/claim/roadmap/roadmap_belt_actuate_witness_test.dag b/dag/test/claim/roadmap/roadmap_belt_actuate_witness_test.dag index b1e9145e17c..4332220a750 100644 --- a/dag/test/claim/roadmap/roadmap_belt_actuate_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_belt_actuate_witness_test.dag @@ -1268,6 +1268,7 @@ test fn observe_refused_spawn_does_not_zero_verify_pass() -> Bool { let receipt = belt_tick_receipt_from_result( result: result, observed_at: "2026-08-02T02:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, ) match receipt.spawn_pass { BeltPassRefused { reason: _ } => true @@ -1297,6 +1298,7 @@ test fn observe_refused_with_empty_verify_is_deferred_not_recorded() -> Bool { match belt_tick_receipt_from_result( result: result, observed_at: "2026-08-02T02:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, ).verify_pass { BeltPassDeferred { reason: _ } => true BeltPassRecorded => false @@ -1345,6 +1347,7 @@ test fn verification_presentation_decouples_verify_from_spawn_observe_refusal() publish_outcomes: [], }, observed_at: "2026-08-02T02:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, ) let detail = belt_verification_presentation_detail( progress: completed_attempt_progress(node_id: "pres-decouple-node"), @@ -1357,6 +1360,7 @@ test fn verification_presentation_decouples_verify_from_spawn_observe_refusal() test fn verification_presentation_verify_refused_is_blocked() -> Bool { let receipt = BeltTickReceipt { observed_at: "2026-08-02T02:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, launches: [], spawn_pass: BeltPassRecorded, teardown_pass: BeltPassRecorded, @@ -1373,6 +1377,7 @@ test fn verification_presentation_verify_refused_is_blocked() -> Bool { test fn verification_presentation_verify_failed_is_failed() -> Bool { let receipt = BeltTickReceipt { observed_at: "2026-08-02T02:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, launches: [], spawn_pass: BeltPassRecorded, teardown_pass: BeltPassRecorded, @@ -2218,6 +2223,7 @@ test fn transition_halted_tick_refuses_all_four_passes() -> Bool { publish_outcomes: [], }, observed_at: "2026-08-30T02:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, ) all_four_refused_with(receipt: receipt, pattern: "deployment transition in force") } @@ -2234,6 +2240,7 @@ test fn revision_refusal_keeps_verify_independently_recorded() -> Bool { publish_outcomes: [], }, observed_at: "2026-08-30T02:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, ) (match receipt.spawn_pass { BeltPassRefused { reason } => string_contains(s: reason, pattern: "not the admitted fleet revision") _ => false }) && (match receipt.teardown_pass { BeltPassRefused { reason: _ } => true _ => false }) diff --git a/dag/test/claim/roadmap/roadmap_launch_admission_witness_test.dag b/dag/test/claim/roadmap/roadmap_launch_admission_witness_test.dag index d2ca541d744..e89ae01ad5c 100644 --- a/dag/test/claim/roadmap/roadmap_launch_admission_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_launch_admission_witness_test.dag @@ -757,6 +757,7 @@ test fn witness_tick_receipt_round_trips_launch_identities() -> Bool { let id = launch_fixture_identity(node_id: parent_id(), cause: Timer, mode: AutomaticReady) let receipt = BeltTickReceipt { observed_at: "2026-08-30T00:00:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: "0123456789abcdef0123456789abcdef01234567", spawn_mode: ManualReady }, launches: [id], spawn_pass: BeltPassRecorded, teardown_pass: BeltPassRecorded, diff --git a/dag/test/claim/roadmap/roadmap_launch_deployment_observe_witness_test.dag b/dag/test/claim/roadmap/roadmap_launch_deployment_observe_witness_test.dag new file mode 100644 index 00000000000..2f79da4f4c6 --- /dev/null +++ b/dag/test/claim/roadmap/roadmap_launch_deployment_observe_witness_test.dag @@ -0,0 +1,158 @@ +module test.claim.roadmap.roadmap_launch_deployment_observe_witness + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data observe_fixture_r: String = "0123456789abcdef0123456789abcdef01234567" + +fn fixture_v4_tick() -> BeltTickReceipt { + BeltTickReceipt { + observed_at: "2026-08-30T12:01:00Z", + execution: TickExecuted { instance_id: "srv1-live", deployed_revision: observe_fixture_r, spawn_mode: ManualReady }, + launches: [], + spawn_pass: BeltPassRecorded, + teardown_pass: BeltPassRecorded, + verify_pass: BeltPassRecorded, + publish_pass: BeltPassRecorded, + } +} + +data fixture_v3_tick_wire: String = "{\"schema\":\"roadmap-belt-tick-receipt/v3\",\"observed_at\":\"2026-08-30T12:01:00Z\",\"launches\":[],\"spawn_pass\":{\"outcome\":\"recorded\"},\"teardown_pass\":{\"outcome\":\"recorded\"},\"verify_pass\":{\"outcome\":\"recorded\"},\"publish_pass\":{\"outcome\":\"recorded\"}}" + +// (1) ISO-8601 UTC instants decode to epoch seconds on known values (Python datetime as the oracle). +test fn witness_iso8601_utc_epoch_seconds_matches_known_instants() -> Bool { + (match iso8601_utc_epoch_seconds(text: "2026-08-30T12:00:00Z") { Present { value: s } => second_count(s) == 1788091200 Absent => false }) + && (match iso8601_utc_epoch_seconds(text: "2000-03-01T00:00:00Z") { Present { value: s } => second_count(s) == 951868800 Absent => false }) + && (match iso8601_utc_epoch_seconds(text: "1970-01-01T00:00:00Z") { Present { value: s } => second_count(s) == 0 Absent => false }) + && (match iso8601_utc_epoch_seconds(text: "2026-08-30 12:00:00") { Present { value: _ } => false Absent => true }) + && (match iso8601_utc_epoch_seconds(text: "clock-unreadable") { Present { value: _ } => false Absent => true }) + && (match iso8601_utc_epoch_seconds(text: "2026-02-30T00:00:00Z") { Present { value: _ } => false Absent => true }) + && (match iso8601_utc_epoch_seconds(text: "2027-04-31T00:00:00Z") { Present { value: _ } => false Absent => true }) + && (match iso8601_utc_epoch_seconds(text: "2024-02-29T00:00:00Z") { Present { value: s } => second_count(s) == 1709164800 Absent => false }) +} + +// (2) Freshness: fresh within the cadence bound, stale beyond it, refused before the deploy, and +// refused on an unparseable instant -- four typed arms, none of which is a default. +test fn witness_tick_freshness_arms() -> Bool { + let deploy = "2026-08-30T12:00:00Z" + (match belt_tick_freshness(observed_at: "2026-08-30T12:01:00Z", deploy_completed_at: deploy, now: "2026-08-30T12:02:00Z") { TickFresh { age_seconds: a } => second_count(a) == 60 _ => false }) + && (match belt_tick_freshness(observed_at: "2026-08-30T12:01:00Z", deploy_completed_at: deploy, now: "2026-08-30T12:10:00Z") { TickStale { age_seconds: a, bound_seconds: b } => second_count(a) == 540 && second_count(b) == 180 _ => false }) + && (match belt_tick_freshness(observed_at: "2026-08-30T11:59:00Z", deploy_completed_at: deploy, now: "2026-08-30T12:00:30Z") { TickBeforeDeploy { observed_at: _, deploy_completed_at: _ } => true _ => false }) + && (match belt_tick_freshness(observed_at: "clock-unreadable", deploy_completed_at: deploy, now: "2026-08-30T12:00:30Z") { TickInstantUnparseable { which: w, text: _ } => string_contains(s: w, pattern: "observed_at") _ => false }) +} + +// (3) /instance.json decodes the members the serve route writes, and refuses another schema. +test fn witness_instance_document_decodes_the_served_members() -> Bool { + let body = dashboard_instance_json(instance: srv1_live_dashboard_instance()) + match dashboard_instance_document_decode(raw: body) { + InstanceDocumentServed { document: d } => d.instance_id == "srv1-live" && d.host_identity == "srv1" && d.actuation == "actuating" + InstanceDocumentRefused { detail: _ } => false + } +} + +test fn witness_instance_document_refuses_another_schema() -> Bool { + match dashboard_instance_document_decode(raw: "{\"schema\":\"roadmap-dashboard-instance/v0\",\"instance_id\":\"srv1-live\"}") { + InstanceDocumentServed { document: _ } => false + InstanceDocumentRefused { detail: d } => string_contains(s: d, pattern: "schema is not") + } +} + +// (4) /workflow.json: the embedded v4 tick decodes; an embedded v3 tick is UNREADABLE, not Idle; +// an absent tick is Absent; observe_refused is carried, not swallowed. +fn workflow_wire(observe_refused: String, belt_tick: String) -> String { + join(["{\"schema\":\"roadmap-workflow/v1\",\"observe_refused\":", observe_refused, ",\"attempts\":[],\"belt_tick\":", belt_tick, "}"], "") +} + +test fn witness_workflow_document_decodes_an_embedded_v4_tick() -> Bool { + let tick = serialize_json(v: belt_tick_receipt_wire_json_from_read(tick_read: BeltTickReceiptPresent { receipt: fixture_v4_tick() })) + match roadmap_workflow_document_decode(raw: workflow_wire(observe_refused: "false", belt_tick: tick)) { + WorkflowDocumentServed { document: d } => + !d.observe_refused + && (match d.belt_tick { + BeltTickReceiptPresent { receipt: r } => belt_tick_receipt_identity_hex(receipt: r) == belt_tick_receipt_identity_hex(receipt: fixture_v4_tick()) + _ => false + }) + WorkflowDocumentRefused { detail: _ } => false + } +} + +test fn witness_workflow_document_with_embedded_v3_tick_is_unreadable() -> Bool { + let tick = join(["{\"present\":true,\"receipt\":", fixture_v3_tick_wire, "}"], "") + match roadmap_workflow_document_decode(raw: workflow_wire(observe_refused: "false", belt_tick: tick)) { + WorkflowDocumentServed { document: d } => + match d.belt_tick { + BeltTickReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "schema is unknown") + _ => false + } + WorkflowDocumentRefused { detail: _ } => false + } +} + +test fn witness_workflow_document_without_a_tick_is_absent_and_refused_ledger_is_carried() -> Bool { + match roadmap_workflow_document_decode(raw: workflow_wire(observe_refused: "true", belt_tick: "{\"present\":false}")) { + WorkflowDocumentServed { document: d } => + d.observe_refused && (match d.belt_tick { BeltTickReceiptAbsent => true _ => false }) + WorkflowDocumentRefused { detail: _ } => false + } +} + +test fn witness_workflow_document_of_another_schema_refuses() -> Bool { + match roadmap_workflow_document_decode(raw: "{\"schema\":\"roadmap-workflow/v0\",\"observe_refused\":false}") { + WorkflowDocumentServed { document: _ } => false + WorkflowDocumentRefused { detail: d } => string_contains(s: d, pattern: "schema is not") + } +} + +// (5) The on-disk v3 receipt refuses the v4 decoder outright (ruling section 6: an old v3 refuses). +test fn witness_v3_tick_receipt_refuses_the_v4_decoder() -> Bool { + match belt_tick_receipt_decode(raw: fixture_v3_tick_wire) { + BeltTickReceiptPresent { receipt: _ } => false + BeltTickReceiptAbsent => false + BeltTickReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "schema is unknown") + } +} + +test fn witness_v4_tick_receipt_round_trips_execution_and_identity() -> Bool { + let t = fixture_v4_tick() + match belt_tick_receipt_decode(raw: serialize_json(v: belt_tick_receipt_json_value(receipt: t))) { + BeltTickReceiptPresent { receipt: back } => + belt_tick_receipt_identity_hex(receipt: back) == belt_tick_receipt_identity_hex(receipt: t) + && (match back.execution { TickExecuted { instance_id: i, deployed_revision: r, spawn_mode: ManualReady } => i == "srv1-live" && r == observe_fixture_r _ => false }) + _ => false + } +} + +test fn witness_v4_tick_receipt_with_tampered_mode_refuses() -> Bool { + let wire = serialize_json(v: belt_tick_receipt_json_value(receipt: fixture_v4_tick())) + let tampered = replace(wire, "\"spawn_mode\": \"manual_ready\"", "\"spawn_mode\": \"automatic_ready\"") + tampered != wire + && (match belt_tick_receipt_decode(raw: tampered) { + BeltTickReceiptUnreadable { reason: r } => string_contains(s: r, pattern: "identity does not match") + _ => false + }) +} + +// (6) The deployed root-tree argv is admitted by the fleet portability wall (the `HEAD:` spelling), +// and the join refuses a deployed tree that is not the candidate's. +test fn witness_root_tree_argv_is_admitted_by_the_portability_wall() -> Bool { + match portable_remote_words(raws: deployed_tree_root_tree_argv(repo: "/opt/gunbc/gunbc" as NonEmptyStr)) { + Present { value: _ } => true + Absent => false + } +} + +test fn witness_root_tree_standing_refuses_a_mismatched_tree() -> Bool { + let a = "fedcba9876543210fedcba9876543210fedcba98" + let b = "0000000000000000000000000000000000000001" + match git_object_id_from_untagged_hex(hex: observe_fixture_r) { + Absent => false + Present { value: rev } => + match git_object_id_from_untagged_hex(hex: a) { + Absent => false + Present { value: tree } => { + let obs = DeployedTreeObserved { revision: rev, tree: tree, content: DeployedScopeMatchesCommit } + (match deployed_root_tree_standing(candidate_tree_hex: a, observation: obs) { DeployedRootTreeMatches { tree_hex: h } => h == a _ => false }) + && (match deployed_root_tree_standing(candidate_tree_hex: b, observation: obs) { DeployedRootTreeMismatch { candidate_hex: c, deployed_hex: d } => c == b && d == a _ => false }) + } + } + } +} diff --git a/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag b/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag new file mode 100644 index 00000000000..9c985f9b078 --- /dev/null +++ b/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag @@ -0,0 +1,659 @@ +module test.claim.roadmap.roadmap_launch_deployment_receipt_witness + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// THE FIXTURE IS ONE COMPLETE FACT POPULATION; EVERY RED CONTROL REPLACES EXACTLY ONE MEMBER and +// proves the join becomes Incomplete with that member named (RLM-2 ruling, "Superseding RLM-2a +// acceptance bar"). The positive control is the unreplaced population, which must be Complete -- +// otherwise every red below would be red by vacancy. + +data rlm_fixture_r: String = "0123456789abcdef0123456789abcdef01234567" +data rlm_fixture_other_r: String = "89abcdef0123456789abcdef0123456789abcdef" +data rlm_fixture_tree: String = "fedcba9876543210fedcba9876543210fedcba98" +data rlm_fixture_other_tree: String = "0000000000000000000000000000000000000001" +data rlm_fixture_surface: String = "0011223344556677" +data rlm_fixture_plan_hash: String = "a1b2c3d4e5f60718" +data rlm_fixture_deploy_completed_at: String = "2026-08-30T12:00:00Z" +data rlm_fixture_tick_observed_at: String = "2026-08-30T12:01:00Z" +data rlm_fixture_now: String = "2026-08-30T12:01:30Z" + +fn rlm_run(run_id: String, revision: String) -> ActionsRunBinding { + ActionsRunBinding { + repository: "gunb-ai/gunbc", + workflow_ref: "gunb-ai/gunbc/.github/workflows/fleet-converge.yml@refs/heads/main", + run_id: run_id, + run_revision: revision, + } +} + +fn rlm_service_principal() -> String { + deployment_spec_srv1().service.service_user as String +} + +fn rlm_subject() -> RoadmapLaunchDeploymentSubject { + RoadmapLaunchDeploymentSubject { + rlm1_merge_commit: rlm1_merge_commit, + rlm1_contract_digest: rlm1_contract_digest_frozen, + revision: rlm_fixture_r, + candidate_tree_oid: rlm_fixture_tree, + host: "srv1", + active_instance: "srv1-live", + service_principal: rlm_service_principal(), + repository: "gunb-ai/gunbc", + workflow_path: rlm_workflow_file_path(), + plan_run_id: "1001", + apply_run_id: "1002", + dashboard_run_id: "1003", + receipt_run_id: "1004", + } +} + +fn rlm_plan(run: ActionsRunBinding, host: String) -> FleetConvergePlanReceipt { + FleetConvergePlanReceipt { + run: run, + observed_host: host, + scope_wire: "full_host", + member_set_fingerprint_hex: "fnv1a64:0011223344556677", + plan_artifact_hash: rlm_fixture_plan_hash, + observed_baseline_hex: "1122334455667788", + prior_generation: 4, + planned_generation: 5, + lease_key: "fleet-converge-plan:srv1", + lease_resource_fingerprint: "fnv1a64:aaaaaaaaaaaaaaaa", + lease_owner_fingerprint: "fnv1a64:bbbbbbbbbbbbbbbb", + lease_generation: 5, + apply_terminal: FullyApplied, + } +} + +fn rlm_fixture_plan() -> FleetConvergePlanReceipt { + rlm_plan(run: rlm_run(run_id: "1001", revision: rlm_fixture_r), host: "srv1") +} + +fn rlm_plan_identity() -> String { + fleet_converge_plan_receipt_identity_hex(r: rlm_fixture_plan()) +} + +fn rlm_fixture_apply() -> FleetConvergeApplyReceipt { + rlm_apply(run: rlm_run(run_id: "1002", revision: rlm_fixture_r), plan_identity: rlm_plan_identity()) +} + +fn rlm_apply_identity() -> String { + fleet_converge_apply_receipt_identity_hex(r: rlm_fixture_apply()) +} + +fn rlm_apply(run: ActionsRunBinding, plan_identity: String) -> FleetConvergeApplyReceipt { + FleetConvergeApplyReceipt { + run: run, + plan_run_id: "1001", + plan_receipt_identity: plan_identity, + plan_artifact_hash: rlm_fixture_plan_hash, + observed_host: "srv1", + prior_generation: 4, + planned_generation: 5, + expected_revision_admitted: rlm_fixture_r, + terminal: FullyApplied, + locked_apply_exit_code: 0, + } +} + +fn rlm_dashboard(run: ActionsRunBinding, expected: String) -> LiveDeployApplyReceipt { + LiveDeployApplyReceipt { + run: run, + target: "srv1", + expected_revision: expected, + candidate_revision: expected, + candidate_surface_identity: rlm_fixture_surface, + candidate_tree_oid: rlm_fixture_tree, + target_decision: DecisionProceed, + apply_outcome: LiveDeployApplyConverged, + readiness: LiveDeployReadinessObserved { revision: expected, surface: rlm_fixture_surface }, + provenance: LiveDeployFleetProvenance { + plan_run_id: "1001", apply_run_id: "1002", plan_artifact_hash: rlm_fixture_plan_hash, + plan_receipt_identity: rlm_plan_identity(), apply_receipt_identity: rlm_apply_identity(), + }, + completed_at: rlm_fixture_deploy_completed_at, + } +} + +fn rlm_tick(instance_id: String, revision: String, mode: SpawnMode, observed_at: String) -> BeltTickReceipt { + BeltTickReceipt { + observed_at: observed_at, + execution: TickExecuted { instance_id: instance_id, deployed_revision: revision, spawn_mode: mode }, + launches: [], + spawn_pass: BeltPassRecorded, + teardown_pass: BeltPassRecorded, + verify_pass: BeltPassRecorded, + publish_pass: BeltPassRecorded, + } +} + +fn rlm_tick_fact(t: BeltTickReceipt) -> TickFact { + TickObserved { receipt: t, freshness: belt_tick_freshness(observed_at: t.observed_at, deploy_completed_at: rlm_fixture_deploy_completed_at, now: rlm_fixture_now) } +} + +fn rlm_units(timer: BeltTimerStanding, oneshot: BeltOneshotStanding) -> LaunchUnitStanding { + LaunchUnitStanding { + dashboard: DashboardServiceConverged { unit: "gunbc-roadmap.service" }, + timer: timer, + oneshot: oneshot, + } +} + +fn rlm_release_binding(revision_hex: String) -> DeployedReleaseBinding { + match git_object_id_from_untagged_hex(hex: revision_hex) { + Present { value: oid } => ReleaseBoundToTree { revision: oid, surface: Fnv1a64Structural { digest: rlm_fixture_surface as Fnv1a64StructuralDigestHex } } + Absent => ReleaseBindingUnobserved { cause: "fixture revision undecodable" } + } +} + +fn rlm_predecessor(run_id: String) -> PredecessorRunProvenance { + PredecessorRunProvenance { + run_id: run_id, + conclusion_wire: "success", + head_sha: rlm_fixture_r, + workflow_path: rlm_workflow_file_path(), + } +} + +fn rlm_provenance() -> RunProvenanceFact { + RunProvenanceObserved { + receipt_repository: "gunb-ai/gunbc", + plan: rlm_predecessor(run_id: "1001"), + apply: rlm_predecessor(run_id: "1002"), + dashboard: rlm_predecessor(run_id: "1003"), + receipt_workflow_ref: ReceiptWorkflowRefNamesFile, + } +} + +fn rlm_snapshot() -> WindowSnapshot { + WindowSnapshot { + main_hex: rlm_fixture_r, + desired_hex: rlm_fixture_r, + deployed_revision_hex: rlm_fixture_r, + deployed_tree_hex: rlm_fixture_tree, + running_release_hex: rlm_fixture_r, + transition: "permitted", + active_instance: "srv1-live", + } +} + +fn rlm_complete_facts() -> RoadmapLaunchDeploymentFacts { + let plan = rlm_plan(run: rlm_run(run_id: "1001", revision: rlm_fixture_r), host: "srv1") + let tick = rlm_tick(instance_id: "srv1-live", revision: rlm_fixture_r, mode: ManualReady, observed_at: rlm_fixture_tick_observed_at) + RoadmapLaunchDeploymentFacts { + desired_revision: DesiredRevisionObserved { main_hex: rlm_fixture_r, desired_hex: rlm_fixture_r }, + plan_receipt: PlanReceiptRead { receipt: plan }, + apply_receipt: ApplyReceiptRead { receipt: rlm_apply(run: rlm_run(run_id: "1002", revision: rlm_fixture_r), plan_identity: fleet_converge_plan_receipt_identity_hex(r: plan)) }, + dashboard_receipt: DashboardReceiptRead { receipt: rlm_dashboard(run: rlm_run(run_id: "1003", revision: rlm_fixture_r), expected: rlm_fixture_r) }, + deployed_tree: DeployedRootTreeMatches { tree_hex: rlm_fixture_tree }, + release_binding: rlm_release_binding(revision_hex: rlm_fixture_r), + readiness: ReadinessServed { revision: rlm_fixture_r, surface: rlm_fixture_surface }, + transition: ActuationPermitted, + unit_standing: rlm_units(timer: BeltTimerConverged { unit: "gunbc-belt.timer" }, oneshot: BeltOneshotConverged { unit: "gunbc-belt.service", active_state: "inactive" }), + tick_standing: rlm_tick_fact(t: tick), + footprint: FootprintAdmitted, + provider_preflight: ProviderPreflightOk, + dispatch_preflight: DispatchPreflightAdmittedFact { instance_id: "srv1-live", revision_hex: rlm_fixture_r }, + workflow_endpoint: WorkflowServedFact { observe_refused: false, tick: BeltTickReceiptPresent { receipt: tick } }, + instance_endpoint: InstanceServedFact { instance_id: "srv1-live", host_identity: "srv1", actuation: "actuating", repo_root: "/opt/gunbc/gunbc" }, + principal: PrincipalObserved { name: rlm_service_principal() }, + observed_host: HostObserved { name: "srv1" }, + observation_window: observation_window_fact(opened: rlm_snapshot(), closed: rlm_snapshot()), + run_provenance: rlm_provenance(), + } +} + +// One substituted member, one named blocker. The helper returns true only when the verdict is +// Incomplete AND its first blocker names the expected member. +fn incomplete_at(facts: RoadmapLaunchDeploymentFacts, member: String) -> Bool { + match roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: facts) { + DeploymentComplete { subject: _, facts: _, proof: _ } => false + DeploymentIncomplete { subject: _, facts: _, first_blocker: b, further_blockers: _ } => b.member == member + } +} + +fn with_plan(f: RoadmapLaunchDeploymentFacts, plan: PlanReceiptFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: plan, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_apply(f: RoadmapLaunchDeploymentFacts, apply: ApplyReceiptFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: apply, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_dashboard(f: RoadmapLaunchDeploymentFacts, d: DashboardReceiptFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: d, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_tree(f: RoadmapLaunchDeploymentFacts, t: DeployedRootTreeStanding) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: t, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_transition(f: RoadmapLaunchDeploymentFacts, t: ActuationStanding) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: t, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_units(f: RoadmapLaunchDeploymentFacts, u: LaunchUnitStanding) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: u, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_tick(f: RoadmapLaunchDeploymentFacts, t: TickFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: t, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_footprint(f: RoadmapLaunchDeploymentFacts, a: BeltFootprintAdmission) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: a, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_provider(f: RoadmapLaunchDeploymentFacts, p: BeltProviderPreflight) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: p, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_workflow(f: RoadmapLaunchDeploymentFacts, w: WorkflowEndpointFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: w, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_principal(f: RoadmapLaunchDeploymentFacts, p: PrincipalFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: p, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_host(f: RoadmapLaunchDeploymentFacts, h: HostFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: h, observation_window: f.observation_window, run_provenance: f.run_provenance } +} +fn with_release_binding(f: RoadmapLaunchDeploymentFacts, b: DeployedReleaseBinding) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: b, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} + +fn with_window(f: RoadmapLaunchDeploymentFacts, w: ObservationWindowFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: w, run_provenance: f.run_provenance } +} +fn with_run_provenance(f: RoadmapLaunchDeploymentFacts, rp: RunProvenanceFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: f.desired_revision, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: rp } +} + +fn with_desired(f: RoadmapLaunchDeploymentFacts, d: DesiredRevisionFact) -> RoadmapLaunchDeploymentFacts { + RoadmapLaunchDeploymentFacts { desired_revision: d, plan_receipt: f.plan_receipt, apply_receipt: f.apply_receipt, dashboard_receipt: f.dashboard_receipt, deployed_tree: f.deployed_tree, release_binding: f.release_binding, readiness: f.readiness, transition: f.transition, unit_standing: f.unit_standing, tick_standing: f.tick_standing, footprint: f.footprint, provider_preflight: f.provider_preflight, dispatch_preflight: f.dispatch_preflight, workflow_endpoint: f.workflow_endpoint, instance_endpoint: f.instance_endpoint, principal: f.principal, observed_host: f.observed_host, observation_window: f.observation_window, run_provenance: f.run_provenance } +} + +// ---------------------------------------------------------------- POSITIVE CONTROL + +test fn witness_complete_population_is_complete() -> Bool { + match roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: rlm_complete_facts()) { + DeploymentComplete { subject: _, facts: _, proof: p } => string_length(s: p.receipt_identity) == 16 + DeploymentIncomplete { subject: _, facts: _, first_blocker: _, further_blockers: _ } => false + } +} + +test fn witness_complete_receipt_document_names_its_verdict_and_identity() -> Bool { + let v = roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: rlm_complete_facts()) + let doc = roadmap_launch_deployment_receipt_document(v: v) + string_contains(s: doc, pattern: "\"verdict\": \"DeploymentComplete\"") + && string_contains(s: doc, pattern: verdict_receipt_identity(v: v)) + && string_contains(s: doc, pattern: "\"blockers\": []") +} + +// ---------------------------------------------------------------- RED CONTROLS (one member each) + +// plan, apply, dashboard or receipt run at a SHA other than R +test fn witness_plan_run_at_another_sha_is_incomplete_at_plan_receipt() -> Bool { + incomplete_at(facts: with_plan(f: rlm_complete_facts(), plan: PlanReceiptRead { receipt: rlm_plan(run: rlm_run(run_id: "1001", revision: rlm_fixture_other_r), host: "srv1") }), member: "plan_receipt") +} + +test fn witness_apply_run_at_another_sha_is_incomplete_at_apply_receipt() -> Bool { + let plan = rlm_plan(run: rlm_run(run_id: "1001", revision: rlm_fixture_r), host: "srv1") + incomplete_at(facts: with_apply(f: rlm_complete_facts(), apply: ApplyReceiptRead { receipt: rlm_apply(run: rlm_run(run_id: "1002", revision: rlm_fixture_other_r), plan_identity: fleet_converge_plan_receipt_identity_hex(r: plan)) }), member: "apply_receipt") +} + +test fn witness_dashboard_run_at_another_sha_is_incomplete_at_dashboard_receipt() -> Bool { + incomplete_at(facts: with_dashboard(f: rlm_complete_facts(), d: DashboardReceiptRead { receipt: rlm_dashboard(run: rlm_run(run_id: "1003", revision: rlm_fixture_other_r), expected: rlm_fixture_r) }), member: "dashboard_receipt") +} + +// plan run ID substitution +test fn witness_plan_run_id_substitution_is_incomplete_at_plan_receipt() -> Bool { + incomplete_at(facts: with_plan(f: rlm_complete_facts(), plan: PlanReceiptRead { receipt: rlm_plan(run: rlm_run(run_id: "1099", revision: rlm_fixture_r), host: "srv1") }), member: "plan_receipt") +} + +// apply receipt bound to another plan +test fn witness_apply_bound_to_another_plan_is_incomplete_at_apply_receipt() -> Bool { + incomplete_at(facts: with_apply(f: rlm_complete_facts(), apply: ApplyReceiptRead { receipt: rlm_apply(run: rlm_run(run_id: "1002", revision: rlm_fixture_r), plan_identity: "deadbeefdeadbeef") }), member: "apply_receipt") +} + +// dashboard receipt bound to another R +test fn witness_dashboard_bound_to_another_r_is_incomplete_at_dashboard_receipt() -> Bool { + incomplete_at(facts: with_dashboard(f: rlm_complete_facts(), d: DashboardReceiptRead { receipt: rlm_dashboard(run: rlm_run(run_id: "1003", revision: rlm_fixture_r), expected: rlm_fixture_other_r) }), member: "dashboard_receipt") +} + +// workflow input srv1 while the observed runner host is not srv1 +test fn witness_plan_observed_on_another_host_is_incomplete_at_plan_receipt() -> Bool { + incomplete_at(facts: with_plan(f: rlm_complete_facts(), plan: PlanReceiptRead { receipt: rlm_plan(run: rlm_run(run_id: "1001", revision: rlm_fixture_r), host: "srv2") }), member: "plan_receipt") +} + +test fn witness_receipt_run_on_another_host_is_incomplete_at_observed_host() -> Bool { + incomplete_at(facts: with_host(f: rlm_complete_facts(), h: HostObserved { name: "srv2" }), member: "observed_host") +} + +// candidate/deployed root-tree OID mismatch +test fn witness_deployed_tree_mismatch_is_incomplete_at_deployed_tree() -> Bool { + incomplete_at(facts: with_tree(f: rlm_complete_facts(), t: DeployedRootTreeMismatch { candidate_hex: rlm_fixture_tree, deployed_hex: rlm_fixture_other_tree }), member: "deployed_tree") +} + +// v3, stale, wrong-instance, wrong-revision, or non-ManualReady tick receipt +test fn witness_stale_tick_is_incomplete_at_tick_standing() -> Bool { + incomplete_at(facts: with_tick(f: rlm_complete_facts(), t: rlm_tick_fact(t: rlm_tick(instance_id: "srv1-live", revision: rlm_fixture_r, mode: ManualReady, observed_at: "2026-08-30T11:00:00Z"))), member: "tick_standing") +} + +test fn witness_wrong_instance_tick_is_incomplete_at_tick_standing() -> Bool { + incomplete_at(facts: with_tick(f: rlm_complete_facts(), t: rlm_tick_fact(t: rlm_tick(instance_id: "srv1-lab", revision: rlm_fixture_r, mode: ManualReady, observed_at: rlm_fixture_tick_observed_at))), member: "tick_standing") +} + +test fn witness_wrong_revision_tick_is_incomplete_at_tick_standing() -> Bool { + incomplete_at(facts: with_tick(f: rlm_complete_facts(), t: rlm_tick_fact(t: rlm_tick(instance_id: "srv1-live", revision: rlm_fixture_other_r, mode: ManualReady, observed_at: rlm_fixture_tick_observed_at))), member: "tick_standing") +} + +test fn witness_automatic_ready_tick_is_incomplete_at_tick_standing() -> Bool { + incomplete_at(facts: with_tick(f: rlm_complete_facts(), t: rlm_tick_fact(t: rlm_tick(instance_id: "srv1-live", revision: rlm_fixture_r, mode: AutomaticReady, observed_at: rlm_fixture_tick_observed_at))), member: "tick_standing") +} + +// The v4 decoder refuses a v3 document; the observer records that as an unreadable tick. +test fn witness_v3_tick_on_disk_is_incomplete_at_tick_standing() -> Bool { + let read = belt_tick_receipt_decode(raw: "{\"schema\":\"roadmap-belt-tick-receipt/v3\",\"observed_at\":\"2026-08-30T12:01:00Z\"}") + let fact = match read { + BeltTickReceiptUnreadable { reason: r } => TickUnobserved { detail: r } + BeltTickReceiptAbsent => TickUnobserved { detail: "absent" } + BeltTickReceiptPresent { receipt: t } => rlm_tick_fact(t: t) + } + (match read { BeltTickReceiptUnreadable { reason: _ } => true _ => false }) + && incomplete_at(facts: with_tick(f: rlm_complete_facts(), t: fact), member: "tick_standing") +} + +// disabled timer beside an otherwise fresh, manually fabricated tick +test fn witness_disabled_timer_beside_fresh_tick_is_incomplete_at_unit_standing() -> Bool { + incomplete_at( + facts: with_units(f: rlm_complete_facts(), u: rlm_units(timer: BeltTimerRefused { unit: "gunbc-belt.timer", property: "UnitFileState", expected: "enabled", observed: "disabled" }, oneshot: BeltOneshotConverged { unit: "gunbc-belt.service", active_state: "inactive" })), + member: "unit_standing", + ) +} + +// correctly inactive-successful oneshot as a POSITIVE control: the complete fixture carries +// exactly that (active_state "inactive") and is Complete; an ACTIVE oneshot mid-tick is equally +// accepted. +test fn witness_active_oneshot_mid_tick_is_still_complete() -> Bool { + match roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: with_units(f: rlm_complete_facts(), u: rlm_units(timer: BeltTimerConverged { unit: "gunbc-belt.timer" }, oneshot: BeltOneshotConverged { unit: "gunbc-belt.service", active_state: "active" }))) { + DeploymentComplete { subject: _, facts: _, proof: _ } => true + DeploymentIncomplete { subject: _, facts: _, first_blocker: _, further_blockers: _ } => false + } +} + +// wrong effective principal +test fn witness_wrong_principal_is_incomplete_at_principal() -> Bool { + incomplete_at(facts: with_principal(f: rlm_complete_facts(), p: PrincipalObserved { name: "ci-runner" }), member: "principal") +} + +// production state changing during the observation window +test fn witness_window_change_is_incomplete_at_observation_window() -> Bool { + let moved = WindowSnapshot { + main_hex: rlm_fixture_other_r, desired_hex: rlm_fixture_r, deployed_revision_hex: rlm_fixture_r, deployed_tree_hex: rlm_fixture_tree, + running_release_hex: rlm_fixture_r, transition: "permitted", active_instance: "srv1-live", + } + incomplete_at(facts: with_window(f: rlm_complete_facts(), w: observation_window_fact(opened: rlm_snapshot(), closed: moved)), member: "observation_window") +} + +// the four destructive falsifiers, run synthetically (never against srv1) +test fn witness_transition_marker_is_incomplete_at_transition() -> Bool { + let record = TransitionRecord { actor: "fixture", invocation: "fixture", repo_root: "/opt/gunbc/gunbc", base_ref: "main", candidate: rlm_fixture_r, phase: TransitionInForce } + incomplete_at(facts: with_transition(f: rlm_complete_facts(), t: ActuationInhibited { record: record }), member: "transition") +} + +test fn witness_unwritable_root_is_incomplete_at_footprint() -> Bool { + incomplete_at(facts: with_footprint(f: rlm_complete_facts(), a: FootprintRefused { step: "receipt-root", detail: "not writable by the service principal" }), member: "footprint") +} + +test fn witness_missing_provider_credential_is_incomplete_at_provider_preflight() -> Bool { + incomplete_at(facts: with_provider(f: rlm_complete_facts(), p: ProviderPreflightRefused { step: "credential", detail: "provider credential absent" }), member: "provider_preflight") +} + +test fn witness_unobservable_attempt_ledger_is_incomplete_at_workflow_endpoint() -> Bool { + let tick = rlm_tick(instance_id: "srv1-live", revision: rlm_fixture_r, mode: ManualReady, observed_at: rlm_fixture_tick_observed_at) + incomplete_at(facts: with_workflow(f: rlm_complete_facts(), w: WorkflowServedFact { observe_refused: true, tick: BeltTickReceiptPresent { receipt: tick } }), member: "workflow_endpoint") +} + +test fn witness_workflow_json_without_a_tick_is_incomplete_at_workflow_endpoint() -> Bool { + incomplete_at(facts: with_workflow(f: rlm_complete_facts(), w: WorkflowServedFact { observe_refused: false, tick: BeltTickReceiptAbsent }), member: "workflow_endpoint") +} + +// deploy R-1: release equality refuses (the main/desired read and the running release both name it) +test fn witness_desired_ref_behind_r_is_incomplete_at_desired_revision() -> Bool { + incomplete_at(facts: with_desired(f: rlm_complete_facts(), d: DesiredRevisionObserved { main_hex: rlm_fixture_r, desired_hex: rlm_fixture_other_r }), member: "desired_revision") +} + +// ---------------------------------------------------------------- TOTALITY + +// Two substituted members yield two blockers: the first is presented, the second is retained. +test fn witness_two_substitutions_retain_both_blockers() -> Bool { + let facts = with_host(f: with_principal(f: rlm_complete_facts(), p: PrincipalObserved { name: "ci-runner" }), h: HostObserved { name: "srv2" }) + match roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: facts) { + DeploymentComplete { subject: _, facts: _, proof: _ } => false + DeploymentIncomplete { subject: _, facts: _, first_blocker: b, further_blockers: rest } => + b.member == "principal" && list_length(items: rest) == 1 + && (match list_head(xs: rest) { HeadFound { value: r } => r.member == "observed_host" HeadAbsent => false }) + } +} + +// An Incomplete receipt is serialized with its blockers, so a refusal is evidence, not discarded output. +test fn witness_incomplete_receipt_document_carries_its_blockers() -> Bool { + let v = roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: with_principal(f: rlm_complete_facts(), p: PrincipalObserved { name: "ci-runner" })) + let doc = roadmap_launch_deployment_receipt_document(v: v) + string_contains(s: doc, pattern: "\"verdict\": \"DeploymentIncomplete\"") + && string_contains(s: doc, pattern: "\"member\": \"principal\"") + && string_contains(s: doc, pattern: "ci-runner") +} + +// The receipt identity is a function of subject + facts: the same population hashes the same, a +// different population hashes differently. +test fn witness_receipt_identity_is_stable_and_discriminating() -> Bool { + let a = roadmap_launch_deployment_receipt_identity_hex(subject: rlm_subject(), facts: rlm_complete_facts()) + let b = roadmap_launch_deployment_receipt_identity_hex(subject: rlm_subject(), facts: rlm_complete_facts()) + let c = roadmap_launch_deployment_receipt_identity_hex(subject: rlm_subject(), facts: with_host(f: rlm_complete_facts(), h: HostObserved { name: "srv2" })) + a == b && a != c +} + +// ---------------------------------------------------------------- review 5061891290 RED CONTROLS + +// P0-2: an exit-0 PartiallyApplied fleet apply cannot certify completion. +test fn witness_partially_applied_exit_zero_is_incomplete_at_apply_receipt() -> Bool { + let base = rlm_fixture_apply() + let a = FleetConvergeApplyReceipt { + run: base.run, plan_run_id: base.plan_run_id, plan_receipt_identity: base.plan_receipt_identity, + plan_artifact_hash: base.plan_artifact_hash, observed_host: base.observed_host, + prior_generation: base.prior_generation, planned_generation: base.planned_generation, + expected_revision_admitted: base.expected_revision_admitted, + terminal: PartiallyApplied { refused_axis_count: 1, detail: "slot=0 cap=0 timer=1 fabric-cell=0 activation=0" }, + locked_apply_exit_code: 0, + } + incomplete_at(facts: with_apply(f: rlm_complete_facts(), apply: ApplyReceiptRead { receipt: a }), member: "apply_receipt") +} + +// P0-1: an apply whose admitted expected revision is not R cannot certify completion, even when +// the plan and apply agree with each other. +test fn witness_apply_admitted_against_q_is_incomplete_at_apply_receipt() -> Bool { + let base = rlm_fixture_apply() + let a = FleetConvergeApplyReceipt { + run: base.run, plan_run_id: base.plan_run_id, plan_receipt_identity: base.plan_receipt_identity, + plan_artifact_hash: base.plan_artifact_hash, observed_host: base.observed_host, + prior_generation: base.prior_generation, planned_generation: base.planned_generation, + expected_revision_admitted: rlm_fixture_other_r, + terminal: FullyApplied, + locked_apply_exit_code: 0, + } + incomplete_at(facts: with_apply(f: rlm_complete_facts(), apply: ApplyReceiptRead { receipt: a }), member: "apply_receipt") +} + +// P1-4: each leg of the four-run provenance chain blocks on its own defect. +fn rlm_provenance_with_plan(p: PredecessorRunProvenance) -> RunProvenanceFact { + RunProvenanceObserved { receipt_repository: "gunb-ai/gunbc", plan: p, apply: rlm_predecessor(run_id: "1002"), dashboard: rlm_predecessor(run_id: "1003"), receipt_workflow_ref: ReceiptWorkflowRefNamesFile } +} + +test fn witness_predecessor_non_success_conclusion_is_incomplete_at_run_provenance() -> Bool { + incomplete_at(facts: with_run_provenance(f: rlm_complete_facts(), rp: rlm_provenance_with_plan(p: PredecessorRunProvenance { run_id: "1001", conclusion_wire: "failure", head_sha: rlm_fixture_r, workflow_path: rlm_workflow_file_path() })), member: "run_provenance") +} + +test fn witness_predecessor_at_another_sha_is_incomplete_at_run_provenance() -> Bool { + incomplete_at(facts: with_run_provenance(f: rlm_complete_facts(), rp: rlm_provenance_with_plan(p: PredecessorRunProvenance { run_id: "1001", conclusion_wire: "success", head_sha: rlm_fixture_other_r, workflow_path: rlm_workflow_file_path() })), member: "run_provenance") +} + +test fn witness_predecessor_on_another_workflow_is_incomplete_at_run_provenance() -> Bool { + incomplete_at(facts: with_run_provenance(f: rlm_complete_facts(), rp: rlm_provenance_with_plan(p: PredecessorRunProvenance { run_id: "1001", conclusion_wire: "success", head_sha: rlm_fixture_r, workflow_path: ".github/workflows/witnesses.yml" })), member: "run_provenance") +} + +test fn witness_predecessor_run_id_substitution_is_incomplete_at_run_provenance() -> Bool { + incomplete_at(facts: with_run_provenance(f: rlm_complete_facts(), rp: rlm_provenance_with_plan(p: rlm_predecessor(run_id: "1099"))), member: "run_provenance") +} + +test fn witness_receipt_workflow_ref_not_named_is_incomplete_at_run_provenance() -> Bool { + incomplete_at(facts: with_run_provenance(f: rlm_complete_facts(), rp: RunProvenanceObserved { receipt_repository: "gunb-ai/gunbc", plan: rlm_predecessor(run_id: "1001"), apply: rlm_predecessor(run_id: "1002"), dashboard: rlm_predecessor(run_id: "1003"), receipt_workflow_ref: ReceiptWorkflowRefForeign { workflow_ref: "fork-owner/gunbc/.github/workflows/other.yml@refs/heads/main" } }), member: "run_provenance") +} + +test fn witness_provenance_unobserved_is_incomplete_at_run_provenance() -> Bool { + incomplete_at(facts: with_run_provenance(f: rlm_complete_facts(), rp: RunProvenanceUnobserved { detail: "workflow-runs API unreachable" }), member: "run_provenance") +} + +// P1-4: the dashboard receipt must cite the exact predecessor receipts this join read. +test fn witness_dashboard_citing_another_plan_receipt_is_incomplete_at_dashboard_receipt() -> Bool { + let base = rlm_dashboard(run: rlm_run(run_id: "1003", revision: rlm_fixture_r), expected: rlm_fixture_r) + let d = LiveDeployApplyReceipt { + run: base.run, target: base.target, expected_revision: base.expected_revision, candidate_revision: base.candidate_revision, + candidate_surface_identity: base.candidate_surface_identity, candidate_tree_oid: base.candidate_tree_oid, + target_decision: base.target_decision, apply_outcome: base.apply_outcome, readiness: base.readiness, + provenance: LiveDeployFleetProvenance { + plan_run_id: "1001", apply_run_id: "1002", plan_artifact_hash: rlm_fixture_plan_hash, + plan_receipt_identity: "9999999999999999", apply_receipt_identity: rlm_apply_identity(), + }, + completed_at: base.completed_at, + } + incomplete_at(facts: with_dashboard(f: rlm_complete_facts(), d: DashboardReceiptRead { receipt: d }), member: "dashboard_receipt") +} + +// P1-7: the durable document's facts member is a STRUCTURED object -- every frozen-join member is +// recoverable by JSON navigation, never by parsing prose. +fn rlm_doc_member(doc: JsonValue, key: String) -> JsonValue? { + match json_object_unique_member(v: doc, key: key) { + JsonMemberFound { value: v } => Present { value: v } + JsonMemberAbsent => Absent + JsonMemberDuplicated { count: _ } => Absent + JsonMemberNotAnObject => Absent + } +} + +fn rlm_doc_string(doc: JsonValue, key: String) -> String { + match json_required_string_member(doc: doc, key: key) { + JsonStringMemberFound { value: v } => v + JsonStringMemberRefused { reason: r } => join([""], "") + } +} + +test fn witness_receipt_document_facts_are_machine_readable() -> Bool { + let v = roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: rlm_complete_facts()) + let doc = roadmap_launch_deployment_receipt_document(v: v) + match parse_json_document(s: doc) { + JsonDocumentUnreadable { gap: _ } => false + JsonDocumentParsed { value: root } => + match rlm_doc_member(doc: root, key: "facts") { + Absent => false + Present { value: facts } => + (match rlm_doc_member(doc: facts, key: "tick_standing") { + Absent => false + Present { value: tick } => + rlm_doc_string(doc: tick, key: "arm") == "observed" + && (match rlm_doc_member(doc: tick, key: "execution") { + Absent => false + Present { value: ex } => rlm_doc_string(doc: ex, key: "deployed_revision") == rlm_fixture_r && rlm_doc_string(doc: ex, key: "spawn_mode") == "manual_ready" + }) + }) + && (match rlm_doc_member(doc: facts, key: "plan_receipt") { + Absent => false + Present { value: pr } => + rlm_doc_string(doc: pr, key: "arm") == "read" + && (match rlm_doc_member(doc: pr, key: "receipt") { + Absent => false + Present { value: prr } => rlm_doc_string(doc: prr, key: "receipt_identity") == rlm_plan_identity() + }) + }) + && (match rlm_doc_member(doc: facts, key: "run_provenance") { + Absent => false + Present { value: rp } => + rlm_doc_string(doc: rp, key: "arm") == "observed" + && (match rlm_doc_member(doc: rp, key: "plan") { + Absent => false + Present { value: rpp } => rlm_doc_string(doc: rpp, key: "conclusion") == "success" + }) + }) + && (match rlm_doc_member(doc: facts, key: "observation_window") { + Absent => false + Present { value: w } => rlm_doc_string(doc: w, key: "arm") == "stable" + }) + } + } +} + +// Review 5062738052 B2: the receipt run itself must execute in the canonical repository -- a fork +// spelling fork-owner/gunbc satisfies the workflow-ref prefix check, so this is its own blocker. +test fn witness_receipt_run_in_another_repository_is_incomplete_at_run_provenance() -> Bool { + incomplete_at(facts: with_run_provenance(f: rlm_complete_facts(), rp: RunProvenanceObserved { receipt_repository: "fork-owner/gunbc", plan: rlm_predecessor(run_id: "1001"), apply: rlm_predecessor(run_id: "1002"), dashboard: rlm_predecessor(run_id: "1003"), receipt_workflow_ref: ReceiptWorkflowRefNamesFile }), member: "run_provenance") +} + +// Review 5062738052 B1: the routed endpoint serving R under the WRONG surface refuses at the +// release binding -- the mixed-locus counterexample (local green, routed wrong) is unwritable now +// that the binding consumes the routed identity, and this is its discriminating fixture. +test fn witness_routed_surface_mixed_is_incomplete_at_release_binding() -> Bool { + let b = match git_object_id_from_untagged_hex(hex: rlm_fixture_r) { + Absent => ReleaseBindingUnobserved { cause: "fixture revision undecodable" } + Present { value: oid } => ReleaseSurfaceMixed { + revision: oid, + expected_surface: Fnv1a64Structural { digest: rlm_fixture_surface as Fnv1a64StructuralDigestHex }, + process_surface: Fnv1a64Structural { digest: "ffffffffffffffff" as Fnv1a64StructuralDigestHex }, + } + } + incomplete_at(facts: with_release_binding(f: rlm_complete_facts(), b: b), member: "release_binding") +} + +// Review 5062738052 B1: the routed running-release member of the observation window changes +// between open and close -- a route flip during capture is a window change, not a stable window. +test fn witness_route_change_during_window_is_incomplete_at_observation_window() -> Bool { + let closed = WindowSnapshot { + main_hex: rlm_fixture_r, desired_hex: rlm_fixture_r, deployed_revision_hex: rlm_fixture_r, + deployed_tree_hex: rlm_fixture_tree, running_release_hex: "0000000000000000000000000000000000000001", + transition: "permitted", active_instance: "srv1-live", + } + incomplete_at(facts: with_window(f: rlm_complete_facts(), w: WindowChanged { opened: rlm_snapshot(), closed: closed, changed: ["running_release_hex"] }), member: "observation_window") +} + +// Review 5062738052 B3: tick freshness is a typed-arm object in the durable document -- the +// observed age and the cadence-derived bound are recovered by JSON navigation, never from prose. +// Two witnesses (fresh doc, stale doc) so each stays inside the floor's 500ms CPU line: one +// document build + parse each; the single two-document form was BUDGET-REFUSED on run 33358233304. +fn rlm_doc_freshness(doc: String) -> JsonValue? { + match parse_json_document(s: doc) { + JsonDocumentUnreadable { gap: _ } => none + JsonDocumentParsed { value: root } => + match rlm_doc_member(doc: root, key: "facts") { + Absent => none + Present { value: facts } => + match rlm_doc_member(doc: facts, key: "tick_standing") { + Absent => none + Present { value: tick } => rlm_doc_member(doc: tick, key: "freshness") + } + } + } +} + +test fn witness_receipt_document_fresh_age_is_machine_readable() -> Bool { + let doc = roadmap_launch_deployment_receipt_document(v: roadmap_launch_deployment_verdict(subject: rlm_subject(), facts: rlm_complete_facts())) + match rlm_doc_freshness(doc: doc) { + Absent => false + Present { value: fr } => rlm_doc_string(doc: fr, key: "arm") == "fresh" && rlm_doc_string(doc: fr, key: "age_seconds") == "30" + } +} + +test fn witness_receipt_document_stale_age_and_bound_are_machine_readable() -> Bool { + let stale_tick = rlm_tick(instance_id: "srv1-live", revision: rlm_fixture_r, mode: ManualReady, observed_at: rlm_fixture_tick_observed_at) + let doc = roadmap_launch_deployment_receipt_document(v: roadmap_launch_deployment_verdict( + subject: rlm_subject(), + facts: with_tick(f: rlm_complete_facts(), t: TickObserved { receipt: stale_tick, freshness: TickStale { age_seconds: second(count: 540), bound_seconds: second(count: 180) } }), + )) + match rlm_doc_freshness(doc: doc) { + Absent => false + Present { value: fr } => + rlm_doc_string(doc: fr, key: "arm") == "stale" + && rlm_doc_string(doc: fr, key: "age_seconds") == "540" + && rlm_doc_string(doc: fr, key: "bound_seconds") == "180" + } +}