From e9d480c6720874a5f335747c81dcedee7d8af066 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 15:17:50 +0000 Subject: [PATCH 01/17] Converge R2 entitlement + bucket existence for every allocated origin purpose gunbc.cloudflare.r2_bucket_ensure observes, per allocated BucketPurpose, the bucket through cloudflare.R2Buckets.Get (extdeps.cloudflare.r2), classifies with std.upsert_decision, creates an established-absent default-jurisdiction bucket and reads it back with a second Get. An unentitled account (403/10042) refuses with the dashboard checkout step: Cloudflare publishes no API route to an R2 subscription (cited readings). Wired as fleet-converge mode r2_bucket_ensure; witness test.claim.cloudflare_r2_bucket_ensure. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/fleet-converge.yml | 22 +- dag/extdeps/cloudflare/client_v4.dag | 70 +++- dag/extdeps/cloudflare/r2.dag | 233 +++++++++++- dag/gunbc/ci/ci_spec.dag | 23 ++ dag/gunbc/cloudflare/r2_bucket_ensure.dag | 336 ++++++++++++++++++ dag/gunbc/cloudflare/r2_origin.dag | 11 + dag/gunbc/fleet/fleet_converge_workflow.dag | 51 ++- ...oudflare_r2_bucket_ensure_witness_test.dag | 189 ++++++++++ 8 files changed, 931 insertions(+), 4 deletions(-) create mode 100644 dag/gunbc/cloudflare/r2_bucket_ensure.dag create mode 100644 dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index bb830799529..b2ecabd2405 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,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; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and refuses the kvm grant by name; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' vLLM units over the password session, workers before heads; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_bucket_ensure, r2_object_write_mint] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -414,6 +414,26 @@ jobs: retention-days: 30 if: github.event.inputs.mode == 'r2_mint_preflight' timeout-minutes: 10 + - name: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" + id: r2_bucket_ensure + 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/cloudflare/r2_bucket_ensure.dag --function ensure + cat "$ROOT/target/r2-bucket-ensure-receipt.txt" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 5 + - name: Upload R2 bucket ensure receipt + id: r2_bucket_ensure_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-ensure + path: target/r2-bucket-ensure-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 10 - name: R2 object-write token mint (AccountTokens.Create + Secret Manager custody) id: r2_object_write_mint run: | diff --git a/dag/extdeps/cloudflare/client_v4.dag b/dag/extdeps/cloudflare/client_v4.dag index c7e88524d6f..36f4957d076 100644 --- a/dag/extdeps/cloudflare/client_v4.dag +++ b/dag/extdeps/cloudflare/client_v4.dag @@ -1,6 +1,11 @@ module extdeps.cloudflare.client_v4 -import std.types { NonEmptyStr, String, Int } +import std.types { NonEmptyStr, String, Int, List } +import extdeps.languages.json.emit { JsonValue, JsonNull, JsonBool, JsonNumber, JsonString, JsonArray, JsonObject } +import extdeps.languages.json.parse { + parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable, json_document_gap_text, + json_object_unique_member, JsonMemberFound, JsonMemberAbsent, JsonMemberDuplicated, JsonMemberNotAnObject, +} import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } import extdeps.uri { Uri, Https } @@ -37,3 +42,66 @@ type CloudflareApiError { code: Int message: String } + +// THE ERROR ENVELOPE'S CODES, READ RATHER THAN MATCHED AS SUBSTRINGS. Every client/v4 response +// carries `errors: [{ code, message }]` (the envelope this module's anchor documents), and a refused +// status's body is the only place the service says WHICH refusal it made -- a 403 is an +// authorization refusal and an entitlement refusal alike. A substring search for a code would +// accept the digits inside a message or an id; reading the array is the discriminator. +// +// THE CODES STAY LEXEMES. The envelope's code is a JSON number whose meaning is its identity, not +// its magnitude, and every consumer compares it to a cited code; parsing it to an Int would add a +// conversion no consumer demands. +type CloudflareErrorCodesRead + = CloudflareErrorCodesListed { codes: List } + | CloudflareErrorEnvelopeUnreadable { cause: NonEmptyStr } + +fn cloudflare_error_code_lexemes(entry: JsonValue) -> List { + match json_object_unique_member(v: entry, key: "code") { + JsonMemberFound { value: v } => + match v { + JsonNumber { lexeme: lexeme } => [lexeme as String] + JsonNull => [] + JsonBool { value: _ } => [] + JsonString { value: _ } => [] + JsonArray { elements: _ } => [] + JsonObject { members: _ } => [] + } + JsonMemberAbsent => [] + JsonMemberNotAnObject => [] + JsonMemberDuplicated { count: _ } => [] + } +} + +fn cloudflare_error_codes_from_entries(entries: List) -> CloudflareErrorCodesRead { + let codes = entries |> flat_map(entry => cloudflare_error_code_lexemes(entry: entry)) + if codes.length() == entries.length() { + CloudflareErrorCodesListed { codes: codes } + } else { + CloudflareErrorEnvelopeUnreadable { cause: "an errors entry carries no numeric code" as NonEmptyStr } + } +} + +fn cloudflare_error_codes(body: String) -> CloudflareErrorCodesRead { + match parse_json_document(s: body) { + JsonDocumentUnreadable { gap: gap } => + CloudflareErrorEnvelopeUnreadable { + cause: concat("the refused body is not JSON: ", json_document_gap_text(gap: gap)) as NonEmptyStr + } + JsonDocumentParsed { value: doc } => + match json_object_unique_member(v: doc, key: "errors") { + JsonMemberFound { value: errors } => + match errors { + JsonArray { elements: entries } => cloudflare_error_codes_from_entries(entries: entries) + JsonNull => CloudflareErrorEnvelopeUnreadable { cause: "errors is null" as NonEmptyStr } + JsonBool { value: _ } => CloudflareErrorEnvelopeUnreadable { cause: "errors is not an array" as NonEmptyStr } + JsonNumber { lexeme: _ } => CloudflareErrorEnvelopeUnreadable { cause: "errors is not an array" as NonEmptyStr } + JsonString { value: _ } => CloudflareErrorEnvelopeUnreadable { cause: "errors is not an array" as NonEmptyStr } + JsonObject { members: _ } => CloudflareErrorEnvelopeUnreadable { cause: "errors is not an array" as NonEmptyStr } + } + JsonMemberAbsent => CloudflareErrorEnvelopeUnreadable { cause: "the refused body carries no errors member" as NonEmptyStr } + JsonMemberNotAnObject => CloudflareErrorEnvelopeUnreadable { cause: "the refused body is not a JSON object" as NonEmptyStr } + JsonMemberDuplicated { count: _ } => CloudflareErrorEnvelopeUnreadable { cause: "the refused body names errors twice" as NonEmptyStr } + } + } +} diff --git a/dag/extdeps/cloudflare/r2.dag b/dag/extdeps/cloudflare/r2.dag index b3abd7c955e..d2749746131 100644 --- a/dag/extdeps/cloudflare/r2.dag +++ b/dag/extdeps/cloudflare/r2.dag @@ -1,6 +1,14 @@ module extdeps.cloudflare.r2 -import std.types { NonEmptyStr, String, List, Timestamp, Map } +import std.types { NonEmptyStr, String, List, Timestamp, Map, Secret, HttpStatus } +import extdeps.transports.rest { RestOutcome, RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } +import extdeps.cloudflare.client_v4 { + cloudflare_client_v4_base, + CloudflareApiError, + cloudflare_error_codes, + CloudflareErrorCodesListed, + CloudflareErrorEnvelopeUnreadable, +} import std.decl_ref { DeclarationRef, WholeDeclaration, NamedField, decl_ref } import std.roster_frontier { FrontierRow, frontier_row_decl } import std.dissolution { unbound_dissolution } @@ -148,6 +156,9 @@ data extdeps_model_scope: ExternalModelScope = ExternalModelScope { r2_us_jurisdiction_changelog_authority, r2_create_bucket_authority, r2_get_bucket_authority, + r2_get_started_authority, + r2_error_codes_authority, + cloudflare_account_subscription_create_authority, ] } // THE FIVE ARMS ARE THE CREATE/GET BUCKET JURISDICTION VALUE SET (r2_create_bucket_location_hint_reading, @@ -499,6 +510,226 @@ data r2_custom_domain_citation: FactCitation = FactCitation { } +// ── THE ACCOUNT'S R2 PREREQUISITES: ENTITLEMENT AND BUCKET EXISTENCE ──────────────────────────── +// Two facts a fleet needs before any signed S3 request can mean anything, and which were assumed +// rather than observed for nine days (gunbc#11713): the account holds an R2 subscription, and the +// allocated bucket exists. Upstream owns what the API returns about both; which buckets this fleet +// wants, and what to do about an absence, is the workflow's (gunbc.cloudflare.r2_bucket_ensure). + +data r2_get_started_authority: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "developers.cloudflare.com/r2/get-started/" + } +} + +data r2_error_codes_authority: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "developers.cloudflare.com/r2/api/error-codes/" + } +} + +data cloudflare_account_subscription_create_authority: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "developers.cloudflare.com/api/resources/accounts/subresources/subscriptions/methods/create/" + } +} + +// THE SUBSCRIPTION IS A DASHBOARD CHECKOUT, AND THE API OFFERS NO R2 PLAN. The get-started page names +// the dashboard flow as the way to add R2; the one API that creates a subscription takes a rate_plan +// id whose published value set is the zone plan family and names no R2 member. So activation is not +// an effect this repository can perform: a converge meeting an unentitled account REFUSES and names +// the dashboard step. Both readings are transcribed verbatim (curl of the index.md projection). +data r2_get_started_subscription_reading: NonEmptyStr = "Before you begin. You need a Cloudflare account with an R2 subscription. If you do not have one: 1. Go to the Cloudflare Dashboard. 2. Select Storage & databases > R2 > Overview 3. Complete the checkout flow to add an R2 subscription to your account. R2 is free to get started with included free monthly usage. You are billed for your usage on a monthly basis. Last updated Apr 21, 2026. Read 2026-09-19." + +data r2_get_started_subscription_citation: FactCitation = FactCitation { + fact: DeclarationRef { + module_path: "extdeps.cloudflare.r2", + decl_name: "r2_get_started_subscription_reading", + field: WholeDeclaration + }, + authority: r2_get_started_authority, +} + +data cloudflare_account_subscription_rate_plan_reading: NonEmptyStr = "Create Subscription POST/\{accounts_or_zones\}/\{account_or_zone_id\}/subscriptions. rate_plan: optional RatePlan { id, currency, externally_managed, 4 more }. id: optional \"free\"or \"lite\"or \"pro\"or 7 more: \"free\" \"lite\" \"pro\" \"pro_plus\" \"business\" \"enterprise\" \"partners_free\" \"partners_pro\" \"partners_business\" \"partners_enterprise\". Read 2026-09-19." + +data cloudflare_account_subscription_rate_plan_citation: FactCitation = FactCitation { + fact: DeclarationRef { + module_path: "extdeps.cloudflare.r2", + decl_name: "cloudflare_account_subscription_rate_plan_reading", + field: WholeDeclaration + }, + authority: cloudflare_account_subscription_create_authority, +} + +// THE TWO CODES THAT DISCRIMINATE, AND THE HONEST REACH OF THEIR CITATION. The error-code page is +// written for the Workers and S3-compatible APIs; that the client/v4 bucket endpoints answer an +// unentitled account and a missing bucket with the same codes in their envelope is an INFERENCE from +// one product's one code table, not a sentence on the REST reference. It is consumed as a bet: only +// an exact code match is classified, and any other refusal -- including a 403 or a 404 carrying a +// code this table does not name -- stays an unclassified refusal rather than being read as either +// fact. A wrong bet therefore refuses loudly; it can never mint "absent" or "unentitled". +data r2_error_codes_reading: NonEmptyStr = "| 10042 | NotEntitled | 403 | Account not entitled to this feature. | Ensure your account has an R2 subscription. | | 10006 | NoSuchBucket | 404 | The specified bucket does not exist. | Verify the bucket name is correct and the bucket exists in your account. | Last updated Jul 31, 2026. Read 2026-09-19." + +data r2_error_codes_citation: FactCitation = FactCitation { + fact: DeclarationRef { + module_path: "extdeps.cloudflare.r2", + decl_name: "r2_error_codes_reading", + field: WholeDeclaration + }, + authority: r2_error_codes_authority, +} + +data r2_not_entitled_error_code: NonEmptyStr = "10042" + +data r2_no_such_bucket_error_code: NonEmptyStr = "10006" + +// THE BUCKET AS GET AND CREATE RETURN IT. `name` is the identity the readback compares; jurisdiction +// is the wire token r2_jurisdiction_wire spells, optional on the wire (r2_get_bucket_location_reading) +// and parsed rather than trusted. +type R2BucketResponse { + name: String + jurisdiction: String? + location: String? +} + +fn parse_r2_jurisdiction(raw: String) -> R2Jurisdiction? { + if raw == "default" { + Present { value: R2JurisdictionDefault } + } else if raw == "eu" { + Present { value: R2JurisdictionEu } + } else if raw == "fedramp" { + Present { value: R2JurisdictionFedramp } + } else if raw == "fedramp-high" { + Present { value: R2JurisdictionFedrampHigh } + } else if raw == "us" { + Present { value: R2JurisdictionUs } + } else { + none + } +} + +// THE JURISDICTION HEADER IS NOT CARRIED, AND THAT IS WHY BOTH OPERATIONS ARE DEFAULT-ONLY. Create +// and Get take jurisdiction as the cf-r2-jurisdiction HEADER (r2_create_bucket_location_hint_reading), +// and this service surface has no header slot; a request without it addresses the default +// jurisdiction. A caller that wants another jurisdiction must refuse before calling rather than +// create a default bucket under a non-default name -- the converge does exactly that. +// +// CREATE IS NOT IDEMPOTENT ON THE WIRE: a second Create of an existing name is refused (10073 +// BucketConflict on the error-code page), so the converge observes first, creates only on an +// established absence, and reads back through Get rather than trusting the Create response. +service cloudflare.R2Buckets { + config { + endpoint: cloudflare_client_v4_base + auth: Bearer + auth_input: auth_token + } + + operation Get { + input { + auth_token: Secret + account_id: String + bucket_name: String + } + output { + name: String from "result/name" + jurisdiction: String? from "result/jurisdiction" + location: String? from "result/location" + outcome: RestOutcome + } + readonly + transport rest { + method: GET, + path: "/accounts/\{account_id\}/r2/buckets/\{bucket_name\}" + } + response { + 200 => R2BucketResponse + 401 => CloudflareApiError + 403 => CloudflareApiError + 404 => CloudflareApiError + 5xx => CloudflareApiError + } + } + + operation Create { + input { + auth_token: Secret + account_id: String + name: String + } + output { + name: String from "result/name" + jurisdiction: String? from "result/jurisdiction" + location: String? from "result/location" + outcome: RestOutcome + } + transport rest { + method: POST, + path: "/accounts/\{account_id\}/r2/buckets", + body: { + name: name + } + } + response { + 200 => R2BucketResponse + 400 => CloudflareApiError + 401 => CloudflareApiError + 403 => CloudflareApiError + 409 => CloudflareApiError + 5xx => CloudflareApiError + } + } +} + +// ONE BUCKET READ, CLASSIFIED. The three facts the Get can establish -- the bucket, its absence, the +// account's lack of entitlement -- are arms; everything else is a refusal carrying the status and the +// codes it did carry. An entitlement is observed as a BYPRODUCT of any bucket answer: a 200 or an +// exact NoSuchBucket means the account answered about R2 resources, which an unentitled one does not. +type R2BucketRead + = R2BucketPresent { bucket: R2BucketResponse } + | R2BucketAbsent + | R2AccountNotEntitled + | R2BucketReadRefused { cause: NonEmptyStr } + +fn r2_codes_text(codes: List) -> String { + if codes.length() == 0 { "no codes" } else { join(codes, ",") } +} + +fn classify_r2_bucket_refusal(status: HttpStatus, body: String) -> R2BucketRead { + match cloudflare_error_codes(body: body) { + CloudflareErrorEnvelopeUnreadable { cause: cause } => + R2BucketReadRefused { + cause: join(["status ", to_string(status), " with an unreadable error envelope: ", cause as String], "") as NonEmptyStr + } + CloudflareErrorCodesListed { codes: codes } => + if status == 403 && contains(codes, r2_not_entitled_error_code as String) { + R2AccountNotEntitled + } else if status == 404 && contains(codes, r2_no_such_bucket_error_code as String) { + R2BucketAbsent + } else { + R2BucketReadRefused { + cause: join(["status ", to_string(status), " carrying ", r2_codes_text(codes: codes), ", which names neither NotEntitled nor NoSuchBucket"], "") as NonEmptyStr + } + } + } +} + +fn classify_r2_bucket_get(outcome: RestOutcome, bucket: R2BucketResponse) -> R2BucketRead { + match outcome { + RestOk => R2BucketPresent { bucket: bucket } + RestStatusRefused { status: status, body: body } => classify_r2_bucket_refusal(status: status, body: body) + RestTransportRefused { cause: cause } => + R2BucketReadRefused { cause: concat("no response: ", cause as String) as NonEmptyStr } + RestBodyUndecodable { status: status, cause: cause } => + R2BucketReadRefused { + cause: join(["status ", to_string(status), " body did not decode: ", cause as String], "") as NonEmptyStr + } + } +} + + // THE r2_s3_endpoint_url ROW IS RETIRED: gunbc.cloudflare.r2_origin_object reaches it through // r2_object_url on the executing put_origin_object and fetch_origin_object entries, which is the // consumer the row named. The list is the membership of what remains. r2_tokens_doc_as_of stays: diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 525b0ebbecb..02d37fbbd44 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -1,6 +1,7 @@ module gunbc.ci_spec import gunbc.fleet.app_control_plane_converge { app_control_plane_receipt_path } import gunbc.cloudflare.r2_permission_group_observe { r2_mint_preflight_receipt_path } +import gunbc.cloudflare.r2_bucket_ensure { r2_bucket_ensure_receipt_path } import gunbc.spark.managed_access_apply { spark_apply_receipt_path } import gunbc.spark.pair_serving_apply { spark_pair_apply_receipt_path } import gunbc.ledger_row_coherence { heal_repair_declaration_artifact_path } @@ -774,6 +775,17 @@ data gunbc_ci_r2_mint_preflight_target: GunbcRunStepTarget = GunbcRunStepTarget function: "observe_account_permission_groups_wet", } +// THE BUCKET ENSURE PRECEDES THE MINT IN MEANING, NOT ONLY IN ORDER. A bucket-scoped token names a +// bucket resource that must exist for any request it signs to succeed, and the account's R2 +// entitlement is a precondition of both; gunbc.cloudflare.r2_bucket_ensure observes the two facts +// for every allocated purpose, creates an absent bucket, and refuses an unentitled account with the +// dashboard step (gunbc#11713: nine days of failed signed requests against a bucket nobody had +// observed). +data gunbc_ci_r2_bucket_ensure_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/cloudflare/r2_bucket_ensure.dag", + function: "ensure", +} + data gunbc_ci_r2_object_write_mint_target: GunbcRunStepTarget = GunbcRunStepTarget { entry: "dag/gunbc/cloudflare/r2_token_mint_run.dag", function: "run_object_write", @@ -997,6 +1009,7 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_fleet_converge_apply_target, gunbc_ci_org_actions_converge_target, gunbc_ci_r2_mint_preflight_target, + gunbc_ci_r2_bucket_ensure_target, gunbc_ci_r2_object_write_mint_target, gunbc_ci_runner_guest_image_observe_target, gunbc_ci_runner_guest_image_converge_target, @@ -1771,6 +1784,16 @@ fn gunbc_ci_r2_mint_preflight_invoke() -> String { ) } +fn gunbc_ci_r2_bucket_ensure_invoke() -> String { + gunbc_run_step_script( + source_roots: witness_layer_roots, + entry: gunbc_ci_r2_bucket_ensure_target.entry, + function: gunbc_ci_r2_bucket_ensure_target.function, + claim_run: false, + receipt_rel: r2_bucket_ensure_receipt_path + ) +} + fn gunbc_ci_r2_object_write_mint_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, diff --git a/dag/gunbc/cloudflare/r2_bucket_ensure.dag b/dag/gunbc/cloudflare/r2_bucket_ensure.dag new file mode 100644 index 00000000000..58c5454129c --- /dev/null +++ b/dag/gunbc/cloudflare/r2_bucket_ensure.dag @@ -0,0 +1,336 @@ +module gunbc.cloudflare.r2_bucket_ensure + +import std.types { String, NonEmptyStr, Secret, List, Bool } +import std.resources { Network } +import extdeps.filesystem.filesystem_io { Filesystem } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.upsert_decision { + ObservationVerdict, Converged, Absent, Drifted, Conflict, Inaccessible, UnknownRefused, + UpsertDecision, Noop, Apply, Refuse, + UpsertClassification, + upsert_decision_label, +} +import extdeps.cloudflare.r2 { + R2Jurisdiction, R2JurisdictionDefault, + r2_jurisdiction_wire, + parse_r2_jurisdiction, + R2BucketResponse, + R2BucketRead, R2BucketPresent, R2BucketAbsent, R2AccountNotEntitled, R2BucketReadRefused, + classify_r2_bucket_get, +} +import gunbc.cloudflare.r2_origin { + CloudflareAccountId, + BucketPurpose, + R2BucketAllocation, + bucket_purpose_key, + bucket_purpose_origin_standing, + gunbc_fleet_r2_bucket_allocations, + R2OriginStanding, R2OriginAllocated, R2OriginUnallocated, + FleetCloudflareAccountStanding, CloudflareAccountAllocated, CloudflareAccountUnallocated, + BootstrapCustodyStanding, BootstrapCustodyPinned, BootstrapCustodyUnrecorded, + fleet_cloudflare_account_standing, + fleet_cloudflare_bootstrap_custody, +} +import gunbc.auth.access_token_source { + resolve_access_token, + AccessTokenReady, AccessTokenUpsertRequired, AccessTokenEnsureRefused, + access_token_upsert_required_reason, +} +import gunbc.auth.secret_ref_credential { + fetch_secret_ref_credential_with_token, + SecretCredentialReady, + SecretCredentialAccessUpsertRequired, + SecretCredentialWireDecodeRefused, + SecretCredentialFetchRefused, + SecretCredentialResolvedVersionMismatch, +} + +// THE ACCOUNT'S R2 PREREQUISITES ARE CONVERGED, NOT ASSUMED. gunbc.cloudflare.r2_origin allocated +// gunbai-fabric-origin and every signed request against it failed for nine days, because the +// account held no R2 subscription and the bucket did not exist, and nothing in the corpus observed +// either fact (gunbc#11713). This module is that observation and the one effect it licenses. +// +// THE SHAPE IS std.upsert_decision, NOT A NEW CONVERGENCE VOCABULARY. Per allocated purpose: an +// independent read of the bucket (goal-blind: the Get takes the name the roster allocated and +// returns what the account answered), a POINT classification pairing a verdict with Noop | Apply | +// Refuse, the one Apply the classifier can choose (create the bucket), and a readback by a second +// Get that must itself classify Converged. Selection happened earlier and elsewhere (DESIGN 3d): +// which purposes exist, which bucket each gets and in which jurisdiction are the roster's and the +// standing's; this module chooses none of them. +// +// ENTITLEMENT IS OBSERVED, AND REFUSED, NEVER PURCHASED. Cloudflare's published route to an R2 +// subscription is a dashboard checkout and its subscription API names no R2 rate plan +// (extdeps.cloudflare.r2 r2_get_started_subscription_reading, +// cloudflare_account_subscription_rate_plan_reading), so there is no gated effect to model: an +// unentitled account classifies Inaccessible and Refuses with the dashboard step. The account's +// entitlement is read as a byproduct of every bucket Get (R2BucketRead), so it costs no extra call +// and cannot be skipped by a purpose that happens to already have a bucket. +// +// JURISDICTION IS THE STANDING'S, AND A NON-DEFAULT ONE REFUSES BEFORE CREATE. The Create surface +// carries no jurisdiction header (extdeps.cloudflare.r2 cloudflare.R2Buckets), so a create for a +// non-default standing would silently make a default bucket under that name -- and a jurisdiction +// cannot be changed after creation. Both current purposes declare default. + +type R2BucketCreatePlan { + bucket_name: NonEmptyStr +} + +data r2_subscription_dashboard_step: NonEmptyStr = "THE ACCOUNT HOLDS NO R2 SUBSCRIPTION (403 NotEntitled): open the Cloudflare dashboard for this account, Storage & databases > R2 > Overview, and complete the checkout flow to add an R2 subscription (a billing purchase an operator makes; Cloudflare's subscription API offers no R2 plan, so this converge cannot perform it). Then run this entry again. NOTHING WAS CREATED." + +fn r2_ensure_refuse(verdict: ObservationVerdict, reason: String) -> UpsertClassification { + UpsertClassification { verdict: verdict, decision: Refuse { reason: reason as NonEmptyStr } } +} + +fn classify_present_bucket( + desired_name: NonEmptyStr, + desired: R2Jurisdiction, + observed: R2BucketResponse, +) -> UpsertClassification { + let wanted = desired_name as String + if observed.name != wanted { + r2_ensure_refuse( + verdict: Conflict, + reason: join(["the Get for ", desired_name as String, " answered about a bucket named ", observed.name], ""), + ) + } else { + match observed.jurisdiction { + Absent => + r2_ensure_refuse( + verdict: UnknownRefused, + reason: join(["bucket ", desired_name as String, " exists and its jurisdiction was not returned, so it cannot be compared to the declared ", r2_jurisdiction_wire(jurisdiction: desired)], ""), + ) + Present { value: raw } => + match parse_r2_jurisdiction(raw: raw) { + Absent => + r2_ensure_refuse( + verdict: UnknownRefused, + reason: join(["bucket ", desired_name as String, " reports jurisdiction ", raw, ", which is not in the published value set"], ""), + ) + Present { value: got } => + if r2_jurisdiction_wire(jurisdiction: got) == r2_jurisdiction_wire(jurisdiction: desired) { + UpsertClassification { verdict: Converged, decision: Noop } + } else { + r2_ensure_refuse( + verdict: Conflict, + reason: join(["bucket ", desired_name as String, " exists in jurisdiction ", raw, " but the standing declares ", r2_jurisdiction_wire(jurisdiction: desired), "; a jurisdiction cannot be changed after creation, so the bucket must be replaced by an operator or the standing corrected"], ""), + ) + } + } + } + } +} + +// PURE, SO EVERY ARM IS WITNESSABLE OVER A SUPPLIED READ (test.claim.cloudflare_r2_bucket_ensure). +fn classify_r2_bucket_ensure(desired: R2OriginStanding, read: R2BucketRead) -> UpsertClassification { + match desired { + R2OriginUnallocated { purpose: purpose } => + r2_ensure_refuse(verdict: UnknownRefused, reason: concat("no bucket is allocated for ", bucket_purpose_key(purpose: purpose))) + R2OriginAllocated { bucket: bucket } => + match read { + R2AccountNotEntitled => r2_ensure_refuse(verdict: Inaccessible, reason: r2_subscription_dashboard_step as String) + R2BucketReadRefused { cause: cause } => + r2_ensure_refuse(verdict: UnknownRefused, reason: concat("THE BUCKET READ WAS REFUSED, NOTHING CREATED: ", cause as String)) + R2BucketPresent { bucket: observed } => + classify_present_bucket(desired_name: bucket.bucket_name, desired: bucket.jurisdiction, observed: observed) + R2BucketAbsent => + match bucket.jurisdiction { + R2JurisdictionDefault => + UpsertClassification { verdict: Absent, decision: Apply { plan: R2BucketCreatePlan { bucket_name: bucket.bucket_name } } } + _ => + r2_ensure_refuse( + verdict: Absent, + reason: join(["bucket ", bucket.bucket_name as String, " is absent and its standing declares jurisdiction ", r2_jurisdiction_wire(jurisdiction: bucket.jurisdiction), ", which the Create surface cannot request; creating it would make a default-jurisdiction bucket under that name"], ""), + ) + } + } + } +} + +// ── THE EFFECTFUL HALF ─────────────────────────────────────────────────────────────────────────── + +type R2EnsureCredentials + = R2EnsureCredentialsReady { account_id: CloudflareAccountId, bootstrap: Secret } + | R2EnsureCredentialsRefused { cause: NonEmptyStr } + +// THE SAME CUSTODY LADDER THE MINT RUNS (gunbc.cloudflare.r2_token_mint_run r2_mint_bootstrap), +// WITHOUT THE MINT: account standing, then the pinned bootstrap locus, then the exact-version read +// through the run's resolved GCP identity. Each absence refuses before any Cloudflare call. +fn r2_ensure_credentials() -> R2EnsureCredentials uses net: Network { + match fleet_cloudflare_account_standing() { + CloudflareAccountUnallocated { company: _, intervention: intervention } => + R2EnsureCredentialsRefused { cause: concat("NO CLOUDFLARE ACCOUNT ID: ", intervention.instruction as String) as NonEmptyStr } + CloudflareAccountAllocated { company: _, account_id: account_id } => + match fleet_cloudflare_bootstrap_custody() { + BootstrapCustodyUnrecorded { intervention: intervention } => + R2EnsureCredentialsRefused { cause: concat("NO BOOTSTRAP TOKEN IN CUSTODY: ", intervention.instruction as String) as NonEmptyStr } + BootstrapCustodyPinned { locus: locus } => + match resolve_access_token() { + AccessTokenUpsertRequired { plan: plan } => + R2EnsureCredentialsRefused { cause: concat("NO GCP ACCESS TOKEN: ", access_token_upsert_required_reason(plan: plan)) as NonEmptyStr } + AccessTokenEnsureRefused { reason: reason } => + R2EnsureCredentialsRefused { cause: concat("NO GCP ACCESS TOKEN: ", reason as String) as NonEmptyStr } + AccessTokenReady { token: gcp_token } => + match fetch_secret_ref_credential_with_token(secret_ref: locus, access_token: gcp_token) { + SecretCredentialAccessUpsertRequired { plan: _ } => + R2EnsureCredentialsRefused { cause: "THE BOOTSTRAP TOKEN COULD NOT BE READ: the access ensure requires an interactive authentication this run cannot perform" as NonEmptyStr } + SecretCredentialFetchRefused { reason: reason } => + R2EnsureCredentialsRefused { cause: concat("THE BOOTSTRAP TOKEN COULD NOT BE READ: ", reason as String) as NonEmptyStr } + SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => + R2EnsureCredentialsRefused { cause: "THE BOOTSTRAP TOKEN COULD NOT BE READ: the access response did not decode" as NonEmptyStr } + SecretCredentialResolvedVersionMismatch { requested: requested, resolved: resolved } => + R2EnsureCredentialsRefused { + cause: join(["THE BOOTSTRAP READ ANSWERED ABOUT ANOTHER VERSION: asked for ", requested, ", answered about ", resolved], "") as NonEmptyStr + } + SecretCredentialReady { credential: bootstrap, resolved_version: _ } => + R2EnsureCredentialsReady { account_id: account_id, bootstrap: bootstrap } + } + } + } + } +} + +fn read_r2_bucket(bootstrap: Secret, account_id: CloudflareAccountId, bucket_name: NonEmptyStr) -> R2BucketRead uses net: Network { + let got = cloudflare.R2Buckets.Get( + auth_token: bootstrap, + account_id: account_id as String, + bucket_name: bucket_name as String, + ) + classify_r2_bucket_get( + outcome: got.outcome, + bucket: R2BucketResponse { name: got.name, jurisdiction: got.jurisdiction, location: got.location }, + ) +} + +// ONE LINE PER PURPOSE: which purpose, what was observed, what was decided, and -- for an Apply -- +// what the independent readback established. `held` is whether the purpose ended converged. +type R2PurposeEnsureOutcome { + line: String + held: Bool +} + +fn purpose_line(purpose: BucketPurpose, verdict: String, decision: String, detail: String) -> String { + join([bucket_purpose_key(purpose: purpose), verdict, decision, detail], "\t") +} + +fn observation_verdict_label(verdict: ObservationVerdict) -> String { + match verdict { + Converged => "converged" + Absent => "absent" + Drifted => "drifted" + Conflict => "conflict" + Inaccessible => "inaccessible" + UnknownRefused => "unknown-refused" + } +} + +// THE CREATE IS NEVER ITS OWN EVIDENCE. Its response is discarded for classification purposes; a +// second Get must answer Converged. A Create whose outcome is unknown (transport or 5xx) is followed +// by the same readback, which is what decides whether it landed -- never a retry. +fn apply_r2_bucket_create( + purpose: BucketPurpose, + desired: R2OriginStanding, + bootstrap: Secret, + account_id: CloudflareAccountId, + plan: R2BucketCreatePlan, +) -> R2PurposeEnsureOutcome uses net: Network { + let created = cloudflare.R2Buckets.Create( + auth_token: bootstrap, + account_id: account_id as String, + name: plan.bucket_name as String, + ) + let readback = classify_r2_bucket_ensure( + desired: desired, + read: read_r2_bucket(bootstrap: bootstrap, account_id: account_id, bucket_name: plan.bucket_name), + ) + let create_status = match created.outcome { + RestOk => "create ok" + RestStatusRefused { status: status, body: _ } => join(["create refused with status ", to_string(status)], "") + RestTransportRefused { cause: _ } => "create got no response" + RestBodyUndecodable { status: status, cause: _ } => join(["create status ", to_string(status), " body undecodable"], "") + } + match readback.decision { + Noop => + R2PurposeEnsureOutcome { + line: purpose_line(purpose: purpose, verdict: "absent", decision: "apply", detail: concat(create_status, "; readback converged")), + held: true, + } + Apply { plan: _ } => + R2PurposeEnsureOutcome { + line: purpose_line(purpose: purpose, verdict: "absent", decision: "apply", detail: concat(create_status, "; READBACK STILL ABSENT")), + held: false, + } + Refuse { reason: reason } => + R2PurposeEnsureOutcome { + line: purpose_line(purpose: purpose, verdict: "absent", decision: "apply", detail: join([create_status, "; READBACK REFUSED: ", reason as String], "")), + held: false, + } + } +} + +fn ensure_r2_purpose_bucket( + purpose: BucketPurpose, + bootstrap: Secret, + account_id: CloudflareAccountId, +) -> R2PurposeEnsureOutcome uses net: Network { + let desired = bucket_purpose_origin_standing(purpose: purpose) + let name = match desired { + R2OriginAllocated { bucket: bucket } => Present { value: bucket.bucket_name } + R2OriginUnallocated { purpose: _ } => none + } + match name { + Absent => + R2PurposeEnsureOutcome { + line: purpose_line(purpose: purpose, verdict: "unknown-refused", decision: "refuse", detail: "no bucket allocated"), + held: false, + } + Present { value: bucket_name } => { + let classified = classify_r2_bucket_ensure( + desired: desired, + read: read_r2_bucket(bootstrap: bootstrap, account_id: account_id, bucket_name: bucket_name), + ) + match classified.decision { + Noop => + R2PurposeEnsureOutcome { + line: purpose_line(purpose: purpose, verdict: observation_verdict_label(verdict: classified.verdict), decision: "noop", detail: bucket_name as String), + held: true, + } + Refuse { reason: reason } => + R2PurposeEnsureOutcome { + line: purpose_line(purpose: purpose, verdict: observation_verdict_label(verdict: classified.verdict), decision: "refuse", detail: reason as String), + held: false, + } + Apply { plan: plan } => + apply_r2_bucket_create(purpose: purpose, desired: desired, bootstrap: bootstrap, account_id: account_id, plan: plan) + } + } + } +} + +// RELATIVE TO THE CHECKOUT, like the preflight's receipt, so the fleet-converge upload step and a +// session run name the same file. +data r2_bucket_ensure_receipt_path: String = "target/r2-bucket-ensure-receipt.txt" + +// THE ENTRY. Every ALLOCATED purpose is walked -- the roster, not a hand list of the sum -- so a +// purpose allocated later is converged without an edit here. The receipt is written whatever the +// outcome, and the run fails if any purpose did not end converged. +fn ensure() -> ProcessExit uses net: Network { + match r2_ensure_credentials() { + R2EnsureCredentialsRefused { cause: cause } => exit_failure(reason: cause as String) + R2EnsureCredentialsReady { account_id: account_id, bootstrap: bootstrap } => { + let outcomes = map( + gunbc_fleet_r2_bucket_allocations, + row => ensure_r2_purpose_bucket(purpose: row.purpose, bootstrap: bootstrap, account_id: account_id), + ) + let text = join(map(outcomes, o => o.line), "\n") + let written = Filesystem.Write(path: r2_bucket_ensure_receipt_path, content: text) + let refused = filter(outcomes, o => !o.held) + if !written.success { + exit_failure(reason: concat("THE RECEIPT COULD NOT BE WRITTEN: ", written.error)) + } else if refused.length() == 0 { + ExitSuccess + } else { + exit_failure(reason: join(map(refused, o => o.line), "\n")) + } + } + } +} diff --git a/dag/gunbc/cloudflare/r2_origin.dag b/dag/gunbc/cloudflare/r2_origin.dag index edaa837c38b..5848411bc3c 100644 --- a/dag/gunbc/cloudflare/r2_origin.dag +++ b/dag/gunbc/cloudflare/r2_origin.dag @@ -375,3 +375,14 @@ fn fabric_boot_origin_standing() -> R2OriginStanding { } } } + +// ONE STANDING PER PURPOSE, SELECTED BY AN EXHAUSTIVE MATCH, so a third purpose cannot be allocated +// without stating its standing -- and every consumer that walks the allocation roster (the bucket +// converge in gunbc.cloudflare.r2_bucket_ensure) reaches each purpose's declared jurisdiction here +// rather than defaulting it at the call site. +fn bucket_purpose_origin_standing(purpose: BucketPurpose) -> R2OriginStanding { + match purpose { + FabricDurableOrigin {} => fabric_durable_origin_standing() + FabricBootOrigin {} => fabric_boot_origin_standing() + } +} diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index 35da4110b44..7a1f546eb34 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -17,6 +17,7 @@ import gunbc.action_use_admission { import gunbc.fleet.app_control_plane_converge { app_control_plane_receipt_path } import gunbc.fleet.org_actions_converge { org_actions_validation_receipt_path } import gunbc.cloudflare.r2_permission_group_observe { r2_mint_preflight_receipt_path } +import gunbc.cloudflare.r2_bucket_ensure { r2_bucket_ensure_receipt_path } import gunbc.cloudflare.r2_token_mint_run { r2_mint_receipt_glob_in, r2_mint_receipt_scope, R2MintReceiptAttemptScoped, R2OriginObjectWrite, } @@ -73,6 +74,7 @@ import gunbc.ci_spec { gunbc_ci_org_actions_converge_invoke, gunbc_ci_app_control_plane_converge_invoke, gunbc_ci_r2_mint_preflight_invoke, + gunbc_ci_r2_bucket_ensure_invoke, gunbc_ci_r2_object_write_mint_invoke, gunbc_ci_runner_guest_image_observe_invoke, gunbc_ci_runner_guest_image_converge_invoke, @@ -186,6 +188,7 @@ type FleetConvergeWorkflowMode | RunnerHostFileConverge | RunnerPasswordSessionToolConverge | R2MintPreflight + | R2BucketEnsure | R2ObjectWriteMint fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String { @@ -211,6 +214,7 @@ fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String RunnerHostFileConverge => "runner_host_file_converge" RunnerPasswordSessionToolConverge => "runner_password_session_tool_converge" R2MintPreflight => "r2_mint_preflight" + R2BucketEnsure => "r2_bucket_ensure" R2ObjectWriteMint => "r2_object_write_mint" } } @@ -236,7 +240,7 @@ fn fleet_converge_spark_target_description() -> String { ], "") } -data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint] +data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2BucketEnsure, R2ObjectWriteMint] // WHICH SCOPE A MODE SELECTS, WHERE IT SELECTS ONE AT ALL. // @@ -281,6 +285,7 @@ fn fleet_converge_mode_scope(mode: FleetConvergeWorkflowMode) -> FleetConvergeSc RunnerHostFileConverge => none RunnerPasswordSessionToolConverge => none R2MintPreflight => none + R2BucketEnsure => none R2ObjectWriteMint => none } } @@ -1105,6 +1110,7 @@ fn fleet_converge_app_control_plane_step() -> Step { // short-lived access token the WIF auth step mints for THIS run, read in-process by // gunbc.auth.access_token_source rather than interpolated into a command line. data fleet_converge_r2_mint_preflight_step_if: String = fleet_converge_mode_step_if(mode: R2MintPreflight) +data fleet_converge_r2_bucket_ensure_step_if: String = fleet_converge_mode_step_if(mode: R2BucketEnsure) data fleet_converge_r2_object_write_mint_step_if: String = fleet_converge_mode_step_if(mode: R2ObjectWriteMint) fn fleet_converge_r2_mint_preflight_step() -> Step { @@ -1141,6 +1147,47 @@ fn fleet_converge_r2_mint_preflight_receipt_upload_step() -> Step { } } +// THE BUCKET ENSURE IS ITS OWN MODE, NOT A PREFIX OF THE MINT. It is the one R2 step whose effect is +// idempotent under readback (a Create only on an established absence, confirmed by a second Get), and +// it answers a question -- is the account entitled, does each allocated bucket exist -- worth asking +// on its own at any time. Fusing it into the mint would make that question unaskable without also +// minting a non-idempotent token. +fn fleet_converge_r2_bucket_ensure_step() -> Step { + RunStep { + name: Present { value: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" }, + id: Present { value: "r2_bucket_ensure" }, + run: gunbc_ci_r2_bucket_ensure_invoke(), + shell: none, + env: Present { value: [ + kv(key: "WIF_ACCESS_TOKEN", value: yaml_string(s: "${{ steps.wif_auth.outputs.access_token }}")), + ] }, + working_directory: none, + if_condition: Present { value: fleet_converge_r2_bucket_ensure_step_if }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_aux_step_timeout_minutes } + } +} + +// always(): a refusing run -- an unentitled account, a bucket in the wrong jurisdiction -- is the run +// whose receipt names what to do, and the entry writes the receipt before it fails. +fn fleet_converge_r2_bucket_ensure_receipt_upload_step() -> Step { + UsesStep { + name: Present { value: "Upload R2 bucket ensure receipt" }, + id: Present { value: "r2_bucket_ensure_receipt_upload" }, + uses: upload_artifact_action, + with: Present { value: [ + kv(key: "name", value: yaml_string(s: "r2-bucket-ensure")), + kv(key: "path", value: yaml_string(s: r2_bucket_ensure_receipt_path)), + kv(key: "if-no-files-found", value: yaml_string(s: "warn")), + kv(key: "retention-days", value: yaml_int(n: 30)), + ] }, + env: none, + if_condition: Present { value: join(["always() && ", fleet_converge_r2_bucket_ensure_step_if], "") }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_artifact_transfer_step_timeout_minutes } + } +} + fn fleet_converge_r2_object_write_mint_step() -> Step { RunStep { name: Present { value: "R2 object-write token mint (AccountTokens.Create + Secret Manager custody)" }, @@ -1537,6 +1584,8 @@ fn fleet_converge_job() -> Job { fleet_converge_app_control_plane_receipt_upload_step(), fleet_converge_r2_mint_preflight_step(), fleet_converge_r2_mint_preflight_receipt_upload_step(), + fleet_converge_r2_bucket_ensure_step(), + fleet_converge_r2_bucket_ensure_receipt_upload_step(), fleet_converge_r2_object_write_mint_step(), fleet_converge_r2_object_write_mint_receipt_upload_step(), fleet_converge_guest_image_observe_step(), diff --git a/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag b/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag new file mode 100644 index 00000000000..f286619187a --- /dev/null +++ b/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag @@ -0,0 +1,189 @@ +module test.claim.cloudflare_r2_bucket_ensure + +import std.types { String, NonEmptyStr, Bool } +import std.upsert_decision { + ObservationVerdict, Converged, Absent, Conflict, Inaccessible, UnknownRefused, + Noop, Apply, Refuse, + UpsertClassification, +} +import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused } +import extdeps.cloudflare.r2 { + R2JurisdictionDefault, R2JurisdictionEu, + R2BucketResponse, + R2BucketRead, R2BucketPresent, R2BucketAbsent, R2AccountNotEntitled, R2BucketReadRefused, + classify_r2_bucket_get, +} +import gunbc.cloudflare.r2_origin { + FabricDurableOrigin, FabricBootOrigin, + R2OriginStanding, R2OriginAllocated, R2OriginUnallocated, + R2OriginBucket, + OriginCredentialMintEntryUnbuilt, + bucket_purpose_origin_standing, + gunbc_fleet_r2_bucket_allocations, +} +import gunbc.cloudflare.r2_bucket_ensure { + R2BucketCreatePlan, + classify_r2_bucket_ensure, + r2_subscription_dashboard_step, +} + +// THE WITNESS SUPPLIES THE READ AND RUNS THE REAL CLASSIFIERS. The two classifiers are the whole +// decision surface: classify_r2_bucket_get turns an HTTP outcome into one of the four bucket facts, +// and classify_r2_bucket_ensure turns a declared standing and that fact into Noop | Apply | Refuse. +// The live Get/Create exchange is exercised by running gunbc.cloudflare.r2_bucket_ensure ensure +// against the account (its receipt is recorded on the landing PR); no claim here can reach it. + +fn witness_standing(jurisdiction_is_eu: Bool) -> R2OriginStanding { + R2OriginAllocated { + bucket: R2OriginBucket { + bucket_name: "witness-bucket", + jurisdiction: if jurisdiction_is_eu { R2JurisdictionEu } else { R2JurisdictionDefault }, + purpose: FabricDurableOrigin {}, + read_credential: OriginCredentialMintEntryUnbuilt { secret_id: "witness-read" }, + write_credential: OriginCredentialMintEntryUnbuilt { secret_id: "witness-write" }, + } + } +} + +fn present(name: String, jurisdiction: String) -> R2BucketRead { + R2BucketPresent { bucket: R2BucketResponse { name: name, jurisdiction: Present { value: jurisdiction }, location: none } } +} + +fn empty_bucket() -> R2BucketResponse { + R2BucketResponse { name: "", jurisdiction: none, location: none } +} + +fn is_noop(c: UpsertClassification) -> Bool { + match c.decision { Noop => true Apply { plan: _ } => false Refuse { reason: _ } => false } +} + +fn refusal_reason(c: UpsertClassification) -> String { + match c.decision { Noop => "" Apply { plan: _ } => "" Refuse { reason: r } => r as String } +} + +fn applied_bucket(c: UpsertClassification) -> String { + match c.decision { Noop => "" Apply { plan: p } => p.bucket_name as String Refuse { reason: _ } => "" } +} + +fn verdict_is(c: UpsertClassification, want: ObservationVerdict) -> Bool { + match c.verdict { + Converged => match want { Converged => true _ => false } + Absent => match want { Absent => true _ => false } + Conflict => match want { Conflict => true _ => false } + Inaccessible => match want { Inaccessible => true _ => false } + UnknownRefused => match want { UnknownRefused => true _ => false } + _ => false + } +} + +// ── THE DISCRIMINATING REDS: THE TWO STATES THE INCIDENT HID ──────────────────────────────────── + +// An established absence creates the declared bucket, and only that bucket. +test fn an_absent_bucket_applies_a_create_of_the_declared_name() -> Bool { + let c = classify_r2_bucket_ensure(desired: witness_standing(jurisdiction_is_eu: false), read: R2BucketAbsent) + verdict_is(c: c, want: Absent) && applied_bucket(c: c) == "witness-bucket" +} + +// An unentitled account refuses with the dashboard step and creates nothing -- the purchase is not +// an effect this converge may perform. +test fn an_unentitled_account_refuses_with_the_dashboard_step() -> Bool { + let c = classify_r2_bucket_ensure(desired: witness_standing(jurisdiction_is_eu: false), read: R2AccountNotEntitled) + verdict_is(c: c, want: Inaccessible) + && refusal_reason(c: c) == r2_subscription_dashboard_step as String + && applied_bucket(c: c) == "" +} + +// ── THE POSITIVE CONTROL ──────────────────────────────────────────────────────────────────────── + +test fn a_present_default_bucket_is_converged_and_noops() -> Bool { + let c = classify_r2_bucket_ensure(desired: witness_standing(jurisdiction_is_eu: false), read: present(name: "witness-bucket", jurisdiction: "default")) + verdict_is(c: c, want: Converged) && is_noop(c: c) +} + +// ── THE NEIGHBOURING REFUSALS ─────────────────────────────────────────────────────────────────── + +// A jurisdiction is immutable after creation, so a mismatch is a conflict, never an Apply. +test fn a_bucket_in_another_jurisdiction_is_a_conflict() -> Bool { + let c = classify_r2_bucket_ensure(desired: witness_standing(jurisdiction_is_eu: true), read: present(name: "witness-bucket", jurisdiction: "default")) + verdict_is(c: c, want: Conflict) && !is_noop(c: c) && applied_bucket(c: c) == "" +} + +// The Create surface carries no jurisdiction header, so an absent non-default bucket refuses rather +// than creating a default bucket under its name. +test fn an_absent_non_default_bucket_refuses_rather_than_creating_default() -> Bool { + let c = classify_r2_bucket_ensure(desired: witness_standing(jurisdiction_is_eu: true), read: R2BucketAbsent) + verdict_is(c: c, want: Absent) && applied_bucket(c: c) == "" && refusal_reason(c: c) != "" +} + +test fn a_present_bucket_with_no_jurisdiction_is_not_assumed_default() -> Bool { + let c = classify_r2_bucket_ensure( + desired: witness_standing(jurisdiction_is_eu: false), + read: R2BucketPresent { bucket: R2BucketResponse { name: "witness-bucket", jurisdiction: none, location: none } }, + ) + verdict_is(c: c, want: UnknownRefused) && !is_noop(c: c) +} + +test fn a_refused_read_creates_nothing() -> Bool { + let c = classify_r2_bucket_ensure(desired: witness_standing(jurisdiction_is_eu: false), read: R2BucketReadRefused { cause: "status 401" }) + verdict_is(c: c, want: UnknownRefused) && applied_bucket(c: c) == "" +} + +// ── THE WIRE CLASSIFIER: ONLY AN EXACT CITED CODE MINTS A FACT ───────────────────────────────── + +fn read_is_not_entitled(r: R2BucketRead) -> Bool { + match r { R2AccountNotEntitled => true _ => false } +} + +fn read_is_absent(r: R2BucketRead) -> Bool { + match r { R2BucketAbsent => true _ => false } +} + +fn read_is_refused(r: R2BucketRead) -> Bool { + match r { R2BucketReadRefused { cause: _ } => true _ => false } +} + +test fn a_403_carrying_not_entitled_reads_as_unentitled() -> Bool { + read_is_not_entitled(r: classify_r2_bucket_get( + outcome: RestStatusRefused { status: 403, body: "{\"success\":false,\"errors\":[{\"code\":10042,\"message\":\"Please enable R2 through the Cloudflare Dashboard.\"}],\"messages\":[],\"result\":null}" }, + bucket: empty_bucket(), + )) +} + +test fn a_404_carrying_no_such_bucket_reads_as_absent() -> Bool { + read_is_absent(r: classify_r2_bucket_get( + outcome: RestStatusRefused { status: 404, body: "{\"success\":false,\"errors\":[{\"code\":10006,\"message\":\"The specified bucket does not exist.\"}],\"messages\":[],\"result\":null}" }, + bucket: empty_bucket(), + )) +} + +// The bet stays a bet: an authorization 403 is NOT an entitlement fact, even when its message +// mentions the entitlement code, and a bare 404 is NOT an established absence. +test fn a_403_with_another_code_is_an_unclassified_refusal() -> Bool { + read_is_refused(r: classify_r2_bucket_get( + outcome: RestStatusRefused { status: 403, body: "{\"success\":false,\"errors\":[{\"code\":10000,\"message\":\"Authentication error (not 10042)\"}]}" }, + bucket: empty_bucket(), + )) +} + +test fn a_404_with_an_unreadable_body_is_not_an_absence() -> Bool { + read_is_refused(r: classify_r2_bucket_get(outcome: RestStatusRefused { status: 404, body: "not found" }, bucket: empty_bucket())) +} + +test fn a_transport_failure_is_not_an_absence() -> Bool { + read_is_refused(r: classify_r2_bucket_get(outcome: RestTransportRefused { cause: "tls handshake alert" }, bucket: empty_bucket())) +} + +// ── THE REAL ROSTER REACHES THE CLASSIFIER ────────────────────────────────────────────────────── +// Every allocated purpose resolves, through the real standing, to an allocated bucket whose own +// name and declared jurisdiction noop against a matching read. A purpose whose standing were +// unallocated, or declared a jurisdiction other than the one its bucket answers with, goes red here. +test fn every_allocated_purpose_converges_against_its_own_bucket() -> Bool { + let held = map(gunbc_fleet_r2_bucket_allocations, row => { + let c = classify_r2_bucket_ensure( + desired: bucket_purpose_origin_standing(purpose: row.purpose), + read: present(name: row.bucket_name as String, jurisdiction: "default"), + ) + is_noop(c: c) + }) + held.length() == 2 && filter(held, h => !h).length() == 0 +} From f820b55a6783911dbf15fa6a03edddbc0a9ae435 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 16:13:17 +0000 Subject: [PATCH 02/17] Bucket ensure signs with a minted bucket-admin token; label spellings on std.upsert_decision Operator ruling (option B): the bootstrap token is not widened. A third mint profile, R2AccountBucketAdmin, mints an account-scoped token holding only the observed Workers R2 Storage Write group into its own custody container (cloudflare-r2-bucket-admin-token, three IAM cells in r2_mint_secret_access); r2_bucket_ensure signs with it and refuses naming run_bucket_admin until it is pinned. The R2 buckets service moves to extdeps.cloudflare.r2_buckets so its cloudflare.* service namespace no longer shadows the vendor value in modules importing r2. Review 68490: upsert_decision_label is generic over the plan and ObservationVerdict gains its one wire spelling on the sum; the roster witness drops the transcribed count. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/cloudflare/r2.dag | 188 ++++------------- dag/extdeps/cloudflare/r2_buckets.dag | 192 ++++++++++++++++++ dag/gunbc/ci/ci_spec.dag | 16 ++ dag/gunbc/cloudflare/r2_bucket_ensure.dag | 120 ++++++----- .../cloudflare/r2_mint_secret_access.dag | 46 +++++ dag/gunbc/cloudflare/r2_origin.dag | 15 ++ .../r2_permission_group_observe.dag | 13 ++ dag/gunbc/cloudflare/r2_token_mint.dag | 40 +++- dag/gunbc/cloudflare/r2_token_mint_run.dag | 28 ++- dag/gunbc/extdeps_scope_frontier.dag | 1 + dag/gunbc/fleet/fleet_converge_workflow.dag | 51 ++++- dag/std/upsert_decision.dag | 16 +- ...oudflare_r2_bucket_ensure_witness_test.dag | 39 +++- ...dflare_r2_origin_mint_run_witness_test.dag | 41 ++++ .../r2_mint_secret_access_witness_test.dag | 23 +++ 15 files changed, 620 insertions(+), 209 deletions(-) create mode 100644 dag/extdeps/cloudflare/r2_buckets.dag diff --git a/dag/extdeps/cloudflare/r2.dag b/dag/extdeps/cloudflare/r2.dag index d2749746131..5d95b24fb26 100644 --- a/dag/extdeps/cloudflare/r2.dag +++ b/dag/extdeps/cloudflare/r2.dag @@ -1,14 +1,6 @@ module extdeps.cloudflare.r2 -import std.types { NonEmptyStr, String, List, Timestamp, Map, Secret, HttpStatus } -import extdeps.transports.rest { RestOutcome, RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } -import extdeps.cloudflare.client_v4 { - cloudflare_client_v4_base, - CloudflareApiError, - cloudflare_error_codes, - CloudflareErrorCodesListed, - CloudflareErrorEnvelopeUnreadable, -} +import std.types { NonEmptyStr, String, List, Timestamp, Map } import std.decl_ref { DeclarationRef, WholeDeclaration, NamedField, decl_ref } import std.roster_frontier { FrontierRow, frontier_row_decl } import std.dissolution { unbound_dissolution } @@ -365,6 +357,38 @@ fn r2_object_write_create_request( ) } +// THE ACCOUNT-SCOPED STORAGE GRANT: BUCKET MANAGEMENT IS AN ACCOUNT OPERATION. Creating or reading a +// bucket's metadata is not an item operation on a bucket that may not yet exist, so no bucket-scoped +// resource can carry it; the grant names the ACCOUNT resource (r2_account_resource_name). Like the +// item-write grant, the group id is an observation the workflow layer supplies +// (gunbc.cloudflare.r2_permission_group_observe), never an id authored here. +fn r2_account_storage_grant(storage_group: PermissionGroupRef) -> CloudflarePermissionGrant { + PermissionGroupGrant { group: storage_group } +} + +fn r2_account_resources(account_id: String) -> Map { + map_insert(empty_map(), r2_account_resource_name(account_id: account_id), "*") +} + +fn r2_account_storage_create_request( + name: String, + storage_group: PermissionGroupRef, + account_id: String +) -> CreateAccountTokenRequest { + CreateAccountTokenRequest { + name: name, + policies: [ + AccountTokenPolicy { + effect: PolicyAllow, + permission_groups: permission_groups_from_grants(grants: [r2_account_storage_grant(storage_group: storage_group)]), + resources: r2_account_resources(account_id: account_id) + } + ], + not_before: none, + expires_on: none + } +} + // THE S3 OBJECT ADDRESS AND THE SIGNATURE REGION, CITED TO THE R2 S3 API DOCUMENT // (developers.cloudflare.com/r2/api/s3/api, r2_s3_api_citation above). R2 serves the S3 API at // .r2.cloudflarestorage.com and addresses objects PATH-STYLE, //, so the @@ -586,150 +610,6 @@ data r2_not_entitled_error_code: NonEmptyStr = "10042" data r2_no_such_bucket_error_code: NonEmptyStr = "10006" -// THE BUCKET AS GET AND CREATE RETURN IT. `name` is the identity the readback compares; jurisdiction -// is the wire token r2_jurisdiction_wire spells, optional on the wire (r2_get_bucket_location_reading) -// and parsed rather than trusted. -type R2BucketResponse { - name: String - jurisdiction: String? - location: String? -} - -fn parse_r2_jurisdiction(raw: String) -> R2Jurisdiction? { - if raw == "default" { - Present { value: R2JurisdictionDefault } - } else if raw == "eu" { - Present { value: R2JurisdictionEu } - } else if raw == "fedramp" { - Present { value: R2JurisdictionFedramp } - } else if raw == "fedramp-high" { - Present { value: R2JurisdictionFedrampHigh } - } else if raw == "us" { - Present { value: R2JurisdictionUs } - } else { - none - } -} - -// THE JURISDICTION HEADER IS NOT CARRIED, AND THAT IS WHY BOTH OPERATIONS ARE DEFAULT-ONLY. Create -// and Get take jurisdiction as the cf-r2-jurisdiction HEADER (r2_create_bucket_location_hint_reading), -// and this service surface has no header slot; a request without it addresses the default -// jurisdiction. A caller that wants another jurisdiction must refuse before calling rather than -// create a default bucket under a non-default name -- the converge does exactly that. -// -// CREATE IS NOT IDEMPOTENT ON THE WIRE: a second Create of an existing name is refused (10073 -// BucketConflict on the error-code page), so the converge observes first, creates only on an -// established absence, and reads back through Get rather than trusting the Create response. -service cloudflare.R2Buckets { - config { - endpoint: cloudflare_client_v4_base - auth: Bearer - auth_input: auth_token - } - - operation Get { - input { - auth_token: Secret - account_id: String - bucket_name: String - } - output { - name: String from "result/name" - jurisdiction: String? from "result/jurisdiction" - location: String? from "result/location" - outcome: RestOutcome - } - readonly - transport rest { - method: GET, - path: "/accounts/\{account_id\}/r2/buckets/\{bucket_name\}" - } - response { - 200 => R2BucketResponse - 401 => CloudflareApiError - 403 => CloudflareApiError - 404 => CloudflareApiError - 5xx => CloudflareApiError - } - } - - operation Create { - input { - auth_token: Secret - account_id: String - name: String - } - output { - name: String from "result/name" - jurisdiction: String? from "result/jurisdiction" - location: String? from "result/location" - outcome: RestOutcome - } - transport rest { - method: POST, - path: "/accounts/\{account_id\}/r2/buckets", - body: { - name: name - } - } - response { - 200 => R2BucketResponse - 400 => CloudflareApiError - 401 => CloudflareApiError - 403 => CloudflareApiError - 409 => CloudflareApiError - 5xx => CloudflareApiError - } - } -} - -// ONE BUCKET READ, CLASSIFIED. The three facts the Get can establish -- the bucket, its absence, the -// account's lack of entitlement -- are arms; everything else is a refusal carrying the status and the -// codes it did carry. An entitlement is observed as a BYPRODUCT of any bucket answer: a 200 or an -// exact NoSuchBucket means the account answered about R2 resources, which an unentitled one does not. -type R2BucketRead - = R2BucketPresent { bucket: R2BucketResponse } - | R2BucketAbsent - | R2AccountNotEntitled - | R2BucketReadRefused { cause: NonEmptyStr } - -fn r2_codes_text(codes: List) -> String { - if codes.length() == 0 { "no codes" } else { join(codes, ",") } -} - -fn classify_r2_bucket_refusal(status: HttpStatus, body: String) -> R2BucketRead { - match cloudflare_error_codes(body: body) { - CloudflareErrorEnvelopeUnreadable { cause: cause } => - R2BucketReadRefused { - cause: join(["status ", to_string(status), " with an unreadable error envelope: ", cause as String], "") as NonEmptyStr - } - CloudflareErrorCodesListed { codes: codes } => - if status == 403 && contains(codes, r2_not_entitled_error_code as String) { - R2AccountNotEntitled - } else if status == 404 && contains(codes, r2_no_such_bucket_error_code as String) { - R2BucketAbsent - } else { - R2BucketReadRefused { - cause: join(["status ", to_string(status), " carrying ", r2_codes_text(codes: codes), ", which names neither NotEntitled nor NoSuchBucket"], "") as NonEmptyStr - } - } - } -} - -fn classify_r2_bucket_get(outcome: RestOutcome, bucket: R2BucketResponse) -> R2BucketRead { - match outcome { - RestOk => R2BucketPresent { bucket: bucket } - RestStatusRefused { status: status, body: body } => classify_r2_bucket_refusal(status: status, body: body) - RestTransportRefused { cause: cause } => - R2BucketReadRefused { cause: concat("no response: ", cause as String) as NonEmptyStr } - RestBodyUndecodable { status: status, cause: cause } => - R2BucketReadRefused { - cause: join(["status ", to_string(status), " body did not decode: ", cause as String], "") as NonEmptyStr - } - } -} - - // THE r2_s3_endpoint_url ROW IS RETIRED: gunbc.cloudflare.r2_origin_object reaches it through // r2_object_url on the executing put_origin_object and fetch_origin_object entries, which is the // consumer the row named. The list is the membership of what remains. r2_tokens_doc_as_of stays: @@ -738,7 +618,7 @@ fn classify_r2_bucket_get(outcome: RestOutcome, bucket: R2BucketResponse) -> R2B data cloudflare_r2_frontier_rows: List = [ frontier_row_decl( ref: decl_ref(module_path: "extdeps.cloudflare.r2", decl_name: "r2_account_resource_name"), - reason: "account IAM resource name has no executing consumer; object-read policy uses the bucket resource name", + reason: "account IAM resource name is consumed by r2_account_storage_create_request, whose mint entry (gunbc.cloudflare.r2_token_mint_run run_bucket_admin) has not yet executed against the account", dissolution: unbound_dissolution( description: "dissolves when a gunbc declaration consumes r2_account_resource_name in an executing token policy or S3 IAM grant; a witness that the fold exists does not fire this" ) diff --git a/dag/extdeps/cloudflare/r2_buckets.dag b/dag/extdeps/cloudflare/r2_buckets.dag new file mode 100644 index 00000000000..1c362b61478 --- /dev/null +++ b/dag/extdeps/cloudflare/r2_buckets.dag @@ -0,0 +1,192 @@ +module extdeps.cloudflare.r2_buckets + +import std.types { NonEmptyStr, String, List, Secret, HttpStatus } +import extdeps.transports.rest { RestOutcome, RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } +import extdeps.cloudflare.client_v4 { + cloudflare_client_v4_base, + CloudflareApiError, + cloudflare_error_codes, + CloudflareErrorCodesListed, + CloudflareErrorEnvelopeUnreadable, +} +import std.decl_ref { DeclarationRef, WholeDeclaration } +import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } +import extdeps.cloudflare.r2 { + r2_create_bucket_authority, + r2_get_bucket_authority, + r2_error_codes_authority, + R2Jurisdiction, + R2JurisdictionDefault, R2JurisdictionEu, R2JurisdictionFedramp, R2JurisdictionFedrampHigh, R2JurisdictionUs, + r2_not_entitled_error_code, + r2_no_such_bucket_error_code, +} + +// THE R2 BUCKETS RESOURCE OF THE client/v4 API, ONE MODULE PER API RESOURCE AS +// extdeps.cloudflare.account_api_tokens IS. The product's facts -- jurisdictions, the cited readings, +// the error codes -- stay in extdeps.cloudflare.r2; this module is the operation surface over them. +// It is separate for a concrete reason as well as the precedent: a `service cloudflare.*` declaration +// binds the name `cloudflare` in every module that imports its module, and extdeps.cloudflare.r2 is +// imported beside extdeps.vendor.cloudflare's `cloudflare` value (test.claim.cloudflare_r2_origin_mint_run +// reads `cloudflare.legal_name`), which then stopped resolving. Callers of the operations import this +// module whole, as gunbc.cloudflare.r2_token_mint_run imports account_api_tokens. + +// THE ANCHOR IS THE GET REFERENCE PAGE, ALREADY DECLARED IN extdeps.cloudflare.r2, not a second +// ExternalAuthority for the same URI. +data extdeps_external_authority_anchor: ExternalAuthority = r2_get_bucket_authority + +data extdeps_model_scope: ExternalModelScope = ExternalModelScope { + subject: ExternalSubjectRef { + declaration: DeclarationRef { + module_path: "extdeps.cloudflare.r2_buckets", + decl_name: "R2BucketResponse", + field: WholeDeclaration + } + }, + first_citation: extdeps_external_authority_anchor, + further_citations: [r2_create_bucket_authority, r2_error_codes_authority] +} + +// THE BUCKET AS GET AND CREATE RETURN IT. `name` is the identity the readback compares; jurisdiction +// is the wire token r2_jurisdiction_wire spells, optional on the wire (r2_get_bucket_location_reading) +// and parsed rather than trusted. +type R2BucketResponse { + name: String + jurisdiction: String? + location: String? +} + +fn parse_r2_jurisdiction(raw: String) -> R2Jurisdiction? { + if raw == "default" { + Present { value: R2JurisdictionDefault } + } else if raw == "eu" { + Present { value: R2JurisdictionEu } + } else if raw == "fedramp" { + Present { value: R2JurisdictionFedramp } + } else if raw == "fedramp-high" { + Present { value: R2JurisdictionFedrampHigh } + } else if raw == "us" { + Present { value: R2JurisdictionUs } + } else { + none + } +} + +// THE JURISDICTION HEADER IS NOT CARRIED, AND THAT IS WHY BOTH OPERATIONS ARE DEFAULT-ONLY. Create +// and Get take jurisdiction as the cf-r2-jurisdiction HEADER (r2_create_bucket_location_hint_reading), +// and this service surface has no header slot; a request without it addresses the default +// jurisdiction. A caller that wants another jurisdiction must refuse before calling rather than +// create a default bucket under a non-default name -- the converge does exactly that. +// +// CREATE IS NOT IDEMPOTENT ON THE WIRE: a second Create of an existing name is refused (10073 +// BucketConflict on the error-code page), so the converge observes first, creates only on an +// established absence, and reads back through Get rather than trusting the Create response. +service cloudflare.R2Buckets { + config { + endpoint: cloudflare_client_v4_base + auth: Bearer + auth_input: auth_token + } + + operation Get { + input { + auth_token: Secret + account_id: String + bucket_name: String + } + output { + name: String from "result/name" + jurisdiction: String? from "result/jurisdiction" + location: String? from "result/location" + outcome: RestOutcome + } + readonly + transport rest { + method: GET, + path: "/accounts/\{account_id\}/r2/buckets/\{bucket_name\}" + } + response { + 200 => R2BucketResponse + 401 => CloudflareApiError + 403 => CloudflareApiError + 404 => CloudflareApiError + 5xx => CloudflareApiError + } + } + + operation Create { + input { + auth_token: Secret + account_id: String + name: String + } + output { + name: String from "result/name" + jurisdiction: String? from "result/jurisdiction" + location: String? from "result/location" + outcome: RestOutcome + } + transport rest { + method: POST, + path: "/accounts/\{account_id\}/r2/buckets", + body: { + name: name + } + } + response { + 200 => R2BucketResponse + 400 => CloudflareApiError + 401 => CloudflareApiError + 403 => CloudflareApiError + 409 => CloudflareApiError + 5xx => CloudflareApiError + } + } +} + +// ONE BUCKET READ, CLASSIFIED. The three facts the Get can establish -- the bucket, its absence, the +// account's lack of entitlement -- are arms; everything else is a refusal carrying the status and the +// codes it did carry. An entitlement is observed as a BYPRODUCT of any bucket answer: a 200 or an +// exact NoSuchBucket means the account answered about R2 resources, which an unentitled one does not. +type R2BucketRead + = R2BucketPresent { bucket: R2BucketResponse } + | R2BucketAbsent + | R2AccountNotEntitled + | R2BucketReadRefused { cause: NonEmptyStr } + +fn r2_codes_text(codes: List) -> String { + if codes.length() == 0 { "no codes" } else { join(codes, ",") } +} + +fn classify_r2_bucket_refusal(status: HttpStatus, body: String) -> R2BucketRead { + match cloudflare_error_codes(body: body) { + CloudflareErrorEnvelopeUnreadable { cause: cause } => + R2BucketReadRefused { + cause: join(["status ", to_string(status), " with an unreadable error envelope: ", cause as String], "") as NonEmptyStr + } + CloudflareErrorCodesListed { codes: codes } => + if status == 403 && contains(codes, r2_not_entitled_error_code as String) { + R2AccountNotEntitled + } else if status == 404 && contains(codes, r2_no_such_bucket_error_code as String) { + R2BucketAbsent + } else { + R2BucketReadRefused { + cause: join(["status ", to_string(status), " carrying ", r2_codes_text(codes: codes), ", which names neither NotEntitled nor NoSuchBucket"], "") as NonEmptyStr + } + } + } +} + +fn classify_r2_bucket_get(outcome: RestOutcome, bucket: R2BucketResponse) -> R2BucketRead { + match outcome { + RestOk => R2BucketPresent { bucket: bucket } + RestStatusRefused { status: status, body: body } => classify_r2_bucket_refusal(status: status, body: body) + RestTransportRefused { cause: cause } => + R2BucketReadRefused { cause: concat("no response: ", cause as String) as NonEmptyStr } + RestBodyUndecodable { status: status, cause: cause } => + R2BucketReadRefused { + cause: join(["status ", to_string(status), " body did not decode: ", cause as String], "") as NonEmptyStr + } + } +} + + diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 02d37fbbd44..656568eb6ca 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -786,6 +786,11 @@ data gunbc_ci_r2_bucket_ensure_target: GunbcRunStepTarget = GunbcRunStepTarget { function: "ensure", } +data gunbc_ci_r2_bucket_admin_mint_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/cloudflare/r2_token_mint_run.dag", + function: "run_bucket_admin", +} + data gunbc_ci_r2_object_write_mint_target: GunbcRunStepTarget = GunbcRunStepTarget { entry: "dag/gunbc/cloudflare/r2_token_mint_run.dag", function: "run_object_write", @@ -1010,6 +1015,7 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_org_actions_converge_target, gunbc_ci_r2_mint_preflight_target, gunbc_ci_r2_bucket_ensure_target, + gunbc_ci_r2_bucket_admin_mint_target, gunbc_ci_r2_object_write_mint_target, gunbc_ci_runner_guest_image_observe_target, gunbc_ci_runner_guest_image_converge_target, @@ -1794,6 +1800,16 @@ fn gunbc_ci_r2_bucket_ensure_invoke() -> String { ) } +fn gunbc_ci_r2_bucket_admin_mint_invoke() -> String { + gunbc_run_step_script( + source_roots: witness_layer_roots, + entry: gunbc_ci_r2_bucket_admin_mint_target.entry, + function: gunbc_ci_r2_bucket_admin_mint_target.function, + claim_run: false, + receipt_rel: none + ) +} + fn gunbc_ci_r2_object_write_mint_invoke() -> String { gunbc_run_step_script( source_roots: witness_layer_roots, diff --git a/dag/gunbc/cloudflare/r2_bucket_ensure.dag b/dag/gunbc/cloudflare/r2_bucket_ensure.dag index 58c5454129c..277ce8f0195 100644 --- a/dag/gunbc/cloudflare/r2_bucket_ensure.dag +++ b/dag/gunbc/cloudflare/r2_bucket_ensure.dag @@ -2,17 +2,22 @@ module gunbc.cloudflare.r2_bucket_ensure import std.types { String, NonEmptyStr, Secret, List, Bool } import std.resources { Network } +import extdeps.cloud.gcp.secret_ref { SecretRef } import extdeps.filesystem.filesystem_io { Filesystem } import std.process { ProcessExit, ExitSuccess, exit_failure } import std.upsert_decision { ObservationVerdict, Converged, Absent, Drifted, Conflict, Inaccessible, UnknownRefused, - UpsertDecision, Noop, Apply, Refuse, + Noop, Apply, Refuse, UpsertClassification, upsert_decision_label, + observation_verdict_label, } import extdeps.cloudflare.r2 { R2Jurisdiction, R2JurisdictionDefault, r2_jurisdiction_wire, +} +import extdeps.cloudflare.r2_buckets +import extdeps.cloudflare.r2_buckets { parse_r2_jurisdiction, R2BucketResponse, R2BucketRead, R2BucketPresent, R2BucketAbsent, R2AccountNotEntitled, R2BucketReadRefused, @@ -21,15 +26,14 @@ import extdeps.cloudflare.r2 { import gunbc.cloudflare.r2_origin { CloudflareAccountId, BucketPurpose, - R2BucketAllocation, bucket_purpose_key, bucket_purpose_origin_standing, gunbc_fleet_r2_bucket_allocations, R2OriginStanding, R2OriginAllocated, R2OriginUnallocated, FleetCloudflareAccountStanding, CloudflareAccountAllocated, CloudflareAccountUnallocated, - BootstrapCustodyStanding, BootstrapCustodyPinned, BootstrapCustodyUnrecorded, + OriginCredentialStanding, OriginCredentialPinned, OriginCredentialUnminted, OriginCredentialMintEntryUnbuilt, fleet_cloudflare_account_standing, - fleet_cloudflare_bootstrap_custody, + fleet_r2_bucket_admin_credential, } import gunbc.auth.access_token_source { resolve_access_token, @@ -149,21 +153,45 @@ fn classify_r2_bucket_ensure(desired: R2OriginStanding, read: R2BucketRead) -> U // ── THE EFFECTFUL HALF ─────────────────────────────────────────────────────────────────────────── type R2EnsureCredentials - = R2EnsureCredentialsReady { account_id: CloudflareAccountId, bootstrap: Secret } + = R2EnsureCredentialsReady { account_id: CloudflareAccountId, admin: Secret } | R2EnsureCredentialsRefused { cause: NonEmptyStr } -// THE SAME CUSTODY LADDER THE MINT RUNS (gunbc.cloudflare.r2_token_mint_run r2_mint_bootstrap), -// WITHOUT THE MINT: account standing, then the pinned bootstrap locus, then the exact-version read -// through the run's resolved GCP identity. Each absence refuses before any Cloudflare call. +// THE BUCKET CALLS ARE SIGNED BY THE BUCKET-ADMIN TOKEN AND BY NOTHING ELSE. The bootstrap token's one +// power is minting; it has no R2 grant (the first live run of this entry read 403/10000 through it) +// and is deliberately not widened to get one (operator ruling, 2026-09-19). So the locus this entry +// reads is gunbc.cloudflare.r2_origin fleet_r2_bucket_admin_credential, and an unminted credential +// refuses here, naming its mint entry, before any network call. PURE, so the arm is witnessable. +type R2EnsureSigningLocus + = R2EnsureSigningPinned { locus: SecretRef } + | R2EnsureSigningRefused { cause: NonEmptyStr } + +fn r2_ensure_signing_locus(credential: OriginCredentialStanding) -> R2EnsureSigningLocus { + match credential { + OriginCredentialPinned { access_key_id: _, token_value: locus } => R2EnsureSigningPinned { locus: locus } + OriginCredentialUnminted { mint_entry: entry } => + R2EnsureSigningRefused { + cause: join([ + "THE BUCKET-ADMIN TOKEN IS NOT MINTED, NOTHING CALLED: run ", entry.module_path, " ", entry.decl_name, + " (fleet-converge mode r2_bucket_admin_mint) and pin its stored version in gunbc.cloudflare.r2_origin fleet_r2_bucket_admin_credential; this entry never signs with the bootstrap token", + ], "") as NonEmptyStr + } + OriginCredentialMintEntryUnbuilt { secret_id: secret_id } => + R2EnsureSigningRefused { + cause: concat("THE BUCKET-ADMIN TOKEN HAS NO MINT ENTRY, NOTHING CALLED: its custody locus is ", secret_id as String) as NonEmptyStr + } + } +} + +// THE CUSTODY LADDER: account standing, then the pinned bucket-admin locus, then the exact-version +// read through the run's resolved GCP identity. Each absence refuses before any Cloudflare call. fn r2_ensure_credentials() -> R2EnsureCredentials uses net: Network { match fleet_cloudflare_account_standing() { CloudflareAccountUnallocated { company: _, intervention: intervention } => R2EnsureCredentialsRefused { cause: concat("NO CLOUDFLARE ACCOUNT ID: ", intervention.instruction as String) as NonEmptyStr } CloudflareAccountAllocated { company: _, account_id: account_id } => - match fleet_cloudflare_bootstrap_custody() { - BootstrapCustodyUnrecorded { intervention: intervention } => - R2EnsureCredentialsRefused { cause: concat("NO BOOTSTRAP TOKEN IN CUSTODY: ", intervention.instruction as String) as NonEmptyStr } - BootstrapCustodyPinned { locus: locus } => + match r2_ensure_signing_locus(credential: fleet_r2_bucket_admin_credential()) { + R2EnsureSigningRefused { cause: cause } => R2EnsureCredentialsRefused { cause: cause } + R2EnsureSigningPinned { locus: locus } => match resolve_access_token() { AccessTokenUpsertRequired { plan: plan } => R2EnsureCredentialsRefused { cause: concat("NO GCP ACCESS TOKEN: ", access_token_upsert_required_reason(plan: plan)) as NonEmptyStr } @@ -172,26 +200,26 @@ fn r2_ensure_credentials() -> R2EnsureCredentials uses net: Network { AccessTokenReady { token: gcp_token } => match fetch_secret_ref_credential_with_token(secret_ref: locus, access_token: gcp_token) { SecretCredentialAccessUpsertRequired { plan: _ } => - R2EnsureCredentialsRefused { cause: "THE BOOTSTRAP TOKEN COULD NOT BE READ: the access ensure requires an interactive authentication this run cannot perform" as NonEmptyStr } + R2EnsureCredentialsRefused { cause: "THE BUCKET-ADMIN TOKEN COULD NOT BE READ: the access ensure requires an interactive authentication this run cannot perform" as NonEmptyStr } SecretCredentialFetchRefused { reason: reason } => - R2EnsureCredentialsRefused { cause: concat("THE BOOTSTRAP TOKEN COULD NOT BE READ: ", reason as String) as NonEmptyStr } + R2EnsureCredentialsRefused { cause: concat("THE BUCKET-ADMIN TOKEN COULD NOT BE READ: ", reason as String) as NonEmptyStr } SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => - R2EnsureCredentialsRefused { cause: "THE BOOTSTRAP TOKEN COULD NOT BE READ: the access response did not decode" as NonEmptyStr } + R2EnsureCredentialsRefused { cause: "THE BUCKET-ADMIN TOKEN COULD NOT BE READ: the access response did not decode" as NonEmptyStr } SecretCredentialResolvedVersionMismatch { requested: requested, resolved: resolved } => R2EnsureCredentialsRefused { - cause: join(["THE BOOTSTRAP READ ANSWERED ABOUT ANOTHER VERSION: asked for ", requested, ", answered about ", resolved], "") as NonEmptyStr + cause: join(["THE BUCKET-ADMIN READ ANSWERED ABOUT ANOTHER VERSION: asked for ", requested, ", answered about ", resolved], "") as NonEmptyStr } - SecretCredentialReady { credential: bootstrap, resolved_version: _ } => - R2EnsureCredentialsReady { account_id: account_id, bootstrap: bootstrap } + SecretCredentialReady { credential: admin, resolved_version: _ } => + R2EnsureCredentialsReady { account_id: account_id, admin: admin } } } } } } -fn read_r2_bucket(bootstrap: Secret, account_id: CloudflareAccountId, bucket_name: NonEmptyStr) -> R2BucketRead uses net: Network { +fn read_r2_bucket(admin: Secret, account_id: CloudflareAccountId, bucket_name: NonEmptyStr) -> R2BucketRead uses net: Network { let got = cloudflare.R2Buckets.Get( - auth_token: bootstrap, + auth_token: admin, account_id: account_id as String, bucket_name: bucket_name as String, ) @@ -208,20 +236,15 @@ type R2PurposeEnsureOutcome { held: Bool } -fn purpose_line(purpose: BucketPurpose, verdict: String, decision: String, detail: String) -> String { - join([bucket_purpose_key(purpose: purpose), verdict, decision, detail], "\t") +fn purpose_line(purpose: BucketPurpose, classified: UpsertClassification, detail: String) -> String { + join([ + bucket_purpose_key(purpose: purpose), + observation_verdict_label(verdict: classified.verdict), + upsert_decision_label(decision: classified.decision), + detail, + ], "\t") } -fn observation_verdict_label(verdict: ObservationVerdict) -> String { - match verdict { - Converged => "converged" - Absent => "absent" - Drifted => "drifted" - Conflict => "conflict" - Inaccessible => "inaccessible" - UnknownRefused => "unknown-refused" - } -} // THE CREATE IS NEVER ITS OWN EVIDENCE. Its response is discarded for classification purposes; a // second Get must answer Converged. A Create whose outcome is unknown (transport or 5xx) is followed @@ -229,18 +252,19 @@ fn observation_verdict_label(verdict: ObservationVerdict) -> String { fn apply_r2_bucket_create( purpose: BucketPurpose, desired: R2OriginStanding, - bootstrap: Secret, + admin: Secret, account_id: CloudflareAccountId, + observed: UpsertClassification, plan: R2BucketCreatePlan, ) -> R2PurposeEnsureOutcome uses net: Network { let created = cloudflare.R2Buckets.Create( - auth_token: bootstrap, + auth_token: admin, account_id: account_id as String, name: plan.bucket_name as String, ) let readback = classify_r2_bucket_ensure( desired: desired, - read: read_r2_bucket(bootstrap: bootstrap, account_id: account_id, bucket_name: plan.bucket_name), + read: read_r2_bucket(admin: admin, account_id: account_id, bucket_name: plan.bucket_name), ) let create_status = match created.outcome { RestOk => "create ok" @@ -251,17 +275,17 @@ fn apply_r2_bucket_create( match readback.decision { Noop => R2PurposeEnsureOutcome { - line: purpose_line(purpose: purpose, verdict: "absent", decision: "apply", detail: concat(create_status, "; readback converged")), + line: purpose_line(purpose: purpose, classified: observed, detail: concat(create_status, "; readback converged")), held: true, } Apply { plan: _ } => R2PurposeEnsureOutcome { - line: purpose_line(purpose: purpose, verdict: "absent", decision: "apply", detail: concat(create_status, "; READBACK STILL ABSENT")), + line: purpose_line(purpose: purpose, classified: observed, detail: concat(create_status, "; READBACK STILL ABSENT")), held: false, } Refuse { reason: reason } => R2PurposeEnsureOutcome { - line: purpose_line(purpose: purpose, verdict: "absent", decision: "apply", detail: join([create_status, "; READBACK REFUSED: ", reason as String], "")), + line: purpose_line(purpose: purpose, classified: observed, detail: join([create_status, "; READBACK REFUSED: ", reason as String], "")), held: false, } } @@ -269,7 +293,7 @@ fn apply_r2_bucket_create( fn ensure_r2_purpose_bucket( purpose: BucketPurpose, - bootstrap: Secret, + admin: Secret, account_id: CloudflareAccountId, ) -> R2PurposeEnsureOutcome uses net: Network { let desired = bucket_purpose_origin_standing(purpose: purpose) @@ -280,27 +304,31 @@ fn ensure_r2_purpose_bucket( match name { Absent => R2PurposeEnsureOutcome { - line: purpose_line(purpose: purpose, verdict: "unknown-refused", decision: "refuse", detail: "no bucket allocated"), + line: purpose_line( + purpose: purpose, + classified: r2_ensure_refuse(verdict: UnknownRefused, reason: "no bucket allocated"), + detail: "no bucket allocated", + ), held: false, } Present { value: bucket_name } => { let classified = classify_r2_bucket_ensure( desired: desired, - read: read_r2_bucket(bootstrap: bootstrap, account_id: account_id, bucket_name: bucket_name), + read: read_r2_bucket(admin: admin, account_id: account_id, bucket_name: bucket_name), ) match classified.decision { Noop => R2PurposeEnsureOutcome { - line: purpose_line(purpose: purpose, verdict: observation_verdict_label(verdict: classified.verdict), decision: "noop", detail: bucket_name as String), + line: purpose_line(purpose: purpose, classified: classified, detail: bucket_name as String), held: true, } Refuse { reason: reason } => R2PurposeEnsureOutcome { - line: purpose_line(purpose: purpose, verdict: observation_verdict_label(verdict: classified.verdict), decision: "refuse", detail: reason as String), + line: purpose_line(purpose: purpose, classified: classified, detail: reason as String), held: false, } Apply { plan: plan } => - apply_r2_bucket_create(purpose: purpose, desired: desired, bootstrap: bootstrap, account_id: account_id, plan: plan) + apply_r2_bucket_create(purpose: purpose, desired: desired, admin: admin, account_id: account_id, observed: classified, plan: plan) } } } @@ -316,10 +344,10 @@ data r2_bucket_ensure_receipt_path: String = "target/r2-bucket-ensure-receipt.tx fn ensure() -> ProcessExit uses net: Network { match r2_ensure_credentials() { R2EnsureCredentialsRefused { cause: cause } => exit_failure(reason: cause as String) - R2EnsureCredentialsReady { account_id: account_id, bootstrap: bootstrap } => { + R2EnsureCredentialsReady { account_id: account_id, admin: admin } => { let outcomes = map( gunbc_fleet_r2_bucket_allocations, - row => ensure_r2_purpose_bucket(purpose: row.purpose, bootstrap: bootstrap, account_id: account_id), + row => ensure_r2_purpose_bucket(purpose: row.purpose, admin: admin, account_id: account_id), ) let text = join(map(outcomes, o => o.line), "\n") let written = Filesystem.Write(path: r2_bucket_ensure_receipt_path, content: text) diff --git a/dag/gunbc/cloudflare/r2_mint_secret_access.dag b/dag/gunbc/cloudflare/r2_mint_secret_access.dag index dce11a6afa1..477d115d2e3 100644 --- a/dag/gunbc/cloudflare/r2_mint_secret_access.dag +++ b/dag/gunbc/cloudflare/r2_mint_secret_access.dag @@ -60,6 +60,7 @@ import extdeps.cloud.gcp.iam { import gunbc.cloudflare.r2_origin { cloudflare_bootstrap_custody_secret_id, fabric_durable_origin_write_secret_id, + cloudflare_r2_bucket_admin_secret_id, } import gunbc.secret_provision { fleet_secret_ref } import gunbc.auth.gcp_secret_access { @@ -130,3 +131,48 @@ fn r2_mint_write_version_add_access_converge() -> ProcessExit uses net: Network fn r2_mint_write_readback_access_converge() -> ProcessExit uses net: Network { secret_access_ensure_for(grant: r2_mint_write_readback_grant(), token_source: GcloudPrintToken) } + +// ── THE BUCKET-ADMIN CUSTODY CONTAINER: THE SAME THREE MINT CELLS, AND ONE CONSUMER MORE ───────── +// run_bucket_admin performs the same three secret effects as run_object_write against its own +// container -- observe it (viewer), add the minted value (versionAdder), read that exact version back +// (accessor) -- so the cells are the same three roles on a disjoint target. The accessor cell has a +// second consumer: gunbc.cloudflare.r2_bucket_ensure reads the pinned version to sign its bucket +// calls, which is the same AccessVersion on the same secret and therefore the same cell, not a fourth. +// The operator's container step is, as for the write token: +// +// gcloud secrets create cloudflare-r2-bucket-admin-token --project gunbai-secrets --replication-policy automatic +fn r2_bucket_admin_secret_target() -> SecretRef { + fleet_secret_ref(secret_id: cloudflare_r2_bucket_admin_secret_id) +} + +fn r2_bucket_admin_container_read_grant() -> SecretAccessGrant { + SecretAccessGrant { + target: r2_bucket_admin_secret_target(), + role: role_secretmanager_viewer, + condition: none, + } +} + +fn r2_bucket_admin_version_add_grant() -> SecretAccessGrant { + SecretAccessGrant { + target: r2_bucket_admin_secret_target(), + role: role_secretmanager_secret_version_adder, + condition: none, + } +} + +fn r2_bucket_admin_read_grant() -> SecretAccessGrant { + secret_accessor_grant(target: r2_bucket_admin_secret_target()) +} + +fn r2_bucket_admin_container_read_access_converge() -> ProcessExit uses net: Network { + secret_access_ensure_for(grant: r2_bucket_admin_container_read_grant(), token_source: GcloudPrintToken) +} + +fn r2_bucket_admin_version_add_access_converge() -> ProcessExit uses net: Network { + secret_access_ensure_for(grant: r2_bucket_admin_version_add_grant(), token_source: GcloudPrintToken) +} + +fn r2_bucket_admin_read_access_converge() -> ProcessExit uses net: Network { + secret_access_ensure_for(grant: r2_bucket_admin_read_grant(), token_source: GcloudPrintToken) +} diff --git a/dag/gunbc/cloudflare/r2_origin.dag b/dag/gunbc/cloudflare/r2_origin.dag index 5848411bc3c..424e5088e8f 100644 --- a/dag/gunbc/cloudflare/r2_origin.dag +++ b/dag/gunbc/cloudflare/r2_origin.dag @@ -386,3 +386,18 @@ fn bucket_purpose_origin_standing(purpose: BucketPurpose) -> R2OriginStanding { FabricBootOrigin {} => fabric_boot_origin_standing() } } + +// THE BUCKET-ADMIN CREDENTIAL IS THE FLEET'S, NOT A BUCKET'S. It is account-scoped (bucket creation is +// an account operation) and is spent only by gunbc.cloudflare.r2_bucket_ensure, so it hangs off no +// R2OriginBucket; it inhabits the same OriginCredentialStanding, and its custody locus is disjoint +// from the bootstrap's so the bucket converge never holds the key that mints. +data cloudflare_r2_bucket_admin_secret_id: NonEmptyStr = "cloudflare-r2-bucket-admin-token" + +fn fleet_r2_bucket_admin_credential() -> OriginCredentialStanding { + OriginCredentialUnminted { + mint_entry: decl_ref( + module_path: "gunbc.cloudflare.r2_token_mint_run", + decl_name: "run_bucket_admin", + ), + } +} diff --git a/dag/gunbc/cloudflare/r2_permission_group_observe.dag b/dag/gunbc/cloudflare/r2_permission_group_observe.dag index e9617005f76..f7c1a4b9949 100644 --- a/dag/gunbc/cloudflare/r2_permission_group_observe.dag +++ b/dag/gunbc/cloudflare/r2_permission_group_observe.dag @@ -199,6 +199,19 @@ fn r2_bucket_item_write_permission_group() -> PermissionGroupRef { PermissionGroupRef { id: cloudflare_r2_bucket_item_write_observed_permission_group_id } } +// THE BUCKET-MANAGEMENT GRANT, OBSERVED IN THE SAME LISTING. Creating a bucket and reading its +// metadata are ACCOUNT operations, so neither bucket-item group can carry them; of the observed +// account-scoped R2 groups, Storage Read cannot create and Storage Write is the least that can. This +// is the grant the bucket-admin token (gunbc.cloudflare.r2_token_mint R2 bucket-admin shape) holds, +// so the bootstrap token -- which mints and nothing else -- is never widened to sign bucket calls +// (operator ruling, 2026-09-19). Minimality is relative to THIS LISTING's recorded rows: a narrower +// account group, if Cloudflare publishes one, would be a new observation replacing this constant. +data cloudflare_r2_storage_write_observed_permission_group_id: NonEmptyStr = "bf7481a1826f439697cb59a20b22293e" + +fn r2_storage_write_permission_group() -> PermissionGroupRef { + PermissionGroupRef { id: cloudflare_r2_storage_write_observed_permission_group_id } +} + // THE OBSERVATION ROWS ARE A DECLARED FRONTIER, AND SAYING SO IS THE POINT RATHER THAN AN APOLOGY. // One of them is genuinely consumed: observed_permission_group_by_id is joined against // extdeps.cloudflare.r2's PINNED read id, which is a cross-authority check -- the pin and the diff --git a/dag/gunbc/cloudflare/r2_token_mint.dag b/dag/gunbc/cloudflare/r2_token_mint.dag index c4e97eef3d3..33fe4406f73 100644 --- a/dag/gunbc/cloudflare/r2_token_mint.dag +++ b/dag/gunbc/cloudflare/r2_token_mint.dag @@ -27,9 +27,11 @@ import extdeps.cloudflare.r2 { r2_object_read_create_request, r2_object_read_grant, r2_object_write_create_request, - r2_object_write_grant + r2_object_write_grant, + r2_account_storage_grant, + r2_account_storage_create_request } -import gunbc.cloudflare.r2_permission_group_observe { r2_bucket_item_write_permission_group } +import gunbc.cloudflare.r2_permission_group_observe { r2_bucket_item_write_permission_group, r2_storage_write_permission_group } import std.decl_ref { decl_ref } import std.dissolution { unbound_dissolution } import std.roster_frontier { frontier_row_decl } @@ -56,6 +58,13 @@ import std.roster_frontier { frontier_row_decl } // gunbc.secret_provision, and the write credential pinned in gunbc.cloudflare.r2_origin -- and on // nothing weaker. data cloudflare_r2_token_mint_frontier_rows: List = [ + frontier_row_decl( + ref: decl_ref(module_path: "gunbc.cloudflare.r2_token_mint", decl_name: "admit_r2_bucket_admin_api_mint"), + reason: "the bucket-admin mint is admitted by the pure fold and has an executing entry (gunbc.cloudflare.r2_token_mint_run run_bucket_admin) that has not yet performed AccountTokens.Create against the account", + dissolution: unbound_dissolution( + description: "dissolves when run_bucket_admin has executed with ExitSuccess, the token value is stored through gunbc.secret_provision, and gunbc.cloudflare.r2_origin fleet_r2_bucket_admin_credential is OriginCredentialPinned at the stored version; a witness that the admission fold answers Admitted does not fire this" + ) + ), frontier_row_decl( ref: decl_ref(module_path: "gunbc.cloudflare.r2_token_mint", decl_name: "admit_r2_object_write_api_mint"), reason: "the object-write mint is admitted by the pure fold and has an executing entry (gunbc.cloudflare.r2_token_mint_run run_object_write) that has not yet performed AccountTokens.Create against the account", @@ -181,3 +190,30 @@ fn admit_r2_object_write_api_mint( ) ) } + +// THE BUCKET-ADMIN SHAPE: ONE ACCOUNT-SCOPED STORAGE GRANT, SPENT ONLY BY THE BUCKET CONVERGE. +// gunbc.cloudflare.r2_bucket_ensure signs its bucket Get and Create with this token and with nothing +// else. It is a third minted token rather than a widened bootstrap because the bootstrap's one power +// is minting, and a credential that both mints and manages storage turns every bucket call into a +// use of the key that can issue any other key (operator ruling, 2026-09-19). It holds no object +// grant: it can create a bucket and cannot read or write what the origin stores in it. +fn cloudflare_r2_bucket_admin_token_shape() -> CloudflareAccountTokenShape { + CloudflareAccountTokenShape { + kind: AccountOwnedApiToken, + grants: [r2_account_storage_grant(storage_group: r2_storage_write_permission_group())], + acquisition: CreatedViaAccountTokensApi, + lifetime: NoExpiryUntilRevoked, + revocation: AccountApiTokensSurface + } +} + +fn admit_r2_bucket_admin_api_mint(name: String, account_id: String) -> AccountTokenApiMintStanding { + admit_account_token_api_mint( + shape: cloudflare_r2_bucket_admin_token_shape(), + request: r2_account_storage_create_request( + name: name, + storage_group: r2_storage_write_permission_group(), + account_id: account_id + ) + ) +} diff --git a/dag/gunbc/cloudflare/r2_token_mint_run.dag b/dag/gunbc/cloudflare/r2_token_mint_run.dag index 895d0a9e73f..a7b82d6802a 100644 --- a/dag/gunbc/cloudflare/r2_token_mint_run.dag +++ b/dag/gunbc/cloudflare/r2_token_mint_run.dag @@ -35,6 +35,7 @@ import extdeps.cloudflare.r2 { import gunbc.cloudflare.r2_token_mint { admit_r2_object_read_api_mint, admit_r2_object_write_api_mint, + admit_r2_bucket_admin_api_mint, cloudflare_r2_token_human_burden, } import gunbc.cloudflare.r2_origin { @@ -56,6 +57,8 @@ import gunbc.cloudflare.r2_origin { fleet_cloudflare_bootstrap_custody, fabric_durable_origin_read_secret_id, fabric_durable_origin_write_secret_id, + cloudflare_r2_bucket_admin_secret_id, + fleet_r2_bucket_admin_credential, OriginCredentialStanding, OriginCredentialPinned, OriginCredentialUnminted, @@ -158,9 +161,15 @@ type R2MintPreconditions // would have put the five-arm precondition ladder in two places, drifting on the first arm either // copy learned about. The profile is decided at the entry (run / run_object_write), which is the // dispatch-is-realization seam DESIGN section 3 names. +// +// THE BUCKET-ADMIN PROFILE IS THE SAME FOLD WITH AN ACCOUNT-SCOPED GRANT. It differs from the object +// profiles in exactly the three facts a profile carries -- grant, name, custody container -- and in +// one more the match below makes explicit: its grant names the ACCOUNT, so the bucket fields of the +// admitted origin do not enter its policy and its name does not carry a bucket. type R2OriginTokenProfile = R2OriginObjectRead | R2OriginObjectWrite + | R2AccountBucketAdmin // THE PROFILE'S NAME SUFFIX IS ITS OWN FACT, because two things need it and only one of them has a // bucket: the token NAME is bucket + suffix, and the receipt GLOB is prefix + wildcard + suffix, @@ -170,17 +179,27 @@ fn r2_origin_token_name_suffix(profile: R2OriginTokenProfile) -> String { match profile { R2OriginObjectRead => "-object-read" R2OriginObjectWrite => "-object-write" + R2AccountBucketAdmin => "-bucket-admin" } } +// THE ACCOUNT-SCOPED TOKEN'S NAME STEM. The object tokens are named for their bucket; the bucket-admin +// token manages every bucket and is named for the fleet, with the same suffix the receipt glob reads. +data r2_account_token_name_stem: String = "gunbc-fleet-r2" + fn r2_origin_token_name(profile: R2OriginTokenProfile, bucket: R2OriginBucket) -> String { - join([bucket.bucket_name as String, r2_origin_token_name_suffix(profile: profile)], "") + match profile { + R2AccountBucketAdmin => join([r2_account_token_name_stem, r2_origin_token_name_suffix(profile: profile)], "") + R2OriginObjectRead => join([bucket.bucket_name as String, r2_origin_token_name_suffix(profile: profile)], "") + R2OriginObjectWrite => join([bucket.bucket_name as String, r2_origin_token_name_suffix(profile: profile)], "") + } } fn r2_origin_token_secret_id(profile: R2OriginTokenProfile) -> NonEmptyStr { match profile { R2OriginObjectRead => fabric_durable_origin_read_secret_id R2OriginObjectWrite => fabric_durable_origin_write_secret_id + R2AccountBucketAdmin => cloudflare_r2_bucket_admin_secret_id } } @@ -188,6 +207,7 @@ fn r2_origin_token_credential(profile: R2OriginTokenProfile, bucket: R2OriginBuc match profile { R2OriginObjectRead => bucket.read_credential R2OriginObjectWrite => bucket.write_credential + R2AccountBucketAdmin => fleet_r2_bucket_admin_credential() } } @@ -205,6 +225,8 @@ fn admit_r2_origin_token_mint( R2OriginObjectWrite => admit_r2_object_write_api_mint( name: name, account_id: account_id, jurisdiction: jurisdiction, bucket_name: bucket_name) + R2AccountBucketAdmin => + admit_r2_bucket_admin_api_mint(name: name, account_id: account_id) } } @@ -917,6 +939,10 @@ fn run_object_write() -> ProcessExit uses net: Network { mint_origin_token_and_store(profile: R2OriginObjectWrite) } +fn run_bucket_admin() -> ProcessExit uses net: Network { + mint_origin_token_and_store(profile: R2AccountBucketAdmin) +} + fn origin_credential_already_pinned(profile: R2OriginTokenProfile, origin: R2OriginStanding) -> Bool { match origin { R2OriginUnallocated { purpose: _ } => false diff --git a/dag/gunbc/extdeps_scope_frontier.dag b/dag/gunbc/extdeps_scope_frontier.dag index ed99eb41d4f..d68285ccbbc 100644 --- a/dag/gunbc/extdeps_scope_frontier.dag +++ b/dag/gunbc/extdeps_scope_frontier.dag @@ -534,6 +534,7 @@ data scope_carrier_paths: List = [ "dag/extdeps/cloudflare/client_v4.dag", "dag/extdeps/cloudflare/account_api_tokens.dag", "dag/extdeps/cloudflare/r2.dag", + "dag/extdeps/cloudflare/r2_buckets.dag", "dag/extdeps/deepseek/deepseek_v4_1_flash.dag", "dag/extdeps/zhipu/glm_5_3.dag", "dag/extdeps/radixark/glm_5_3_nvfp4.dag", diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index 7a1f546eb34..717910f8ab5 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -19,7 +19,7 @@ import gunbc.fleet.org_actions_converge { org_actions_validation_receipt_path } import gunbc.cloudflare.r2_permission_group_observe { r2_mint_preflight_receipt_path } import gunbc.cloudflare.r2_bucket_ensure { r2_bucket_ensure_receipt_path } import gunbc.cloudflare.r2_token_mint_run { - r2_mint_receipt_glob_in, r2_mint_receipt_scope, R2MintReceiptAttemptScoped, R2OriginObjectWrite, + r2_mint_receipt_glob_in, r2_mint_receipt_scope, R2MintReceiptAttemptScoped, R2OriginObjectWrite, R2AccountBucketAdmin, } import gunbc.roadmap_execution_contract { CargoCapability } import gunbc.toolchain_home_standing { @@ -75,6 +75,7 @@ import gunbc.ci_spec { gunbc_ci_app_control_plane_converge_invoke, gunbc_ci_r2_mint_preflight_invoke, gunbc_ci_r2_bucket_ensure_invoke, + gunbc_ci_r2_bucket_admin_mint_invoke, gunbc_ci_r2_object_write_mint_invoke, gunbc_ci_runner_guest_image_observe_invoke, gunbc_ci_runner_guest_image_converge_invoke, @@ -189,6 +190,7 @@ type FleetConvergeWorkflowMode | RunnerPasswordSessionToolConverge | R2MintPreflight | R2BucketEnsure + | R2BucketAdminMint | R2ObjectWriteMint fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String { @@ -215,6 +217,7 @@ fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String RunnerPasswordSessionToolConverge => "runner_password_session_tool_converge" R2MintPreflight => "r2_mint_preflight" R2BucketEnsure => "r2_bucket_ensure" + R2BucketAdminMint => "r2_bucket_admin_mint" R2ObjectWriteMint => "r2_object_write_mint" } } @@ -240,7 +243,7 @@ fn fleet_converge_spark_target_description() -> String { ], "") } -data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2BucketEnsure, R2ObjectWriteMint] +data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkRuntimeImageProbe, DashboardDeploy, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2BucketEnsure, R2BucketAdminMint, R2ObjectWriteMint] // WHICH SCOPE A MODE SELECTS, WHERE IT SELECTS ONE AT ALL. // @@ -286,6 +289,7 @@ fn fleet_converge_mode_scope(mode: FleetConvergeWorkflowMode) -> FleetConvergeSc RunnerPasswordSessionToolConverge => none R2MintPreflight => none R2BucketEnsure => none + R2BucketAdminMint => none R2ObjectWriteMint => none } } @@ -1111,6 +1115,7 @@ fn fleet_converge_app_control_plane_step() -> Step { // gunbc.auth.access_token_source rather than interpolated into a command line. data fleet_converge_r2_mint_preflight_step_if: String = fleet_converge_mode_step_if(mode: R2MintPreflight) data fleet_converge_r2_bucket_ensure_step_if: String = fleet_converge_mode_step_if(mode: R2BucketEnsure) +data fleet_converge_r2_bucket_admin_mint_step_if: String = fleet_converge_mode_step_if(mode: R2BucketAdminMint) data fleet_converge_r2_object_write_mint_step_if: String = fleet_converge_mode_step_if(mode: R2ObjectWriteMint) fn fleet_converge_r2_mint_preflight_step() -> Step { @@ -1188,6 +1193,46 @@ fn fleet_converge_r2_bucket_ensure_receipt_upload_step() -> Step { } } +// THE BUCKET-ADMIN MINT IS THE OBJECT-WRITE MINT'S STEP AT ANOTHER PROFILE: same fold, same custody +// bracket, same attempt-scoped orphan receipt, a disjoint container. It precedes r2_bucket_ensure, +// which signs with the token this mints and refuses naming this mode until it is pinned. +fn fleet_converge_r2_bucket_admin_mint_step() -> Step { + RunStep { + name: Present { value: "R2 bucket-admin token mint (AccountTokens.Create + Secret Manager custody)" }, + id: Present { value: "r2_bucket_admin_mint" }, + run: gunbc_ci_r2_bucket_admin_mint_invoke(), + shell: none, + env: Present { value: [ + kv(key: "WIF_ACCESS_TOKEN", value: yaml_string(s: "${{ steps.wif_auth.outputs.access_token }}")), + ] }, + working_directory: none, + if_condition: Present { value: fleet_converge_r2_bucket_admin_mint_step_if }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_aux_step_timeout_minutes } + } +} + +fn fleet_converge_r2_bucket_admin_mint_receipt_upload_step() -> Step { + UsesStep { + name: Present { value: "Upload R2 bucket-admin mint receipt (token id + custody version resource; no secret)" }, + id: Present { value: "r2_bucket_admin_mint_receipt_upload" }, + uses: upload_artifact_action, + with: Present { value: [ + kv(key: "name", value: yaml_string(s: "r2-bucket-admin-mint-receipt")), + kv(key: "path", value: yaml_string(s: r2_mint_receipt_glob_in( + scope: fleet_converge_r2_mint_receipt_scope(), + profile: R2AccountBucketAdmin, + ))), + kv(key: "if-no-files-found", value: yaml_string(s: "warn")), + kv(key: "retention-days", value: yaml_int(n: 30)), + ] }, + env: none, + if_condition: Present { value: join(["always() && ", fleet_converge_r2_bucket_admin_mint_step_if], "") }, + continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_artifact_transfer_step_timeout_minutes } + } +} + fn fleet_converge_r2_object_write_mint_step() -> Step { RunStep { name: Present { value: "R2 object-write token mint (AccountTokens.Create + Secret Manager custody)" }, @@ -1584,6 +1629,8 @@ fn fleet_converge_job() -> Job { fleet_converge_app_control_plane_receipt_upload_step(), fleet_converge_r2_mint_preflight_step(), fleet_converge_r2_mint_preflight_receipt_upload_step(), + fleet_converge_r2_bucket_admin_mint_step(), + fleet_converge_r2_bucket_admin_mint_receipt_upload_step(), fleet_converge_r2_bucket_ensure_step(), fleet_converge_r2_bucket_ensure_receipt_upload_step(), fleet_converge_r2_object_write_mint_step(), diff --git a/dag/std/upsert_decision.dag b/dag/std/upsert_decision.dag index c29035deec0..d79c9f6bf14 100644 --- a/dag/std/upsert_decision.dag +++ b/dag/std/upsert_decision.dag @@ -26,10 +26,24 @@ type UpsertClassification

{ // their records and project here at the emit boundary; they do not store the label. Added for // gunbc.roadmap_dispatch_environment's capability receipt (review 44078). -fn upsert_decision_label(decision: UpsertDecision) -> String { +fn upsert_decision_label

(decision: UpsertDecision

) -> String { match decision { Noop => "noop" Apply { plan: _ } => "apply" Refuse { reason: _ } => "refuse" } } + +// THE ONE WIRE SPELLING OF AN ObservationVerdict, on the sum for the reason upsert_decision_label is: +// a receipt that names what was observed would otherwise re-spell the closed sum per consumer +// (DESIGN 3). First consumer: gunbc.cloudflare.r2_bucket_ensure's per-purpose receipt (review 68490). +fn observation_verdict_label(verdict: ObservationVerdict) -> String { + match verdict { + Converged => "converged" + Absent => "absent" + Drifted => "drifted" + Conflict => "conflict" + Inaccessible => "inaccessible" + UnknownRefused => "unknown-refused" + } +} diff --git a/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag b/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag index f286619187a..6b7ebc7de45 100644 --- a/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag +++ b/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag @@ -7,8 +7,8 @@ import std.upsert_decision { UpsertClassification, } import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused } -import extdeps.cloudflare.r2 { - R2JurisdictionDefault, R2JurisdictionEu, +import extdeps.cloudflare.r2 { R2JurisdictionDefault, R2JurisdictionEu } +import extdeps.cloudflare.r2_buckets { R2BucketResponse, R2BucketRead, R2BucketPresent, R2BucketAbsent, R2AccountNotEntitled, R2BucketReadRefused, classify_r2_bucket_get, @@ -18,6 +18,11 @@ import gunbc.cloudflare.r2_origin { R2OriginStanding, R2OriginAllocated, R2OriginUnallocated, R2OriginBucket, OriginCredentialMintEntryUnbuilt, + OriginCredentialUnminted, + OriginCredentialPinned, + fleet_r2_bucket_admin_credential, + cloudflare_bootstrap_custody_secret_id, + cloudflare_r2_bucket_admin_secret_id, bucket_purpose_origin_standing, gunbc_fleet_r2_bucket_allocations, } @@ -25,7 +30,11 @@ import gunbc.cloudflare.r2_bucket_ensure { R2BucketCreatePlan, classify_r2_bucket_ensure, r2_subscription_dashboard_step, + r2_ensure_signing_locus, + R2EnsureSigningPinned, + R2EnsureSigningRefused, } +import gunbc.secret_provision { fleet_secret_ref, secret_ref_pin_version } // THE WITNESS SUPPLIES THE READ AND RUNS THE REAL CLASSIFIERS. The two classifiers are the whole // decision surface: classify_r2_bucket_get turns an HTTP outcome into one of the four bucket facts, @@ -185,5 +194,29 @@ test fn every_allocated_purpose_converges_against_its_own_bucket() -> Bool { ) is_noop(c: c) }) - held.length() == 2 && filter(held, h => !h).length() == 0 + held.length() > 0 && filter(held, h => !h).length() == 0 +} + +// ── THE SIGNING CREDENTIAL: THE BUCKET-ADMIN TOKEN, NEVER THE BOOTSTRAP ──────────────────────── + +// The fleet's declared standing is unminted today, so the ensure refuses naming the mint entry and +// calls nothing. Red if the standing were silently pinned to the bootstrap locus. +test fn the_unminted_bucket_admin_credential_refuses_naming_its_mint_entry() -> Bool { + match r2_ensure_signing_locus(credential: fleet_r2_bucket_admin_credential()) { + R2EnsureSigningRefused { cause: cause } => string_contains(s: cause as String, pattern: "run_bucket_admin") + R2EnsureSigningPinned { locus: _ } => false + } +} + +test fn a_pinned_bucket_admin_credential_signs_with_its_own_locus() -> Bool { + let pinned = OriginCredentialPinned { + access_key_id: "witness-admin-token-id", + token_value: secret_ref_pin_version(ref: fleet_secret_ref(secret_id: cloudflare_r2_bucket_admin_secret_id), version: "1"), + } + match r2_ensure_signing_locus(credential: pinned) { + R2EnsureSigningPinned { locus: locus } => + (locus.secret as String) == (cloudflare_r2_bucket_admin_secret_id as String) + && (locus.secret as String) != (cloudflare_bootstrap_custody_secret_id as String) + R2EnsureSigningRefused { cause: _ } => false + } } diff --git a/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag b/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag index 0c8e594b276..5e0971b7cb7 100644 --- a/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag +++ b/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag @@ -10,6 +10,7 @@ import extdeps.vendor.cloudflare { cloudflare } import extdeps.cloudflare.r2 { R2JurisdictionDefault, r2_bucket_resource_name, + r2_account_resource_name, workers_r2_storage_bucket_item_read_permission_group_id, } import extdeps.cloud.gcp.secret_ref { SecretRef, HashPending, gcp_resource_names_this_version, secret_ref_version_resource } @@ -61,6 +62,7 @@ import gunbc.cloudflare.r2_origin { } import gunbc.cloudflare.r2_permission_group_observe { cloudflare_r2_bucket_item_write_observed_permission_group_id, + cloudflare_r2_storage_write_observed_permission_group_id, } import gunbc.cloudflare.r2_permission_group_observe { ObservedPermissionGroup, @@ -71,6 +73,7 @@ import gunbc.cloudflare.r2_token_mint_run { classify_r2_mint, R2OriginObjectRead, R2OriginObjectWrite, + R2AccountBucketAdmin, origin_credential_pin_is_exact, R2MintReady, R2MintBootstrapCustodyUnrecorded, @@ -697,3 +700,41 @@ test fn a_scoped_receipt_needs_a_directory_deeper_than_the_root() -> Bool { && scoped != root && string_contains(s: scoped, pattern: "34843927208-1/") } + +// THE BUCKET-ADMIN MINT IS ACCOUNT-SCOPED AND HOLDS ONLY THE OBSERVED STORAGE-WRITE GROUP. Red if the +// profile is routed to a bucket-item grant (it could not create a bucket), if it gains an object +// grant (it could read or write origin bytes), or if its resource names a bucket instead of the +// account (bucket creation has no bucket to scope to). +test fn r2_bucket_admin_mint_is_account_scoped_with_only_the_storage_write_group() -> Bool { + match classify_r2_mint( + profile: R2AccountBucketAdmin, + account: fleet_cloudflare_account_standing(), + custody: fleet_cloudflare_bootstrap_custody(), + origin: fabric_durable_origin_standing(), + container: ContainerAdmitted, + ) { + R2MintReady { account_id: _, bootstrap_locus: _, request: request } => + string_contains(s: request.name, pattern: "-bucket-admin") + && !string_contains(s: request.name, pattern: declared_bucket_name()) + && all(request.policies, p => + all(p.permission_groups, g => + (g.id as String) == (cloudflare_r2_storage_write_observed_permission_group_id as String)) + && p.permission_groups.length() == 1 + && match map_get(p.resources, r2_account_resource_name(account_id: fleet_cloudflare_account_id as String)) { + Present { value: scope } => scope == "*" + Absent => false + } + && match map_get(p.resources, r2_bucket_resource_name( + account_id: fleet_cloudflare_account_id as String, + jurisdiction: R2JurisdictionDefault, + bucket_name: declared_bucket_name())) { + Present { value: _ } => false + Absent => true + }) + R2MintBootstrapCustodyUnrecorded { intervention: _ } => false + R2MintAccountUnallocated { intervention: _ } => false + R2MintOriginUnallocated { purpose: _ } => false + R2MintAdmissionRefused { standing: _ } => false + R2MintCustodyContainerAbsent { intervention: _ } => false + } +} diff --git a/dag/test/claim/r2_mint_secret_access_witness_test.dag b/dag/test/claim/r2_mint_secret_access_witness_test.dag index 06bff9ffdf5..172375bc54d 100644 --- a/dag/test/claim/r2_mint_secret_access_witness_test.dag +++ b/dag/test/claim/r2_mint_secret_access_witness_test.dag @@ -23,9 +23,11 @@ import gunbc.cloudflare.r2_mint_secret_access { r2_mint_bootstrap_secret_target, r2_mint_write_secret_target, r2_mint_bootstrap_read_grant, r2_mint_write_container_read_grant, r2_mint_write_version_add_grant, r2_mint_write_readback_grant, + r2_bucket_admin_container_read_grant, r2_bucket_admin_version_add_grant, r2_bucket_admin_read_grant, } import gunbc.cloudflare.r2_origin { cloudflare_bootstrap_custody_secret_id, fabric_durable_origin_write_secret_id, + cloudflare_r2_bucket_admin_secret_id, } import std.types { String } import gunbc.gcp_estate_observation { fleet_cloud_convergence_sa_email } @@ -121,3 +123,24 @@ test fn none_of_the_mint_grants_are_conditioned() -> Bool { } } } + +// THE BUCKET-ADMIN CONTAINER GETS THE SAME THREE CELLS ON ITS OWN SECRET, AND NONE ON THE BOOTSTRAP'S. +// Red if a cell is dropped, widened, conditioned, or pointed at the bootstrap or write container -- +// the last would let the bucket converge's identity read the key that mints. +test fn the_bucket_admin_secret_gets_container_read_version_add_and_read_on_its_own_container() -> Bool { + let get = r2_bucket_admin_container_read_grant() + let add = r2_bucket_admin_version_add_grant() + let read = r2_bucket_admin_read_grant() + let id = cloudflare_r2_bucket_admin_secret_id as String + get.role == role_secretmanager_viewer + && add.role == role_secretmanager_secret_version_adder + && read.role == role_secretmanager_secret_accessor + && get.target.secret as String == id + && add.target.secret as String == id + && read.target.secret as String == id + && id != cloudflare_bootstrap_custody_secret_id as String + && id != fabric_durable_origin_write_secret_id as String + && match get.condition { Present { value: _ } => false Absent => true } + && match add.condition { Present { value: _ } => false Absent => true } + && match read.condition { Present { value: _ } => false Absent => true } +} From 328374f70082d2dd216ca0f0e1bf2b3fa11d0125 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 16:19:46 +0000 Subject: [PATCH 03/17] Regenerate fleet-converge.yml: r2_bucket_admin_mint mode Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/fleet-converge.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index b2ecabd2405..7c89991424f 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,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; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and refuses the kvm grant by name; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' vLLM units over the password session, workers before heads; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_bucket_ensure, r2_object_write_mint] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_bucket_ensure, r2_bucket_admin_mint, r2_object_write_mint] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -414,6 +414,25 @@ jobs: retention-days: 30 if: github.event.inputs.mode == 'r2_mint_preflight' timeout-minutes: 10 + - name: R2 bucket-admin token mint (AccountTokens.Create + Secret Manager custody) + id: r2_bucket_admin_mint + 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/cloudflare/r2_token_mint_run.dag --function run_bucket_admin + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 5 + - name: Upload R2 bucket-admin mint receipt (token id + custody version resource; no secret) + id: r2_bucket_admin_mint_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-admin-mint-receipt + path: /tmp/r2-mint-receipts/${{ github.run_id }}-${{ github.run_attempt }}/r2-*-bucket-admin-mint-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 10 - name: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" id: r2_bucket_ensure run: | From c463ee87f5ba90025b9356fe01b14e5ba7d90e20 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 17:26:48 +0000 Subject: [PATCH 04/17] Mint flow ensures its custody container; pin the executed bucket-admin mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit observe_r2_mint_custody_container now creates an absent container (automatic replication) as the running identity before any Cloudflare effect; an identity without the project-level secretmanager.secrets.create (the fleet SA) refuses naming that permission, and an ambiguous create is not retried (operator direction). Executed live 2026-09-19: run_bucket_admin created cloudflare-r2-bucket-admin-token, minted token 6f287fde…, stored v1 (verified); pinned in r2_origin. ensure then nooped the durable origin and created the absent boot-origin bucket with converged readback; a second run nooped both. Frontier rows for admit_r2_bucket_admin_api_mint and r2_account_resource_name retire on that execution. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/cloudflare/r2.dag | 8 +--- .../cloudflare/r2_mint_secret_access.dag | 21 ++++----- dag/gunbc/cloudflare/r2_origin.dag | 16 +++++-- dag/gunbc/cloudflare/r2_token_mint.dag | 8 +--- dag/gunbc/cloudflare/r2_token_mint_run.dag | 43 ++++++++++++++++--- ...oudflare_r2_bucket_ensure_witness_test.dag | 23 ++++++++-- 6 files changed, 80 insertions(+), 39 deletions(-) diff --git a/dag/extdeps/cloudflare/r2.dag b/dag/extdeps/cloudflare/r2.dag index 5d95b24fb26..e4e368c4820 100644 --- a/dag/extdeps/cloudflare/r2.dag +++ b/dag/extdeps/cloudflare/r2.dag @@ -616,13 +616,7 @@ data r2_no_such_bucket_error_code: NonEmptyStr = "10006" // the digest that cites it is reached only from the dead ProviderRouteObserved arm and the claim // witness, which is the unread-outside-witness condition the row already recorded. data cloudflare_r2_frontier_rows: List = [ - frontier_row_decl( - ref: decl_ref(module_path: "extdeps.cloudflare.r2", decl_name: "r2_account_resource_name"), - reason: "account IAM resource name is consumed by r2_account_storage_create_request, whose mint entry (gunbc.cloudflare.r2_token_mint_run run_bucket_admin) has not yet executed against the account", - dissolution: unbound_dissolution( - description: "dissolves when a gunbc declaration consumes r2_account_resource_name in an executing token policy or S3 IAM grant; a witness that the fold exists does not fire this" - ) - ), + frontier_row_decl( ref: decl_ref(module_path: "extdeps.cloudflare.r2", decl_name: "r2_tokens_doc_as_of"), reason: "as-of pin is unread outside the claim witness; ExternalAuthority has no as-of field", diff --git a/dag/gunbc/cloudflare/r2_mint_secret_access.dag b/dag/gunbc/cloudflare/r2_mint_secret_access.dag index 477d115d2e3..341fad50c5a 100644 --- a/dag/gunbc/cloudflare/r2_mint_secret_access.dag +++ b/dag/gunbc/cloudflare/r2_mint_secret_access.dag @@ -24,17 +24,16 @@ module gunbc.cloudflare.r2_mint_secret_access // and its trigger is grant member becoming a parameter of gunbc.auth.gcp_secret_access rather than a // resolution of one standing. // -// THE CONTAINER ITSELF IS PART OF THE SAME CEREMONY, AND IT IS NOT A CELL BECAUSE IT CANNOT BE ONE. +// THE CONTAINER IS NOT A CELL BECAUSE IT CANNOT BE ONE, AND IT IS NO LONGER A HUMAN STEP EITHER. // secretmanager.secrets.create is a PROJECT-level permission, and a SecretAccessGrant is one secret // and one role -- so no cell can carry it, and no per-secret cell can be written for a secret that -// does not exist. The mint therefore no longer creates its own container: gunbc.cloudflare.r2_token_mint_run -// observes it as a precondition before spending anything at Cloudflare, and refuses with the -// instruction below when it is absent. The operator's part is: -// -// gcloud secrets create cloudflare-r2-origin-write-token --project gunbai-secrets --replication-policy automatic -// -// followed by every converge entry in this module, one per cell. That ordering matters: a cell -// cannot be bound to a secret that is not there yet. +// does not exist. gunbc.cloudflare.r2_token_mint_run observe_r2_mint_custody_container therefore +// ENSURES the container before spending anything at Cloudflare: it observes it and, when absent, +// creates it as the running identity. An operator identity holds that permission and converges in +// one run; the fleet convergence identity is kept narrow and does not, so a federated run meeting an +// absent container refuses naming the missing project-level permission (operator direction, +// 2026-09-19). The cells below are then converged against the existing container, one entry per +// cell: a cell cannot be bound to a secret that is not there yet. // // THE ROSTER IS THE DECLARATIONS AND IS DELIBERATELY NOT RESTATED HERE. An earlier revision of this // annotation spelled the cells as a count, and the count was already wrong by the time the container @@ -138,9 +137,7 @@ fn r2_mint_write_readback_access_converge() -> ProcessExit uses net: Network { // (accessor) -- so the cells are the same three roles on a disjoint target. The accessor cell has a // second consumer: gunbc.cloudflare.r2_bucket_ensure reads the pinned version to sign its bucket // calls, which is the same AccessVersion on the same secret and therefore the same cell, not a fourth. -// The operator's container step is, as for the write token: -// -// gcloud secrets create cloudflare-r2-bucket-admin-token --project gunbai-secrets --replication-policy automatic +// Its container is ensured by run_bucket_admin itself, as the write token's is by run_object_write. fn r2_bucket_admin_secret_target() -> SecretRef { fleet_secret_ref(secret_id: cloudflare_r2_bucket_admin_secret_id) } diff --git a/dag/gunbc/cloudflare/r2_origin.dag b/dag/gunbc/cloudflare/r2_origin.dag index 424e5088e8f..ad0a55b78e4 100644 --- a/dag/gunbc/cloudflare/r2_origin.dag +++ b/dag/gunbc/cloudflare/r2_origin.dag @@ -393,11 +393,19 @@ fn bucket_purpose_origin_standing(purpose: BucketPurpose) -> R2OriginStanding { // from the bootstrap's so the bucket converge never holds the key that mints. data cloudflare_r2_bucket_admin_secret_id: NonEmptyStr = "cloudflare-r2-bucket-admin-token" +// PINNED ON THE EXECUTED MINT (2026-09-19): run_bucket_admin created this container, minted the token +// and stored its value at version 1, verified by exact-version readback; the receipt is on gunbc#11721. +// The token id is the S3 access key id and is not a secret; a rotation changes both rows together. +data cloudflare_r2_bucket_admin_secret_version: NonEmptyStr = "1" + +data cloudflare_r2_bucket_admin_access_key_id: NonEmptyStr = "6f287fdeeffcfd409f7a8f2450fa2d4f" + fn fleet_r2_bucket_admin_credential() -> OriginCredentialStanding { - OriginCredentialUnminted { - mint_entry: decl_ref( - module_path: "gunbc.cloudflare.r2_token_mint_run", - decl_name: "run_bucket_admin", + OriginCredentialPinned { + access_key_id: cloudflare_r2_bucket_admin_access_key_id, + token_value: secret_ref_pin_version( + ref: fleet_secret_ref(secret_id: cloudflare_r2_bucket_admin_secret_id), + version: cloudflare_r2_bucket_admin_secret_version, ), } } diff --git a/dag/gunbc/cloudflare/r2_token_mint.dag b/dag/gunbc/cloudflare/r2_token_mint.dag index 33fe4406f73..78905883149 100644 --- a/dag/gunbc/cloudflare/r2_token_mint.dag +++ b/dag/gunbc/cloudflare/r2_token_mint.dag @@ -58,13 +58,7 @@ import std.roster_frontier { frontier_row_decl } // gunbc.secret_provision, and the write credential pinned in gunbc.cloudflare.r2_origin -- and on // nothing weaker. data cloudflare_r2_token_mint_frontier_rows: List = [ - frontier_row_decl( - ref: decl_ref(module_path: "gunbc.cloudflare.r2_token_mint", decl_name: "admit_r2_bucket_admin_api_mint"), - reason: "the bucket-admin mint is admitted by the pure fold and has an executing entry (gunbc.cloudflare.r2_token_mint_run run_bucket_admin) that has not yet performed AccountTokens.Create against the account", - dissolution: unbound_dissolution( - description: "dissolves when run_bucket_admin has executed with ExitSuccess, the token value is stored through gunbc.secret_provision, and gunbc.cloudflare.r2_origin fleet_r2_bucket_admin_credential is OriginCredentialPinned at the stored version; a witness that the admission fold answers Admitted does not fire this" - ) - ), + frontier_row_decl( ref: decl_ref(module_path: "gunbc.cloudflare.r2_token_mint", decl_name: "admit_r2_object_write_api_mint"), reason: "the object-write mint is admitted by the pure fold and has an executing entry (gunbc.cloudflare.r2_token_mint_run run_object_write) that has not yet performed AccountTokens.Create against the account", diff --git a/dag/gunbc/cloudflare/r2_token_mint_run.dag b/dag/gunbc/cloudflare/r2_token_mint_run.dag index a7b82d6802a..b45112a4e5e 100644 --- a/dag/gunbc/cloudflare/r2_token_mint_run.dag +++ b/dag/gunbc/cloudflare/r2_token_mint_run.dag @@ -85,6 +85,7 @@ import gunbc.secret_provision_actuator { CredentialSnapshotTaken, CredentialSnapshotRefused, CredentialSnapshotRefusedResidueRemains, OwnedCredentialSnapshot, observe_secret_container, SecretContainerObserved, SecretContainerAbsent, SecretObservationRefused, + create_secret_container, SecretContainerCreated, SecretContainerCreationRefused, SecretContainerCreationOutcomeUnknown, ObservedSecretContainer, version_add_capability_for_observed_container, add_initial_version, VersionAddedAndVerified, VersionAdditionRefused, VersionAdditionOutcomeUnknown, VersionAddedButUnverified, @@ -282,6 +283,16 @@ fn r2_mint_container_admission(standing: R2MintCustodyContainerStanding) -> R2Mi } } +// THE CONTAINER IS ENSURED BY THE MINT FLOW: OBSERVED, AND CREATED WHEN ABSENT (operator direction, +// 2026-09-19). The create runs BEFORE the Cloudflare Create, so a refused or ambiguous create spends +// nothing at Cloudflare. Whether it succeeds is a fact about the RUNNING identity, not a choice made +// here: an operator identity holds project-wide secrets.create and converges in one run; the fleet +// convergence identity holds only per-secret cells and is refused, and the refusal names the +// project-level permission it lacks rather than widening that identity. An ambiguous create is not +// retried (gunbc.secret_provision_actuator SecretCreationOutcome: a later GET cannot establish +// absence). +data r2_mint_container_create_permission: NonEmptyStr = "secretmanager.secrets.create" + data r2_mint_custody_container_intervention_identity: String = "cloudflare-r2-origin-write-custody-container" // THIS BURDEN HAS A REAL DISCHARGE SITE, WHICH IS WHY IT IS NOT NoDischargeSiteModeled. The two @@ -296,9 +307,10 @@ fn r2_mint_custody_container_intervention(secret_id: NonEmptyStr, cause: NonEmpt subject: join(["the Secret Manager custody container ", secret_id as String], "") as NonEmptyStr, instruction: join([ "THE CUSTODY CONTAINER COULD NOT BE OBSERVED: ", cause as String, - " -- create the empty secret ", secret_id as String, + " -- the mint flow creates ", secret_id as String, " in ", fleet_secrets_gcp_project as String, - " from an operator gcloud session and converge the cells declared in gunbc.cloudflare.r2_mint_secret_access, then dispatch again. NOTHING WAS CREATED AT CLOUDFLARE.", + " itself when its identity holds ", r2_mint_container_create_permission as String, + "; run it as an identity that does, or resolve the stated cause, then dispatch again. NOTHING WAS CREATED AT CLOUDFLARE.", ], "") as NonEmptyStr, completion_evidence: DischargedAt { evidence: decl_ref( @@ -323,9 +335,30 @@ fn observe_r2_mint_custody_container( R2MintCustodyContainerUnobserved { intervention: r2_mint_custody_container_intervention(secret_id: secret_id, cause: cause), } - SecretContainerAbsent { project: _, secret_id: absent_id } => - R2MintCustodyContainerUnobserved { - intervention: r2_mint_custody_container_intervention(secret_id: secret_id, cause: concat("no custody container named ", concat(absent_id as String, " exists in the project; this run does not create it")) as NonEmptyStr), + SecretContainerAbsent { project: project, secret_id: _ } => + match create_secret_container(project: project, secret_id: secret_id, token: gcp_token) { + SecretContainerCreated { container: container } => + R2MintCustodyContainerPresent { container: container } + SecretContainerCreationRefused { cause: cause } => + R2MintCustodyContainerUnobserved { + intervention: r2_mint_custody_container_intervention( + secret_id: secret_id, + cause: join([ + "the container is absent and this run's identity could not create it (", + cause as String, + "); creating a secret needs the PROJECT-level permission ", + r2_mint_container_create_permission as String, + ", which the fleet convergence identity deliberately does not hold -- it carries per-secret cells only", + ], "") as NonEmptyStr, + ), + } + SecretContainerCreationOutcomeUnknown { cause: cause } => + R2MintCustodyContainerUnobserved { + intervention: r2_mint_custody_container_intervention( + secret_id: secret_id, + cause: concat("the container create's outcome is unknown and is not retried, because a later GET cannot establish absence: ", cause as String) as NonEmptyStr, + ), + } } SecretContainerObserved { container: container } => R2MintCustodyContainerPresent { container: container } diff --git a/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag b/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag index 6b7ebc7de45..9fa73327b30 100644 --- a/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag +++ b/dag/test/claim/cloudflare_r2_bucket_ensure_witness_test.dag @@ -1,6 +1,7 @@ module test.claim.cloudflare_r2_bucket_ensure import std.types { String, NonEmptyStr, Bool } +import std.decl_ref { decl_ref } import std.upsert_decision { ObservationVerdict, Converged, Absent, Conflict, Inaccessible, UnknownRefused, Noop, Apply, Refuse, @@ -199,15 +200,29 @@ test fn every_allocated_purpose_converges_against_its_own_bucket() -> Bool { // ── THE SIGNING CREDENTIAL: THE BUCKET-ADMIN TOKEN, NEVER THE BOOTSTRAP ──────────────────────── -// The fleet's declared standing is unminted today, so the ensure refuses naming the mint entry and -// calls nothing. Red if the standing were silently pinned to the bootstrap locus. -test fn the_unminted_bucket_admin_credential_refuses_naming_its_mint_entry() -> Bool { - match r2_ensure_signing_locus(credential: fleet_r2_bucket_admin_credential()) { +// An unminted credential refuses naming its mint entry and calls nothing. +test fn an_unminted_bucket_admin_credential_refuses_naming_its_mint_entry() -> Bool { + let unminted = OriginCredentialUnminted { + mint_entry: decl_ref(module_path: "gunbc.cloudflare.r2_token_mint_run", decl_name: "run_bucket_admin"), + } + match r2_ensure_signing_locus(credential: unminted) { R2EnsureSigningRefused { cause: cause } => string_contains(s: cause as String, pattern: "run_bucket_admin") R2EnsureSigningPinned { locus: _ } => false } } +// The fleet's real standing (pinned on the executed mint) signs with the bucket-admin locus and +// never the bootstrap's. Red if the pin were pointed at the bootstrap custody. +test fn the_fleet_bucket_admin_credential_signs_with_its_own_locus_not_the_bootstrap() -> Bool { + match r2_ensure_signing_locus(credential: fleet_r2_bucket_admin_credential()) { + R2EnsureSigningPinned { locus: locus } => + (locus.secret as String) == (cloudflare_r2_bucket_admin_secret_id as String) + && (locus.secret as String) != (cloudflare_bootstrap_custody_secret_id as String) + && (locus.version as String) != "latest" + R2EnsureSigningRefused { cause: _ } => false + } +} + test fn a_pinned_bucket_admin_credential_signs_with_its_own_locus() -> Bool { let pinned = OriginCredentialPinned { access_key_id: "witness-admin-token-id", From b91d37492ab2dbf9a7a3ad51f59984d50c6720d6 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 18:06:41 +0000 Subject: [PATCH 05/17] Name the container step for what it does: ensure, not observe (review 68550) observe_r2_mint_custody_container -> ensure_r2_mint_custody_container, ObserveCustodyContainer -> EnsureCustodyContainer, R2MintCustodyContainerUnobserved -> R2MintCustodyContainerNotEnsured, and the refusal texts with them: the step now creates an absent container, so the observation names were a meaning fork (DESIGN 3). Co-Authored-By: Claude Opus 5 (1M context) --- .../cloudflare/r2_mint_secret_access.dag | 2 +- dag/gunbc/cloudflare/r2_token_mint_run.dag | 30 +++++++++---------- ...dflare_r2_origin_mint_run_witness_test.dag | 4 +-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/dag/gunbc/cloudflare/r2_mint_secret_access.dag b/dag/gunbc/cloudflare/r2_mint_secret_access.dag index 341fad50c5a..441db4ca184 100644 --- a/dag/gunbc/cloudflare/r2_mint_secret_access.dag +++ b/dag/gunbc/cloudflare/r2_mint_secret_access.dag @@ -27,7 +27,7 @@ module gunbc.cloudflare.r2_mint_secret_access // THE CONTAINER IS NOT A CELL BECAUSE IT CANNOT BE ONE, AND IT IS NO LONGER A HUMAN STEP EITHER. // secretmanager.secrets.create is a PROJECT-level permission, and a SecretAccessGrant is one secret // and one role -- so no cell can carry it, and no per-secret cell can be written for a secret that -// does not exist. gunbc.cloudflare.r2_token_mint_run observe_r2_mint_custody_container therefore +// does not exist. gunbc.cloudflare.r2_token_mint_run ensure_r2_mint_custody_container therefore // ENSURES the container before spending anything at Cloudflare: it observes it and, when absent, // creates it as the running identity. An operator identity holds that permission and converges in // one run; the fleet convergence identity is kept narrow and does not, so a federated run meeting an diff --git a/dag/gunbc/cloudflare/r2_token_mint_run.dag b/dag/gunbc/cloudflare/r2_token_mint_run.dag index b45112a4e5e..9ac7e4674f5 100644 --- a/dag/gunbc/cloudflare/r2_token_mint_run.dag +++ b/dag/gunbc/cloudflare/r2_token_mint_run.dag @@ -239,7 +239,7 @@ fn admit_r2_origin_token_mint( type R2MintCustodyContainerStanding = R2MintCustodyContainerPresent { container: ObservedSecretContainer } | R2MintCustodyContainerNotObserved { reason: NonEmptyStr } - | R2MintCustodyContainerUnobserved { intervention: HumanIntervention } + | R2MintCustodyContainerNotEnsured { intervention: HumanIntervention } // THE PURE FOLD TAKES THE VERDICT, NOT THE SEAL, AND THAT IS WHAT KEEPS IT WITNESSABLE. // ObservedSecretContainer is sole_constructor, so no module but gunbc.secret_provision_actuator can @@ -267,9 +267,9 @@ type R2MintContainerAdmission // WHAT THE ENTRY ASKS THE BRACKET TO DO ABOUT THE CONTAINER. This is the caller's statement of its // own effects, not a switch: an entry that will add a Secret Manager version asks for the -// observation, and an entry that only reads the account's tokens says so and carries the reason. +// container to be ensured (observed, and created when absent), and an entry that only reads the account's tokens says so and carries the reason. type R2MintContainerRequirement - = ObserveCustodyContainer + = EnsureCustodyContainer | CustodyContainerNotNeeded { reason: NonEmptyStr } data r2_mint_container_not_required_reason: NonEmptyStr = "this entry reads the account's tokens through the Cloudflare bootstrap credential and writes no Secret Manager version, so the custody container is not its precondition" as NonEmptyStr @@ -278,7 +278,7 @@ fn r2_mint_container_admission(standing: R2MintCustodyContainerStanding) -> R2Mi match standing { R2MintCustodyContainerPresent { container: _ } => ContainerAdmitted R2MintCustodyContainerNotObserved { reason: reason } => ContainerNotRequired { reason: reason } - R2MintCustodyContainerUnobserved { intervention: intervention } => + R2MintCustodyContainerNotEnsured { intervention: intervention } => ContainerAbsent { intervention: intervention } } } @@ -306,7 +306,7 @@ fn r2_mint_custody_container_intervention(secret_id: NonEmptyStr, cause: NonEmpt frequency: FleetOnce, subject: join(["the Secret Manager custody container ", secret_id as String], "") as NonEmptyStr, instruction: join([ - "THE CUSTODY CONTAINER COULD NOT BE OBSERVED: ", cause as String, + "THE CUSTODY CONTAINER COULD NOT BE ENSURED: ", cause as String, " -- the mint flow creates ", secret_id as String, " in ", fleet_secrets_gcp_project as String, " itself when its identity holds ", r2_mint_container_create_permission as String, @@ -315,13 +315,13 @@ fn r2_mint_custody_container_intervention(secret_id: NonEmptyStr, cause: NonEmpt completion_evidence: DischargedAt { evidence: decl_ref( module_path: "gunbc.cloudflare.r2_token_mint_run", - decl_name: "observe_r2_mint_custody_container", + decl_name: "ensure_r2_mint_custody_container", ), }, } } -fn observe_r2_mint_custody_container( +fn ensure_r2_mint_custody_container( profile: R2OriginTokenProfile, gcp_token: Secret, ) -> R2MintCustodyContainerStanding uses net: Network { @@ -332,7 +332,7 @@ fn observe_r2_mint_custody_container( token: gcp_token, ) { SecretObservationRefused { cause: cause } => - R2MintCustodyContainerUnobserved { + R2MintCustodyContainerNotEnsured { intervention: r2_mint_custody_container_intervention(secret_id: secret_id, cause: cause), } SecretContainerAbsent { project: project, secret_id: _ } => @@ -340,7 +340,7 @@ fn observe_r2_mint_custody_container( SecretContainerCreated { container: container } => R2MintCustodyContainerPresent { container: container } SecretContainerCreationRefused { cause: cause } => - R2MintCustodyContainerUnobserved { + R2MintCustodyContainerNotEnsured { intervention: r2_mint_custody_container_intervention( secret_id: secret_id, cause: join([ @@ -353,7 +353,7 @@ fn observe_r2_mint_custody_container( ), } SecretContainerCreationOutcomeUnknown { cause: cause } => - R2MintCustodyContainerUnobserved { + R2MintCustodyContainerNotEnsured { intervention: r2_mint_custody_container_intervention( secret_id: secret_id, cause: concat("the container create's outcome is unknown and is not retried, because a later GET cannot establish absence: ", cause as String) as NonEmptyStr, @@ -385,12 +385,12 @@ fn observe_r2_mint_custody_container( // against a copy of the whole ladder would be the same fold twice, and the two would drift. // THE SEAL TRAVELS AS AN OPTIONAL BECAUSE ONLY SOME ENTRIES HAVE ONE, and the writer is where its // absence refuses. List and revoke legitimately carry none: they were never going to add a version. -// The mint path asks for the observation and therefore refuses on none at the point it would write. +// The mint path asks for the ensure and therefore refuses on none at the point it would write. fn observed_container_or_none(standing: R2MintCustodyContainerStanding) -> ObservedSecretContainer? { match standing { R2MintCustodyContainerPresent { container: container } => Present { value: container } R2MintCustodyContainerNotObserved { reason: _ } => none - R2MintCustodyContainerUnobserved { intervention: _ } => none + R2MintCustodyContainerNotEnsured { intervention: _ } => none } } @@ -511,7 +511,7 @@ fn r2_mint_bootstrap( AccessTokenReady { token: gcp_token } => { let container_standing = match requirement { CustodyContainerNotNeeded { reason: reason } => R2MintCustodyContainerNotObserved { reason: reason } - ObserveCustodyContainer => observe_r2_mint_custody_container(profile: profile, gcp_token: gcp_token) + EnsureCustodyContainer => ensure_r2_mint_custody_container(profile: profile, gcp_token: gcp_token) } match classify_r2_mint( profile: profile, @@ -1195,7 +1195,7 @@ fn mint_origin_token_and_store(profile: R2OriginTokenProfile) -> ProcessExit use "THE ORIGIN ALREADY PINS THIS CREDENTIAL TO AN EXACT VERSION, NOTHING CREATED: a second mint would leave a token on the account that no declaration names; rotate by editing the pin in gunbc.cloudflare.r2_origin first -- ", r2_origin_token_secret_id(profile: profile) as String)) } else { - match r2_mint_bootstrap(profile: profile, requirement: ObserveCustodyContainer) { + match r2_mint_bootstrap(profile: profile, requirement: EnsureCustodyContainer) { R2MintBootstrapRefused { cause: cause } => exit_failure(reason: concat(concat( cause as String, ", NOTHING CREATED || the dashboard ceremony this is waiting on: "), @@ -1210,7 +1210,7 @@ fn mint_origin_token_and_store(profile: R2OriginTokenProfile) -> ProcessExit use } => match container { Absent => - exit_failure(reason: "THE CUSTODY CONTAINER WAS NOT OBSERVED FOR A RUN THAT WOULD WRITE ONE, NOTHING CREATED: the mint asked for the observation and the bracket returned none, so this run cannot show the destination exists and will not spend a Create to find out") + exit_failure(reason: "THE CUSTODY CONTAINER WAS NOT ENSURED FOR A RUN THAT WOULD WRITE ONE, NOTHING CREATED: the mint asked for the ensure and the bracket returned none, so this run cannot show the destination exists and will not spend a Create to find out") Present { value: container } => match ensure_r2_mint_receipt_dir(scope: r2_mint_receipt_scope_for_this_run()) { R2MintReceiptDirRefused { cause: dir_cause } => diff --git a/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag b/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag index 5e0971b7cb7..212ceb25c81 100644 --- a/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag +++ b/dag/test/claim/cloudflare_r2_origin_mint_run_witness_test.dag @@ -85,7 +85,7 @@ import gunbc.cloudflare.r2_token_mint_run { r2_mint_custody_container_intervention, r2_orphan_receipt_content, NothingWasWritten, WriteOutcomeUnknown, VersionLandedUnverified, - ObserveCustodyContainer, CustodyContainerNotNeeded, + EnsureCustodyContainer, CustodyContainerNotNeeded, r2_mint_container_not_required_reason, r2_mint_receipt_path_in, r2_mint_receipt_glob_in, r2_mint_receipt_scope, r2_mint_receipt_dir_root, r2_mint_receipt_dir, @@ -620,7 +620,7 @@ test fn a_non_writing_entry_waives_the_container_with_a_stated_reason() -> Bool match CustodyContainerNotNeeded { reason: r2_mint_container_not_required_reason } { CustodyContainerNotNeeded { reason: reason } => string_contains(s: reason as String, pattern: "writes no Secret Manager version") - ObserveCustodyContainer => false + EnsureCustodyContainer => false } } From 0bb92b1ecd0ec88df447def2d73a6e5b9cc5b5a2 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 18:45:04 +0000 Subject: [PATCH 06/17] Cite cloudflare.R2Buckets in its new module (review 68571) Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/cloudflare/r2_bucket_ensure.dag | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dag/gunbc/cloudflare/r2_bucket_ensure.dag b/dag/gunbc/cloudflare/r2_bucket_ensure.dag index 277ce8f0195..cef15ea11b9 100644 --- a/dag/gunbc/cloudflare/r2_bucket_ensure.dag +++ b/dag/gunbc/cloudflare/r2_bucket_ensure.dag @@ -67,11 +67,11 @@ import gunbc.auth.secret_ref_credential { // (extdeps.cloudflare.r2 r2_get_started_subscription_reading, // cloudflare_account_subscription_rate_plan_reading), so there is no gated effect to model: an // unentitled account classifies Inaccessible and Refuses with the dashboard step. The account's -// entitlement is read as a byproduct of every bucket Get (R2BucketRead), so it costs no extra call +// entitlement is read as a byproduct of every bucket Get (extdeps.cloudflare.r2_buckets R2BucketRead), so it costs no extra call // and cannot be skipped by a purpose that happens to already have a bucket. // // JURISDICTION IS THE STANDING'S, AND A NON-DEFAULT ONE REFUSES BEFORE CREATE. The Create surface -// carries no jurisdiction header (extdeps.cloudflare.r2 cloudflare.R2Buckets), so a create for a +// carries no jurisdiction header (extdeps.cloudflare.r2_buckets cloudflare.R2Buckets), so a create for a // non-default standing would silently make a default bucket under that name -- and a jurisdiction // cannot be changed after creation. Both current purposes declare default. From c3b1c62a20ff09fc76466911032b496b9e4dd282 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 19 Sep 2026 19:25:51 +0000 Subject: [PATCH 07/17] Import the REST outcome constructors r2_bucket_ensure matches on (review 68589) Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/cloudflare/r2_bucket_ensure.dag | 1 + 1 file changed, 1 insertion(+) diff --git a/dag/gunbc/cloudflare/r2_bucket_ensure.dag b/dag/gunbc/cloudflare/r2_bucket_ensure.dag index cef15ea11b9..fbdfba162f5 100644 --- a/dag/gunbc/cloudflare/r2_bucket_ensure.dag +++ b/dag/gunbc/cloudflare/r2_bucket_ensure.dag @@ -16,6 +16,7 @@ import extdeps.cloudflare.r2 { R2Jurisdiction, R2JurisdictionDefault, r2_jurisdiction_wire, } +import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } import extdeps.cloudflare.r2_buckets import extdeps.cloudflare.r2_buckets { parse_r2_jurisdiction, From 854e773699fb77746c054a0f7ef404875187f92c Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 05:28:59 +0000 Subject: [PATCH 08/17] The two R2 modes declare no host SSH demand (fixes the post-merge compile) main #11736 made fleet_converge_mode_fleet_ssh_key_demand exhaustive over the mode sum; the merge that added R2BucketEnsure and R2BucketAdminMint left them without an arm, so the corpus did not resolve and no regeneration of fleet-converge.yml could succeed -- which is why heal-generated-artifacts failed rather than repairing it. Both reach api.cloudflare.com and secretmanager.googleapis.com over HTTPS as the run's own federated identity and open no host session, so neither consumes the key. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/fleet/fleet_converge_workflow.dag | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index 126a9ff1483..c6e9e471726 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -320,16 +320,19 @@ fn fleet_converge_any_mode_step_if(modes: List) -> St // WHICH MODES CONSUME HOST SSH AUTHORITY is declared per mode, exhaustively, so a new mode cannot // inherit the fleet key by omission. The fleet-converge job materializes the fleet key only for a // dispatch whose mode consumes it: an API-only read (the org credential observe and the org runner -// roster read, both of which reach GitHub over gh and open no host session) must not hold host SSH -// authority it never uses. Only those two modes are established API-only by this declaration; -// every other mode keeps the key it held before, which is the status quo rather than a finding -// that it needs it. +// roster read, both of which reach GitHub over gh and open no host session; and the two R2 modes, +// which reach api.cloudflare.com and secretmanager.googleapis.com over HTTPS as the run's own +// federated identity and likewise open none) must not hold host SSH authority it never uses. Those +// modes are established API-only by this declaration; every other mode keeps the key it held +// before, which is the status quo rather than a finding that it needs it. type FleetSshKeyDemand = FleetSshKeyConsumed | FleetSshKeyNotConsumed fn fleet_converge_mode_fleet_ssh_key_demand(mode: FleetConvergeWorkflowMode) -> FleetSshKeyDemand { match mode { OrgActionsObserve => FleetSshKeyNotConsumed OrgRunnerRosterObserve => FleetSshKeyNotConsumed + R2BucketEnsure => FleetSshKeyNotConsumed + R2BucketAdminMint => FleetSshKeyNotConsumed FullHostPlan => FleetSshKeyConsumed LaunchEnvironmentPlan => FleetSshKeyConsumed AllocationStorePlan => FleetSshKeyConsumed From 1cfe38c4f87f25c1bffbf00734d4645547157172 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 05:46:10 +0000 Subject: [PATCH 09/17] Restore main's two SSH-demand arms the merge dropped The merge kept our side of the hunk carrying fleet_converge_mode_fleet_ssh_key_demand, which lost main's ApprovalKeyringConverge and MtCollins1Boot arms, so the match stayed non-exhaustive after 854e773 fixed the other half. Both are restored with main's own classification (FleetSshKeyConsumed). Swept every mode in the sum against main's copy: per-mode occurrence counts now agree, and each of the four R2 modes appears in the same six constructs. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/fleet/fleet_converge_workflow.dag | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index c6e9e471726..001fc30403c 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -354,6 +354,8 @@ fn fleet_converge_mode_fleet_ssh_key_demand(mode: FleetConvergeWorkflowMode) -> RunnerPasswordSessionToolConverge => FleetSshKeyConsumed R2MintPreflight => FleetSshKeyConsumed R2ObjectWriteMint => FleetSshKeyConsumed + ApprovalKeyringConverge => FleetSshKeyConsumed + MtCollins1Boot => FleetSshKeyConsumed } } From 0ac0900144cea893d6dea8499e7fb1e2d7ab14de Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 20 Sep 2026 07:43:00 +0000 Subject: [PATCH 10/17] Regenerate fleet-converge.yml from the merged mode roster The main merge took the ours side of this generated file, dropping the r2_bucket_ensure and r2_bucket_admin_mint modes the model at this head declares. Regenerated through gunbc.instruments.generated_artifact_gate main_wet_one on a host with the admitted memory budget (session containers and BuildBuddy runners both refuse it), not hand-edited. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/fleet-converge.yml | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index a982b0a7dca..b69dbc3e2de 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,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; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and refuses the kvm grant by name; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' vLLM units over the password session, workers before heads; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, r2_bucket_ensure, r2_bucket_admin_mint] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -227,7 +227,7 @@ jobs: 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 }} - if: github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'launch_environment_plan' || github.event.inputs.mode == 'allocation_store_plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'app_control_plane_observe' || github.event.inputs.mode == 'microvm_host_converge' || github.event.inputs.mode == 'guest_image_observe' || github.event.inputs.mode == 'guest_image_converge' || github.event.inputs.mode == 'microvm_boot_probe' || github.event.inputs.mode == 'spark_grants' || github.event.inputs.mode == 'spark_bootstrap' || github.event.inputs.mode == 'spark_serving_apply' || github.event.inputs.mode == 'spark_runtime_image_probe' || github.event.inputs.mode == 'dashboard_deploy' || github.event.inputs.mode == 'rlm_launch_deployment_receipt' || github.event.inputs.mode == 'host_reset_return' || github.event.inputs.mode == 'runner_host_file_observe' || github.event.inputs.mode == 'runner_host_file_converge' || github.event.inputs.mode == 'runner_password_session_tool_converge' || github.event.inputs.mode == 'r2_mint_preflight' || github.event.inputs.mode == 'r2_object_write_mint' + if: github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'launch_environment_plan' || github.event.inputs.mode == 'allocation_store_plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'app_control_plane_observe' || github.event.inputs.mode == 'microvm_host_converge' || github.event.inputs.mode == 'guest_image_observe' || github.event.inputs.mode == 'guest_image_converge' || github.event.inputs.mode == 'microvm_boot_probe' || github.event.inputs.mode == 'spark_grants' || github.event.inputs.mode == 'spark_bootstrap' || github.event.inputs.mode == 'spark_serving_apply' || github.event.inputs.mode == 'spark_runtime_image_probe' || github.event.inputs.mode == 'dashboard_deploy' || github.event.inputs.mode == 'rlm_launch_deployment_receipt' || github.event.inputs.mode == 'host_reset_return' || github.event.inputs.mode == 'runner_host_file_observe' || github.event.inputs.mode == 'runner_host_file_converge' || github.event.inputs.mode == 'runner_password_session_tool_converge' || github.event.inputs.mode == 'r2_mint_preflight' || github.event.inputs.mode == 'r2_object_write_mint' || github.event.inputs.mode == 'approval_keyring_converge' || github.event.inputs.mode == 'mtcollins1_boot' timeout-minutes: 5 - name: Fleet converge plan (membership_reconcile → artifact) id: plan @@ -476,6 +476,45 @@ jobs: retention-days: 30 if: github.event.inputs.mode == 'r2_mint_preflight' timeout-minutes: 10 + - name: R2 bucket-admin token mint (AccountTokens.Create + Secret Manager custody) + id: r2_bucket_admin_mint + 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/cloudflare/r2_token_mint_run.dag --function run_bucket_admin + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 5 + - name: Upload R2 bucket-admin mint receipt (token id + custody version resource; no secret) + id: r2_bucket_admin_mint_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-admin-mint-receipt + path: /tmp/r2-mint-receipts/${{ github.run_id }}-${{ github.run_attempt }}/r2-*-bucket-admin-mint-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 10 + - name: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" + id: r2_bucket_ensure + 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/cloudflare/r2_bucket_ensure.dag --function ensure + cat "$ROOT/target/r2-bucket-ensure-receipt.txt" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 5 + - name: Upload R2 bucket ensure receipt + id: r2_bucket_ensure_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-ensure + path: target/r2-bucket-ensure-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 10 - name: R2 object-write token mint (AccountTokens.Create + Secret Manager custody) id: r2_object_write_mint run: | From eb24535473824eb82e2db51280f538988dbb572d Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 10:23:53 +0000 Subject: [PATCH 11/17] Derive the jurisdiction parse from the wire table (review 69065) parse_r2_jurisdiction re-spelled the five wire tokens r2_jurisdiction_wire already owns, so a corrected spelling in one would have left the other silently unrecognising. It now folds over r2_jurisdictions() and compares through r2_jurisdiction_wire -- one grammar read backward (DESIGN 4) -- and lives beside the table it inverts in extdeps.cloudflare.r2 rather than in the operation module. No declared-fork row is needed because the fork is gone. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/cloudflare/r2.dag | 22 ++++++++++++++++++++++ dag/extdeps/cloudflare/r2_buckets.dag | 17 +---------------- dag/gunbc/cloudflare/r2_bucket_ensure.dag | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/dag/extdeps/cloudflare/r2.dag b/dag/extdeps/cloudflare/r2.dag index e4e368c4820..d0f5739b369 100644 --- a/dag/extdeps/cloudflare/r2.dag +++ b/dag/extdeps/cloudflare/r2.dag @@ -172,6 +172,28 @@ fn r2_jurisdiction_wire(jurisdiction: R2Jurisdiction) -> String { } } +// THE INHABITANTS, SO THE INVERSE IS READ FROM THE SAME TABLE RATHER THAN RE-SPELLED. An exhaustive +// match owes an arm per inhabitant, so a sixth jurisdiction must be added here to compile, and the +// parse below then recognises it without a second edit. +fn r2_jurisdictions() -> List { + [R2JurisdictionDefault, R2JurisdictionEu, R2JurisdictionFedramp, R2JurisdictionFedrampHigh, R2JurisdictionUs] +} + +// ONE GRAMMAR READ BACKWARD (DESIGN section 4): the wire token is spelled once, by +// r2_jurisdiction_wire, and the parse selects from those same rows rather than carrying a second +// copy of the five literals. A corrected spelling therefore moves both directions together; a +// hand-written parse would have gone silently unrecognising instead. +fn parse_r2_jurisdiction(raw: String) -> R2Jurisdiction? { + fold( + r2_jurisdictions(), + init: none, + f: (acc, j) => match acc { + Present { value: found } => Present { value: found } + Absent => if r2_jurisdiction_wire(jurisdiction: j) == raw { Present { value: j } } else { none } + }, + ) +} + type R2S3Endpoint { account_id: String jurisdiction: R2Jurisdiction diff --git a/dag/extdeps/cloudflare/r2_buckets.dag b/dag/extdeps/cloudflare/r2_buckets.dag index 1c362b61478..b88d1ba4180 100644 --- a/dag/extdeps/cloudflare/r2_buckets.dag +++ b/dag/extdeps/cloudflare/r2_buckets.dag @@ -16,7 +16,7 @@ import extdeps.cloudflare.r2 { r2_get_bucket_authority, r2_error_codes_authority, R2Jurisdiction, - R2JurisdictionDefault, R2JurisdictionEu, R2JurisdictionFedramp, R2JurisdictionFedrampHigh, R2JurisdictionUs, + parse_r2_jurisdiction, r2_not_entitled_error_code, r2_no_such_bucket_error_code, } @@ -55,21 +55,6 @@ type R2BucketResponse { location: String? } -fn parse_r2_jurisdiction(raw: String) -> R2Jurisdiction? { - if raw == "default" { - Present { value: R2JurisdictionDefault } - } else if raw == "eu" { - Present { value: R2JurisdictionEu } - } else if raw == "fedramp" { - Present { value: R2JurisdictionFedramp } - } else if raw == "fedramp-high" { - Present { value: R2JurisdictionFedrampHigh } - } else if raw == "us" { - Present { value: R2JurisdictionUs } - } else { - none - } -} // THE JURISDICTION HEADER IS NOT CARRIED, AND THAT IS WHY BOTH OPERATIONS ARE DEFAULT-ONLY. Create // and Get take jurisdiction as the cf-r2-jurisdiction HEADER (r2_create_bucket_location_hint_reading), diff --git a/dag/gunbc/cloudflare/r2_bucket_ensure.dag b/dag/gunbc/cloudflare/r2_bucket_ensure.dag index fbdfba162f5..08bc9c99714 100644 --- a/dag/gunbc/cloudflare/r2_bucket_ensure.dag +++ b/dag/gunbc/cloudflare/r2_bucket_ensure.dag @@ -15,11 +15,11 @@ import std.upsert_decision { import extdeps.cloudflare.r2 { R2Jurisdiction, R2JurisdictionDefault, r2_jurisdiction_wire, + parse_r2_jurisdiction, } import extdeps.transports.rest { RestOk, RestStatusRefused, RestTransportRefused, RestBodyUndecodable } import extdeps.cloudflare.r2_buckets import extdeps.cloudflare.r2_buckets { - parse_r2_jurisdiction, R2BucketResponse, R2BucketRead, R2BucketPresent, R2BucketAbsent, R2AccountNotEntitled, R2BucketReadRefused, classify_r2_bucket_get, From e4db97c93f897da3b60cc1c9729dacb91e771d7c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 20 Sep 2026 16:24:21 +0000 Subject: [PATCH 12/17] Regenerate fleet-converge.yml with all three new modes after the main merge Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/fleet-converge.yml | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index d2a704e6871..8fc6b9f4f9a 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,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; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and refuses the kvm grant by name; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' pair vLLM units over the password session, workers before heads; spark_native_serving_apply relaunches group B's native four-rank arm as ONE BOUNDED TRANSACTION -- an all-host preflight that mutates nothing and refuses on any unresolved artifact from an earlier run, the incumbent UNIT preserved (plus a diagnostic inspect of the incumbent container -- the container itself cannot outlive its unit, because these run --rm in the foreground under systemd), the head applied BEFORE the workers so every rank joins the head's new rendezvous store, then a readback of the COMPLETE realization through each container's own inspect -- every field the create spec states, the effective --node-rank off that container's argv, and NCCL's typed transport verdict on that incarnation -- plus the head's front door answering with this arm's served model, and then commit or a rollback of the whole arm -- and it is a separate mode because it reloads a 328 GB arm and nobody should reach it by asking for the pair units; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, r2_bucket_ensure, r2_bucket_admin_mint] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -507,6 +507,45 @@ jobs: retention-days: 30 if: github.event.inputs.mode == 'r2_mint_preflight' timeout-minutes: 10 + - name: R2 bucket-admin token mint (AccountTokens.Create + Secret Manager custody) + id: r2_bucket_admin_mint + 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/cloudflare/r2_token_mint_run.dag --function run_bucket_admin + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 5 + - name: Upload R2 bucket-admin mint receipt (token id + custody version resource; no secret) + id: r2_bucket_admin_mint_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-admin-mint-receipt + path: /tmp/r2-mint-receipts/${{ github.run_id }}-${{ github.run_attempt }}/r2-*-bucket-admin-mint-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 10 + - name: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" + id: r2_bucket_ensure + 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/cloudflare/r2_bucket_ensure.dag --function ensure + cat "$ROOT/target/r2-bucket-ensure-receipt.txt" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 5 + - name: Upload R2 bucket ensure receipt + id: r2_bucket_ensure_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-ensure + path: target/r2-bucket-ensure-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 10 - name: R2 object-write token mint (AccountTokens.Create + Secret Manager custody) id: r2_object_write_mint run: | From b8167c35316b802818d377bed9a840cb37cd2b7e Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 20 Sep 2026 18:40:01 +0000 Subject: [PATCH 13/17] Regenerate fleet-converge.yml with the approval-broker mode from main Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/fleet-converge.yml | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index 6a492d398be..5ba47e63bab 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,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; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and refuses the kvm grant by name; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' pair vLLM units over the password session, workers before heads; spark_native_serving_apply relaunches group B's native four-rank arm as ONE BOUNDED TRANSACTION -- an all-host preflight that mutates nothing and refuses on any unresolved artifact from an earlier run, the incumbent UNIT preserved (plus a diagnostic inspect of the incumbent container -- the container itself cannot outlive its unit, because these run --rm in the foreground under systemd), the head applied BEFORE the workers so every rank joins the head's new rendezvous store, then a readback of the COMPLETE realization through each container's own inspect -- every field the create spec states, the effective --node-rank off that container's argv, and NCCL's typed transport verdict on that incarnation -- plus the head's front door answering with this arm's served model, and then commit or a rollback of the whole arm -- and it is a separate mode because it reloads a 328 GB arm and nobody should reach it by asking for the pair units; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, r2_bucket_ensure, r2_bucket_admin_mint] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -507,6 +507,45 @@ jobs: retention-days: 30 if: github.event.inputs.mode == 'r2_mint_preflight' timeout-minutes: 10 + - name: R2 bucket-admin token mint (AccountTokens.Create + Secret Manager custody) + id: r2_bucket_admin_mint + 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/cloudflare/r2_token_mint_run.dag --function run_bucket_admin + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 5 + - name: Upload R2 bucket-admin mint receipt (token id + custody version resource; no secret) + id: r2_bucket_admin_mint_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-admin-mint-receipt + path: /tmp/r2-mint-receipts/${{ github.run_id }}-${{ github.run_attempt }}/r2-*-bucket-admin-mint-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 10 + - name: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" + id: r2_bucket_ensure + 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/cloudflare/r2_bucket_ensure.dag --function ensure + cat "$ROOT/target/r2-bucket-ensure-receipt.txt" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 5 + - name: Upload R2 bucket ensure receipt + id: r2_bucket_ensure_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-ensure + path: target/r2-bucket-ensure-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 10 - name: R2 object-write token mint (AccountTokens.Create + Secret Manager custody) id: r2_object_write_mint run: | From 4a2012977a847877283f397bc6a471d6898f5a8e Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 20 Sep 2026 19:55:51 +0000 Subject: [PATCH 14/17] Regenerate fleet-converge.yml with the microvm-controller mode from main Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/fleet-converge.yml | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index f4a7469e1a0..dedaa9d6826 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,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; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; microvm_host_converge installs the cited Firecracker release on the selected host and reads the kvm grant back, refusing by name when the grant has not landed (the grant itself is applied by the full-host apply spine); guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' pair vLLM units over the password session, workers before heads; spark_native_serving_apply relaunches group B's native four-rank arm as ONE BOUNDED TRANSACTION -- an all-host preflight that mutates nothing and refuses on any unresolved artifact from an earlier run, the incumbent UNIT preserved (plus a diagnostic inspect of the incumbent container -- the container itself cannot outlive its unit, because these run --rm in the foreground under systemd), the head applied BEFORE the workers so every rank joins the head's new rendezvous store, then a readback of the COMPLETE realization through each container's own inspect -- every field the create spec states, the effective --node-rank off that container's argv, and NCCL's typed transport verdict on that incarnation -- plus the head's front door answering with this arm's served model, and then commit or a rollback of the whole arm -- and it is a separate mode because it reloads a 328 GB arm and nobody should reach it by asking for the pair units; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, microvm_controller_app_key_converge] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, microvm_controller_app_key_converge, r2_bucket_ensure, r2_bucket_admin_mint] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -507,6 +507,45 @@ jobs: retention-days: 30 if: github.event.inputs.mode == 'r2_mint_preflight' timeout-minutes: 10 + - name: R2 bucket-admin token mint (AccountTokens.Create + Secret Manager custody) + id: r2_bucket_admin_mint + 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/cloudflare/r2_token_mint_run.dag --function run_bucket_admin + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 5 + - name: Upload R2 bucket-admin mint receipt (token id + custody version resource; no secret) + id: r2_bucket_admin_mint_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-admin-mint-receipt + path: /tmp/r2-mint-receipts/${{ github.run_id }}-${{ github.run_attempt }}/r2-*-bucket-admin-mint-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 10 + - name: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" + id: r2_bucket_ensure + 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/cloudflare/r2_bucket_ensure.dag --function ensure + cat "$ROOT/target/r2-bucket-ensure-receipt.txt" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 5 + - name: Upload R2 bucket ensure receipt + id: r2_bucket_ensure_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-ensure + path: target/r2-bucket-ensure-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 10 - name: R2 object-write token mint (AccountTokens.Create + Secret Manager custody) id: r2_object_write_mint run: | From bc2608f3607bfaf0005c34876711205a6c8a5002 Mon Sep 17 00:00:00 2001 From: "gunbai-bot[bot]" <289086189+gunbai-bot[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:41:56 +0000 Subject: [PATCH 15/17] chore: regenerate drifted generated artifacts (ci auto-heal) Ledger-Repair-Judged: docs/design-rung-drops.md Heal-Candidate-Run: 35533900850 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 348c9538632..9e9de4dd75f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ Parked context (non-dispatchable): [compiler algorithm survey](docs/plans/compil The graph has sixteen lanes: SCM compatibility · namespace · P-derive · observation · placement · compute · CI cost · CI control · generated artefacts · v1 exit (four finish lines: compiler fixed point, interpreter deleted, products v1-free, zero hand-maintained Rust) · shell · roadmap runtime · fleet/hygiene · judgment · hermetic toolchain · compiler-guarantee (the DESIGN §4b ladder climbs; rung STATE lives in the guarantee claims carrier and is emitted, never restated in tickets — [gap analysis](docs/plans/compiler-guarantee-recovery-gap-analysis.md)). The three independent SCM R0 models, the separate P−1 evidence carrier, R1 compatibility-shape extraction, and P0 user-contract/landing spine are accepted; the operator-authored P1 proof-kernel node is active and fail-closed pending its first discriminating closing validation, while P2 and later product lanes remain parked. The toolchain pin model is an independent root. The P-derive receipt feeds the emitter fixed point, so the v1 chain nests under it. Compute owns the one contract everything else asks for work through — an exact subject in, a typed ending and the outputs it promised back out — and it sits ABOVE CI rather than inside it, because owning CI, converging the fleet, serving models and eventually judging changes are four consumers of one fabric, not four execution systems. It grounds on the signed realization spine rather than minting a second scheduler beside it. CI control owns who starts, queues and hands out required work and how its result reaches GitHub; CI cost owns how much computation that work performs. Fleet owns whether a merge to main becomes applied machine state and whether that question has one answer. Node fields define boundary, first slice, RED control, exclusions, owner, and handback. -**Focused view — the compute fabric + the infrastructure stabilisation lanes + fleet convergence from main + the harness, roadmap workflow and dashboard lanes.** 136 active deliverable(s) in other lanes are declared in the authority and hidden here; nothing is deleted or parked by focusing. Clear `roadmap_focus_selection` in `dag/gunbc/roadmap/roadmap_authority.dag` to restore the full page. 11 of the rows below are NOT lane deliverables — they are prerequisites pulled in from other lanes because lane work is blocked on them: commit-writer-admission, shell-gate-migration, shell-effectplan-to-bash, shell-typed-invocation, placement-compile-pool-envelope, placement-live-roster-preflight, observation-scoped-run-consumer, observation-scoped-run-seed-growth-justification, observation-scoped-run-seed-growth-justification-closing-contract, fleet-spark-host-enrollment, confidence-semantic-impact-query. Hidden lanes remain readable through their carriers: [docs/plans/namespace-cut-replacement-plan.md](docs/plans/namespace-cut-replacement-plan.md) · [docs/plans/dag-scm-design.md](docs/plans/dag-scm-design.md) · [docs/plans/native-scm-cas-fit-and-consumer-cut.md](docs/plans/native-scm-cas-fit-and-consumer-cut.md) · [docs/plans/namespace-unique-on-chain-operational-plan.md](docs/plans/namespace-unique-on-chain-operational-plan.md) · [docs/plans/v2-self-hosting.md](docs/plans/v2-self-hosting.md) · [src/v2/compiler/05_emit.dag](src/v2/compiler/05_emit.dag) · [docs/plans/progress-observation-design.md](docs/plans/progress-observation-design.md) · [src/v2/compiler/self_host/candidate_generation.dag](src/v2/compiler/self_host/candidate_generation.dag) · [src/v2/compiler/self_host/stage0_production_target.dag](src/v2/compiler/self_host/stage0_production_target.dag) · [src/v2/compiler/self_host/wet_receipt_enrollment.dag](src/v2/compiler/self_host/wet_receipt_enrollment.dag) · [src/v2/workflow/rust_crate_partition.dag](src/v2/workflow/rust_crate_partition.dag) · [src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag](src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag) · [dag/gunbc/v1/v1_deletion_plan.dag](dag/gunbc/v1/v1_deletion_plan.dag) · [dag/gunbc/stage0/stage0_rust_host_observation.dag](dag/gunbc/stage0/stage0_rust_host_observation.dag) · [docs/plans/witness-realization-plan.md](docs/plans/witness-realization-plan.md) · [src/v1/05_emit_rust.dag](src/v1/05_emit_rust.dag) · [dag/gunbc/v1/v1_interpreter_primitive_surface.dag](dag/gunbc/v1/v1_interpreter_primitive_surface.dag) · [docs/plans/shell-to-dag-residual-census-and-arc-completion.md](docs/plans/shell-to-dag-residual-census-and-arc-completion.md) · [docs/plans/roadmap-workspace-ux-plan.md](docs/plans/roadmap-workspace-ux-plan.md) · [docs/plans/hermetic-tool-provisioning-design.md](docs/plans/hermetic-tool-provisioning-design.md) · [dag/gunbc/capability_binding.dag](dag/gunbc/capability_binding.dag) · [dag/gunbc/roadmap/roadmap_component.dag](dag/gunbc/roadmap/roadmap_component.dag) · [dag/gunbc/design_document.dag](dag/gunbc/design_document.dag) · [docs/plans/compiler-guarantee-recovery-gap-analysis.md](docs/plans/compiler-guarantee-recovery-gap-analysis.md) · [docs/plans/cardinality-refinement.md](docs/plans/cardinality-refinement.md) · [docs/plans/typed-module-cross-run-materialization.md](docs/plans/typed-module-cross-run-materialization.md) · [dag/gunbc/roadmap/roadmap_authority.dag](dag/gunbc/roadmap/roadmap_authority.dag) · [dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag) · [dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag) · [dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag](dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag) · [dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag](dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag) · [dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag](dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag) · [dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag](dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag](dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag) · [dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag](dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag](dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag](dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag) · [dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag](dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag) · [dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag](dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag) · [dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag](dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag) · [dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag](dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag) · [dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag](dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag). +**Focused view — the compute fabric + the infrastructure stabilisation lanes + fleet convergence from main + the harness, roadmap workflow and dashboard lanes.** 143 active deliverable(s) in other lanes are declared in the authority and hidden here; nothing is deleted or parked by focusing. Clear `roadmap_focus_selection` in `dag/gunbc/roadmap/roadmap_authority.dag` to restore the full page. 11 of the rows below are NOT lane deliverables — they are prerequisites pulled in from other lanes because lane work is blocked on them: commit-writer-admission, shell-gate-migration, shell-effectplan-to-bash, shell-typed-invocation, placement-compile-pool-envelope, placement-live-roster-preflight, observation-scoped-run-consumer, observation-scoped-run-seed-growth-justification, observation-scoped-run-seed-growth-justification-closing-contract, fleet-spark-host-enrollment, confidence-semantic-impact-query. Hidden lanes remain readable through their carriers: [docs/plans/namespace-cut-replacement-plan.md](docs/plans/namespace-cut-replacement-plan.md) · [docs/plans/dag-scm-design.md](docs/plans/dag-scm-design.md) · [docs/plans/native-scm-cas-fit-and-consumer-cut.md](docs/plans/native-scm-cas-fit-and-consumer-cut.md) · [docs/plans/namespace-unique-on-chain-operational-plan.md](docs/plans/namespace-unique-on-chain-operational-plan.md) · [docs/plans/v2-self-hosting.md](docs/plans/v2-self-hosting.md) · [src/v2/compiler/05_emit.dag](src/v2/compiler/05_emit.dag) · [docs/plans/progress-observation-design.md](docs/plans/progress-observation-design.md) · [src/v2/compiler/self_host/candidate_generation.dag](src/v2/compiler/self_host/candidate_generation.dag) · [src/v2/compiler/self_host/stage0_production_target.dag](src/v2/compiler/self_host/stage0_production_target.dag) · [src/v2/compiler/self_host/wet_receipt_enrollment.dag](src/v2/compiler/self_host/wet_receipt_enrollment.dag) · [src/v2/workflow/rust_crate_partition.dag](src/v2/workflow/rust_crate_partition.dag) · [src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag](src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag) · [dag/gunbc/v1/v1_deletion_plan.dag](dag/gunbc/v1/v1_deletion_plan.dag) · [dag/gunbc/stage0/stage0_rust_host_observation.dag](dag/gunbc/stage0/stage0_rust_host_observation.dag) · [docs/plans/witness-realization-plan.md](docs/plans/witness-realization-plan.md) · [src/v1/05_emit_rust.dag](src/v1/05_emit_rust.dag) · [dag/gunbc/v1/v1_interpreter_primitive_surface.dag](dag/gunbc/v1/v1_interpreter_primitive_surface.dag) · [docs/plans/shell-to-dag-residual-census-and-arc-completion.md](docs/plans/shell-to-dag-residual-census-and-arc-completion.md) · [docs/plans/roadmap-workspace-ux-plan.md](docs/plans/roadmap-workspace-ux-plan.md) · [docs/plans/hermetic-tool-provisioning-design.md](docs/plans/hermetic-tool-provisioning-design.md) · [dag/gunbc/capability_binding.dag](dag/gunbc/capability_binding.dag) · [dag/gunbc/roadmap/roadmap_component.dag](dag/gunbc/roadmap/roadmap_component.dag) · [dag/gunbc/design_document.dag](dag/gunbc/design_document.dag) · [docs/plans/compiler-guarantee-recovery-gap-analysis.md](docs/plans/compiler-guarantee-recovery-gap-analysis.md) · [docs/plans/cardinality-refinement.md](docs/plans/cardinality-refinement.md) · [docs/plans/typed-module-cross-run-materialization.md](docs/plans/typed-module-cross-run-materialization.md) · [dag/gunbc/roadmap/roadmap_authority.dag](dag/gunbc/roadmap/roadmap_authority.dag) · [dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag) · [dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag) · [dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag](dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag) · [dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag](dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag) · [dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag](dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag) · [dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag](dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag](dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag) · [dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag](dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag](dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag](dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag) · [dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag](dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag) · [dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag](dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag) · [dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag](dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag) · [dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag](dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag) · [dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag](dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag). - [x] **Run the worker on DeepSeek V4 Flash with thinking on, straight at the engines, from briefs it can finish** — The harness posts to the Spark vLLM engines directly on the chat-completions shape with Think Max as the request switch, decodes inline reasoning client-side, replays it, and derives its deadline from the engine's own decode rate. A write brief names files small enough to hold whole and the decisions already made. Why: A day of runs with 0 files changed: briefs naming one file when the change spans its importers, a co-tenanted router killing every long step, a deadline that could not cover max_tokens, and agents deliberating modeling decisions a brief should have made. [authority](dag/gunbc/harness/harness_cli.dag) — ✓ signed off: operator curation 2026-09-06 (session 019iXPtAqzPaNBZbaaM5rxrY) - [x] **The roadmap commits, verifies, and opens the pull request itself once verification passes** — After the provider completes, the belt commits the attempt worktree, verifies the committed head in a detached checkout, admits publication only on a ValidationPassed receipt for that exact head, and the token-holding helper pushes the branch and opens the pull request, then answers with what GitHub shows. Why: Verification was checking out an uncommitted worktree's head, publication never consulted the verdict, and no operation existed to push or create a pull request, so every attempt stalled in the Agent segment and a human harvested by hand. [authority](dag/gunbc/roadmap/roadmap_publish_admission.dag) — ✓ signed off: operator curation 2026-09-06 (session 019iXPtAqzPaNBZbaaM5rxrY) From 6b50632f60aba8280dad4c94a36956d5dfa43957 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 20 Sep 2026 20:44:25 +0000 Subject: [PATCH 16/17] Regenerate ROADMAP.md, stale on this branch after the main merge The floor lane read the committed projection against the authority the merge brought in and refused. Regenerated through generated_artifact_gate main_wet_one. Co-Authored-By: Claude Opus 5 (1M context) --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 348c9538632..9e9de4dd75f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ Parked context (non-dispatchable): [compiler algorithm survey](docs/plans/compil The graph has sixteen lanes: SCM compatibility · namespace · P-derive · observation · placement · compute · CI cost · CI control · generated artefacts · v1 exit (four finish lines: compiler fixed point, interpreter deleted, products v1-free, zero hand-maintained Rust) · shell · roadmap runtime · fleet/hygiene · judgment · hermetic toolchain · compiler-guarantee (the DESIGN §4b ladder climbs; rung STATE lives in the guarantee claims carrier and is emitted, never restated in tickets — [gap analysis](docs/plans/compiler-guarantee-recovery-gap-analysis.md)). The three independent SCM R0 models, the separate P−1 evidence carrier, R1 compatibility-shape extraction, and P0 user-contract/landing spine are accepted; the operator-authored P1 proof-kernel node is active and fail-closed pending its first discriminating closing validation, while P2 and later product lanes remain parked. The toolchain pin model is an independent root. The P-derive receipt feeds the emitter fixed point, so the v1 chain nests under it. Compute owns the one contract everything else asks for work through — an exact subject in, a typed ending and the outputs it promised back out — and it sits ABOVE CI rather than inside it, because owning CI, converging the fleet, serving models and eventually judging changes are four consumers of one fabric, not four execution systems. It grounds on the signed realization spine rather than minting a second scheduler beside it. CI control owns who starts, queues and hands out required work and how its result reaches GitHub; CI cost owns how much computation that work performs. Fleet owns whether a merge to main becomes applied machine state and whether that question has one answer. Node fields define boundary, first slice, RED control, exclusions, owner, and handback. -**Focused view — the compute fabric + the infrastructure stabilisation lanes + fleet convergence from main + the harness, roadmap workflow and dashboard lanes.** 136 active deliverable(s) in other lanes are declared in the authority and hidden here; nothing is deleted or parked by focusing. Clear `roadmap_focus_selection` in `dag/gunbc/roadmap/roadmap_authority.dag` to restore the full page. 11 of the rows below are NOT lane deliverables — they are prerequisites pulled in from other lanes because lane work is blocked on them: commit-writer-admission, shell-gate-migration, shell-effectplan-to-bash, shell-typed-invocation, placement-compile-pool-envelope, placement-live-roster-preflight, observation-scoped-run-consumer, observation-scoped-run-seed-growth-justification, observation-scoped-run-seed-growth-justification-closing-contract, fleet-spark-host-enrollment, confidence-semantic-impact-query. Hidden lanes remain readable through their carriers: [docs/plans/namespace-cut-replacement-plan.md](docs/plans/namespace-cut-replacement-plan.md) · [docs/plans/dag-scm-design.md](docs/plans/dag-scm-design.md) · [docs/plans/native-scm-cas-fit-and-consumer-cut.md](docs/plans/native-scm-cas-fit-and-consumer-cut.md) · [docs/plans/namespace-unique-on-chain-operational-plan.md](docs/plans/namespace-unique-on-chain-operational-plan.md) · [docs/plans/v2-self-hosting.md](docs/plans/v2-self-hosting.md) · [src/v2/compiler/05_emit.dag](src/v2/compiler/05_emit.dag) · [docs/plans/progress-observation-design.md](docs/plans/progress-observation-design.md) · [src/v2/compiler/self_host/candidate_generation.dag](src/v2/compiler/self_host/candidate_generation.dag) · [src/v2/compiler/self_host/stage0_production_target.dag](src/v2/compiler/self_host/stage0_production_target.dag) · [src/v2/compiler/self_host/wet_receipt_enrollment.dag](src/v2/compiler/self_host/wet_receipt_enrollment.dag) · [src/v2/workflow/rust_crate_partition.dag](src/v2/workflow/rust_crate_partition.dag) · [src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag](src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag) · [dag/gunbc/v1/v1_deletion_plan.dag](dag/gunbc/v1/v1_deletion_plan.dag) · [dag/gunbc/stage0/stage0_rust_host_observation.dag](dag/gunbc/stage0/stage0_rust_host_observation.dag) · [docs/plans/witness-realization-plan.md](docs/plans/witness-realization-plan.md) · [src/v1/05_emit_rust.dag](src/v1/05_emit_rust.dag) · [dag/gunbc/v1/v1_interpreter_primitive_surface.dag](dag/gunbc/v1/v1_interpreter_primitive_surface.dag) · [docs/plans/shell-to-dag-residual-census-and-arc-completion.md](docs/plans/shell-to-dag-residual-census-and-arc-completion.md) · [docs/plans/roadmap-workspace-ux-plan.md](docs/plans/roadmap-workspace-ux-plan.md) · [docs/plans/hermetic-tool-provisioning-design.md](docs/plans/hermetic-tool-provisioning-design.md) · [dag/gunbc/capability_binding.dag](dag/gunbc/capability_binding.dag) · [dag/gunbc/roadmap/roadmap_component.dag](dag/gunbc/roadmap/roadmap_component.dag) · [dag/gunbc/design_document.dag](dag/gunbc/design_document.dag) · [docs/plans/compiler-guarantee-recovery-gap-analysis.md](docs/plans/compiler-guarantee-recovery-gap-analysis.md) · [docs/plans/cardinality-refinement.md](docs/plans/cardinality-refinement.md) · [docs/plans/typed-module-cross-run-materialization.md](docs/plans/typed-module-cross-run-materialization.md) · [dag/gunbc/roadmap/roadmap_authority.dag](dag/gunbc/roadmap/roadmap_authority.dag) · [dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag) · [dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag) · [dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag](dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag) · [dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag](dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag) · [dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag](dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag) · [dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag](dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag](dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag) · [dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag](dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag](dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag](dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag) · [dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag](dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag) · [dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag](dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag) · [dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag](dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag) · [dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag](dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag) · [dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag](dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag). +**Focused view — the compute fabric + the infrastructure stabilisation lanes + fleet convergence from main + the harness, roadmap workflow and dashboard lanes.** 143 active deliverable(s) in other lanes are declared in the authority and hidden here; nothing is deleted or parked by focusing. Clear `roadmap_focus_selection` in `dag/gunbc/roadmap/roadmap_authority.dag` to restore the full page. 11 of the rows below are NOT lane deliverables — they are prerequisites pulled in from other lanes because lane work is blocked on them: commit-writer-admission, shell-gate-migration, shell-effectplan-to-bash, shell-typed-invocation, placement-compile-pool-envelope, placement-live-roster-preflight, observation-scoped-run-consumer, observation-scoped-run-seed-growth-justification, observation-scoped-run-seed-growth-justification-closing-contract, fleet-spark-host-enrollment, confidence-semantic-impact-query. Hidden lanes remain readable through their carriers: [docs/plans/namespace-cut-replacement-plan.md](docs/plans/namespace-cut-replacement-plan.md) · [docs/plans/dag-scm-design.md](docs/plans/dag-scm-design.md) · [docs/plans/native-scm-cas-fit-and-consumer-cut.md](docs/plans/native-scm-cas-fit-and-consumer-cut.md) · [docs/plans/namespace-unique-on-chain-operational-plan.md](docs/plans/namespace-unique-on-chain-operational-plan.md) · [docs/plans/v2-self-hosting.md](docs/plans/v2-self-hosting.md) · [src/v2/compiler/05_emit.dag](src/v2/compiler/05_emit.dag) · [docs/plans/progress-observation-design.md](docs/plans/progress-observation-design.md) · [src/v2/compiler/self_host/candidate_generation.dag](src/v2/compiler/self_host/candidate_generation.dag) · [src/v2/compiler/self_host/stage0_production_target.dag](src/v2/compiler/self_host/stage0_production_target.dag) · [src/v2/compiler/self_host/wet_receipt_enrollment.dag](src/v2/compiler/self_host/wet_receipt_enrollment.dag) · [src/v2/workflow/rust_crate_partition.dag](src/v2/workflow/rust_crate_partition.dag) · [src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag](src/v2/compiler/self_host/v2_emitter_direct_rust_door_contract.dag) · [dag/gunbc/v1/v1_deletion_plan.dag](dag/gunbc/v1/v1_deletion_plan.dag) · [dag/gunbc/stage0/stage0_rust_host_observation.dag](dag/gunbc/stage0/stage0_rust_host_observation.dag) · [docs/plans/witness-realization-plan.md](docs/plans/witness-realization-plan.md) · [src/v1/05_emit_rust.dag](src/v1/05_emit_rust.dag) · [dag/gunbc/v1/v1_interpreter_primitive_surface.dag](dag/gunbc/v1/v1_interpreter_primitive_surface.dag) · [docs/plans/shell-to-dag-residual-census-and-arc-completion.md](docs/plans/shell-to-dag-residual-census-and-arc-completion.md) · [docs/plans/roadmap-workspace-ux-plan.md](docs/plans/roadmap-workspace-ux-plan.md) · [docs/plans/hermetic-tool-provisioning-design.md](docs/plans/hermetic-tool-provisioning-design.md) · [dag/gunbc/capability_binding.dag](dag/gunbc/capability_binding.dag) · [dag/gunbc/roadmap/roadmap_component.dag](dag/gunbc/roadmap/roadmap_component.dag) · [dag/gunbc/design_document.dag](dag/gunbc/design_document.dag) · [docs/plans/compiler-guarantee-recovery-gap-analysis.md](docs/plans/compiler-guarantee-recovery-gap-analysis.md) · [docs/plans/cardinality-refinement.md](docs/plans/cardinality-refinement.md) · [docs/plans/typed-module-cross-run-materialization.md](docs/plans/typed-module-cross-run-materialization.md) · [dag/gunbc/roadmap/roadmap_authority.dag](dag/gunbc/roadmap/roadmap_authority.dag) · [dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_reference_derived_producer_bounded_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_write_spine_acceptance_closing_contract_witness_test.dag) · [dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag](dag/test/claim/closing/2_scm_native_authority_program_closing_contract_witness_test.dag) · [dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag](dag/test/claim/closing/namespace_cross_file_provenance_closing_contract_witness_test.dag) · [dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag](dag/test/claim/closing/v1_materialization_kernel_closing_contract_witness_test.dag) · [dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag](dag/test/claim/closing/gate_refusal_reason_survives_wrapper_closing_contract_witness_test.dag) · [dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag](dag/test/claim/closing/toolchain_pin_model_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_motion_physical_qualification_closing_contract_witness_test.dag) · [dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag](dag/test/claim/closing/instrument_rendered_control_coverage_closing_contract_witness_test.dag) · [dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag](dag/test/claim/closing/discarded_bool_result_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag](dag/test/claim/closing/ladder_probe_corpus_closing_contract_witness_test.dag) · [dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag](dag/test/claim/closing/method_established_surface_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag](dag/test/claim/closing/call_label_and_surplus_wall_closing_contract_witness_test.dag) · [dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag](dag/test/claim/closing/declared_conformance_ground_fragment_closing_contract_witness_test.dag) · [dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag](dag/test/claim/closing/v2_self_grounding_frontier_closing_contract_witness_test.dag) · [dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag](dag/test/claim/closing/interpreter_primitive_roster_closing_contract_witness_test.dag) · [dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag](dag/test/claim/closing/unforgeable_construction_capability_closing_contract_witness_test.dag) · [dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag](dag/test/claim/closing/typed_store_cold_warm_measurement_closing_contract_witness_test.dag) · [dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag](dag/test/claim/closing/compiler_identity_invariant_under_corpus_commit_closing_contract_witness_test.dag). - [x] **Run the worker on DeepSeek V4 Flash with thinking on, straight at the engines, from briefs it can finish** — The harness posts to the Spark vLLM engines directly on the chat-completions shape with Think Max as the request switch, decodes inline reasoning client-side, replays it, and derives its deadline from the engine's own decode rate. A write brief names files small enough to hold whole and the decisions already made. Why: A day of runs with 0 files changed: briefs naming one file when the change spans its importers, a co-tenanted router killing every long step, a deadline that could not cover max_tokens, and agents deliberating modeling decisions a brief should have made. [authority](dag/gunbc/harness/harness_cli.dag) — ✓ signed off: operator curation 2026-09-06 (session 019iXPtAqzPaNBZbaaM5rxrY) - [x] **The roadmap commits, verifies, and opens the pull request itself once verification passes** — After the provider completes, the belt commits the attempt worktree, verifies the committed head in a detached checkout, admits publication only on a ValidationPassed receipt for that exact head, and the token-holding helper pushes the branch and opens the pull request, then answers with what GitHub shows. Why: Verification was checking out an uncommitted worktree's head, publication never consulted the verdict, and no operation existed to push or create a pull request, so every attempt stalled in the Agent segment and a human harvested by hand. [authority](dag/gunbc/roadmap/roadmap_publish_admission.dag) — ✓ signed off: operator curation 2026-09-06 (session 019iXPtAqzPaNBZbaaM5rxrY) From 91efb5242c85335e969ebee6667f9c23b0f8e49a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 21 Sep 2026 00:55:51 +0000 Subject: [PATCH 17/17] Regenerate fleet-converge.yml with the two R2 bucket modes (review 69429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regeneration landed earlier on this branch and was lost across the later main merges, so the emitted workflow carried neither the r2_bucket_ensure / r2_bucket_admin_mint dispatch options nor their four steps -- leaving gunbc.cloudflare.r2_bucket_ensure with no executing consumer (DESIGN §3c). Regenerated via tools.generated_artifact_gate main_wet; that fold rewrote only this path. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/fleet-converge.yml | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index c5e791505c7..48bb201f607 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,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; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; app_key_version_verify reads the gunbai-ci App private key at the EXACT Secret Manager version named by app_key_version, mints an installation token with it, and refuses unless GitHub accepts it and the key's rotation deadline has not passed -- no add, disable or destroy; microvm_host_converge installs the cited Firecracker release on the selected host and reads the kvm grant back, refusing by name when the grant has not landed (the grant itself is applied by the full-host apply spine); guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' pair vLLM units over the password session, workers before heads; spark_native_serving_apply relaunches group B's native four-rank arm as ONE BOUNDED TRANSACTION -- an all-host preflight that mutates nothing and refuses on any unresolved artifact from an earlier run, the incumbent UNIT preserved (plus a diagnostic inspect of the incumbent container -- the container itself cannot outlive its unit, because these run --rm in the foreground under systemd), the head applied BEFORE the workers so every rank joins the head's new rendezvous store, then a readback of the COMPLETE realization through each container's own inspect -- every field the create spec states, the effective --node-rank off that container's argv, and NCCL's typed transport verdict on that incarnation -- plus the head's front door answering with this arm's served model, and then commit or a rollback of the whole arm -- and it is a separate mode because it reloads a 328 GB arm and nobody should reach it by asking for the pair units; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, microvm_controller_app_key_converge, app_key_version_verify] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, dashboard_deploy, approval_broker_dark_install, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, mtcollins1_boot, mtcollins1_fan_observe, microvm_controller_app_key_converge, app_key_version_verify, r2_bucket_ensure, r2_bucket_admin_mint] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -579,6 +579,45 @@ jobs: retention-days: 30 if: github.event.inputs.mode == 'r2_mint_preflight' timeout-minutes: 10 + - name: R2 bucket-admin token mint (AccountTokens.Create + Secret Manager custody) + id: r2_bucket_admin_mint + 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/cloudflare/r2_token_mint_run.dag --function run_bucket_admin + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 5 + - name: Upload R2 bucket-admin mint receipt (token id + custody version resource; no secret) + id: r2_bucket_admin_mint_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-admin-mint-receipt + path: /tmp/r2-mint-receipts/${{ github.run_id }}-${{ github.run_attempt }}/r2-*-bucket-admin-mint-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_admin_mint' + timeout-minutes: 10 + - name: "R2 bucket ensure: entitlement + bucket existence per allocated purpose (create on absence, readback)" + id: r2_bucket_ensure + 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/cloudflare/r2_bucket_ensure.dag --function ensure + cat "$ROOT/target/r2-bucket-ensure-receipt.txt" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + if: github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 5 + - name: Upload R2 bucket ensure receipt + id: r2_bucket_ensure_receipt_upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: r2-bucket-ensure + path: target/r2-bucket-ensure-receipt.txt + if-no-files-found: warn + retention-days: 30 + if: always() && github.event.inputs.mode == 'r2_bucket_ensure' + timeout-minutes: 10 - name: R2 object-write token mint (AccountTokens.Create + Secret Manager custody) id: r2_object_write_mint run: |