From 7b98166930e9c5abee5d386bcdf0ea8873b50206 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 17:18:55 +0000 Subject: [PATCH 01/48] deployment risk: gunbc.deployment_risk model, HostDashboardInstance.deployment, wrong-instance failure-mode row, RED witness Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/deployment_risk.dag | 221 ++++++++++++++++++ ...ent_acts_on_the_prod_deployments_state.dag | 32 +++ .../roadmap/roadmap_dashboard_instance.dag | 30 +++ .../claim/deployment_risk_witness_test.dag | 143 ++++++++++++ .../claim/readiness_algebra_witness_test.dag | 2 + ...oadmap_dashboard_instance_witness_test.dag | 6 + 6 files changed, 434 insertions(+) create mode 100644 dag/gunbc/deployment_risk.dag create mode 100644 dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag create mode 100644 dag/test/claim/deployment_risk_witness_test.dag diff --git a/dag/gunbc/deployment_risk.dag b/dag/gunbc/deployment_risk.dag new file mode 100644 index 00000000000..743c10e51f7 --- /dev/null +++ b/dag/gunbc/deployment_risk.dag @@ -0,0 +1,221 @@ +module gunbc.deployment_risk + +import std.types { String, NonEmptyStr, Int, List, Bool } +import v2.std.collection { Present, Absent } +import extdeps.external_authority { CitedFigureStanding, TranscribedUncited } +import extdeps.ntfy.ntfy { NtfyTopic } +import gunbc.auth.approval_notification { approval_ntfy_topic } +import gunbc.auth.github_apps { DeclaredGitHubApp, gunbai_bot_declared } + +// DEPLOYMENT-RISK CONFORMANCE -- the home of the vocabulary the operator fixed on 2026-10-03 +// (side-chat ruling relayed by silent-lark-156). Three facts, kept apart (DESIGN 3): +// +// 1. WHICH deployment holds the prod role -- ProdRoleSelection, one row, the only switch +// 2. WHAT a class attaches to -- DeploymentExternalBindingSet, one per class +// 3. WHAT harm a deployment could do if wrong -- DeploymentHarmMagnitudeBet, a 4d bet +// +// The class is DERIVED from (1) and from nothing else. It is not authored on a deployment, it is +// not inferred from a host name, "live", main, or a release stage, and the harm bet never decides +// it: a bet that decided the class would be an estimate standing as a wall (DESIGN 4d). +// +// NOT IN THIS MODULE, deliberately: DeploymentReleaseStage (experiment, canary, serving, dark, +// cut-over) is an independent axis. A canary on the prod deployment is a valid combination, so +// folding stage into the class would be a meaning fork (DESIGN 3). It is the next candidate model +// and has no carrier here. +// +// Machine qualification is a different subject. It shares DeploymentRiskClass only as what a +// machine ADMITS (MachineQualificationPolicy { admits }); a machine is never itself ProdRisk and +// never confers the prod role. That replacement of gunbc.machine_intake_phase +// MachineOperatingEnvironment lands in its own one-motion migration on top of this module. + +// ── The subject ───────────────────────────────────────────────────────────────────────────────── +// ONE CONVERGENCE SUBJECT whose desired state, mutable state and external attachments move +// together. Not a host, a machine, a process, a release stage, or a dashboard instance_id: an +// instance (gunbc.roadmap_dashboard_instance HostDashboardInstance) is a realization of a +// deployment and carries this id, it does not stand for it. A record rather than a branded string +// so equality is structural and cannot be satisfied by an unrelated string of the same spelling. +type DeploymentId { + name: NonEmptyStr +} + +fn deployment_id(name: NonEmptyStr) -> DeploymentId { + DeploymentId { name: name } +} + +data srv1_daily_workspace_deployment: DeploymentId = DeploymentId { name: "srv1-daily-workspace" as NonEmptyStr } +data srv1_lab_codex_feedback_deployment: DeploymentId = DeploymentId { name: "srv1-lab-codex-feedback" as NonEmptyStr } +data srv2_preview_codex_feedback_deployment: DeploymentId = DeploymentId { name: "srv2-preview-codex-feedback" as NonEmptyStr } +data srv2_deploy_deployment: DeploymentId = DeploymentId { name: "srv2-deploy" as NonEmptyStr } +data srv2_lab_v1_deletion_deployment: DeploymentId = DeploymentId { name: "srv2-lab-v1-deletion" as NonEmptyStr } +data macbook_local_deployment: DeploymentId = DeploymentId { name: "macbook-local" as NonEmptyStr } + +// ── The class ─────────────────────────────────────────────────────────────────────────────────── +// TWO POLES, because that is what the operator ruled ("prod is extreme risk, test is zero risk"). +// An ordered scale was considered and refused: every intermediate value would need a consequence +// with a consumer (DESIGN 3c), and none exists. The magnitude lives on the bet, not here. +type DeploymentRiskClass + = TestRisk + | ProdRisk + +// ── The switch ────────────────────────────────────────────────────────────────────────────────── +// THE SOLE SWITCHABLE AUTHORITY. Moving prod, or turning it off, is an edit to this one row and +// to nothing else: every role-following singleton (approval broker, fabric storage placement) +// resolves through it. NoProdRole is a first-class arm, not an absent row, so "nothing is prod" +// is a stated fact rather than a lookup that happened to miss. +type ProdRoleSelection + = NoProdRole + | ProdRoleHeldBy { deployment: DeploymentId } + +// Initial value (operator, 2026-10-03): the srv1 daily-workspace dashboard deployment. Chosen +// arbitrarily -- there are no external customers -- and expected to move. +data prod_role_selection: ProdRoleSelection = ProdRoleHeldBy { deployment: srv1_daily_workspace_deployment } + +fn deployment_risk_class(selection: ProdRoleSelection, deployment: DeploymentId) -> DeploymentRiskClass { + match selection { + NoProdRole => TestRisk + ProdRoleHeldBy { deployment: holder } => if holder == deployment { ProdRisk } else { TestRisk } + } +} + +// ── The bet ───────────────────────────────────────────────────────────────────────────────────── +// A FERMI ORDER OF MAGNITUDE FOR THE HARM CONDITIONAL ON THE DEPLOYMENT DOING THE WRONG THING -- +// not a probability, not an SLA, and not the class. Harm is denominated in operator-hours to +// recover, because time is the one value DESIGN 1 lets us assume is shared. ZeroHarm is its own +// arm because a decade has no zero. Precedent for the typing: gunbc.spark.v41_capacity_measurement +// V41ByteRangeBet, whose standing carries the reading that would settle it. +type DeploymentHarmMagnitude + = ZeroHarm + | HarmDecade { log10_operator_hours: Int } + +type DeploymentHarmMagnitudeBet { + magnitude: DeploymentHarmMagnitude + standing: CitedFigureStanding +} + +// ── The bindings ──────────────────────────────────────────────────────────────────────────────── +// THE EXTERNAL ATTACHMENTS OF EXACTLY ONE CLASS. A deployment reaches an external service only +// through the set for its own derived class; if that exact set is absent it REFUSES. There is no +// shared default, no merged namespace, and no arm that hands a TestRisk deployment the ProdRisk +// set because the TestRisk one is missing -- that borrow is the absorbing fallback DESIGN 5 names. +// +// SCOPE AT THIS RUNG, stated so the type is not read as complete: the set carries the two +// attachments the discriminating RED exercises -- the approval ntfy topic and the publication +// GitHub App. The Codex account and home, admitted ref, R2/GCP/workload identity and OIDC client +// join as fields in the split PRs that move each one (deployment-risk plan, deliverable 3), and +// each of those PRs adds the TestRisk side or leaves its refusal standing. +type DeploymentExternalBindingSet { + class: DeploymentRiskClass + approval_ntfy_topic: NtfyTopic + publication_app: DeclaredGitHubApp +} + +// The ProdRisk set is today's single global binding, now named as the prod class's. NO TestRisk +// set exists: no test-side topic or app has been provisioned, and minting one is an outward-facing +// effect the operator approves first. So every TestRisk deployment refuses here, which is the +// correct state until a test resource exists -- not a gap to paper over. +data deployment_external_binding_sets: List = [ + DeploymentExternalBindingSet { + class: ProdRisk, + approval_ntfy_topic: approval_ntfy_topic, + publication_app: gunbai_bot_declared, + }, +] + +type DeploymentBindingResolution + = DeploymentBindingsResolved { deployment: DeploymentId, bindings: DeploymentExternalBindingSet } + | DeploymentBindingSetAbsent { deployment: DeploymentId, class: DeploymentRiskClass } + | DeploymentBindingSetAmbiguous { deployment: DeploymentId, class: DeploymentRiskClass, count: Int } + +fn binding_sets_for_class( + sets: List, + class: DeploymentRiskClass, +) -> List { + filter(sets, s => s.class == class) +} + +fn resolve_deployment_bindings( + selection: ProdRoleSelection, + sets: List, + deployment: DeploymentId, +) -> DeploymentBindingResolution { + let class = deployment_risk_class(selection: selection, deployment: deployment) + let exact = binding_sets_for_class(sets: sets, class: class) + match exact.first() { + Absent => DeploymentBindingSetAbsent { deployment: deployment, class: class } + Present { value: only } => + if count(exact) == 1 { + DeploymentBindingsResolved { deployment: deployment, bindings: only } + } else { + DeploymentBindingSetAmbiguous { deployment: deployment, class: class, count: count(exact) } + } + } +} + +// ── The interface ─────────────────────────────────────────────────────────────────────────────── +// WHAT EVERY CONVERGED DEPLOYMENT EXPOSES. It declares its identity and its harm bet; it consumes +// its class and its bindings ONLY through deployment_risk_class and resolve_deployment_bindings +// over prod_role_selection and deployment_external_binding_sets. The obligations a conforming +// deployment carries, one per clause of the contract: +// - identity: it names its DeploymentId and never another deployment's roots; +// - class: it never authors a class; it derives one from prod_role_selection; +// - bindings: every external attachment comes from its resolved set, never a literal; +// - refusal: DeploymentBindingSetAbsent / Ambiguous stops the line, it never borrows; +// - retry: its convergence is safe to re-run, and under today's operator policy it converges +// to AwaitingOperatorDisposition rather than self-repairing (gunbc#13124). +// Not a DeploymentSpec (the desired realization convergence consumes), not an actuator, not a host +// interface, not a release-stage model. +type DeploymentRiskInterface { + deployment: DeploymentId + harm: DeploymentHarmMagnitudeBet +} + +fn deployment_risk_interface_class(interface: DeploymentRiskInterface) -> DeploymentRiskClass { + deployment_risk_class(selection: prod_role_selection, deployment: interface.deployment) +} + +fn deployment_risk_interface_bindings(interface: DeploymentRiskInterface) -> DeploymentBindingResolution { + resolve_deployment_bindings( + selection: prod_role_selection, + sets: deployment_external_binding_sets, + deployment: interface.deployment, + ) +} + +// THE BETS, one per deployment. Every one is TranscribedUncited: no incident cost has been +// measured, and the read obligation names what would ground it. The daily workspace is the one +// whose wrong action costs the operator's working day and the shared approval channel; the labs +// and previews are disposable by construction. These values are bets and are consumed by nothing +// that decides -- not the class, not a binding. +fn uncited_harm_bet(magnitude: DeploymentHarmMagnitude, read_obligation: NonEmptyStr) -> DeploymentHarmMagnitudeBet { + DeploymentHarmMagnitudeBet { magnitude: magnitude, standing: TranscribedUncited { read_obligation: read_obligation } } +} + +data deployment_risk_interfaces: List = [ + DeploymentRiskInterface { + deployment: srv1_daily_workspace_deployment, + harm: uncited_harm_bet( + magnitude: HarmDecade { log10_operator_hours: 1 }, + read_obligation: "operator-hours to recover, summed over the wrong-instance incidents filed under gunbc.recurring_failure_mode a_test_deployment_acts_on_the_prod_deployments_state" as NonEmptyStr, + ), + }, + DeploymentRiskInterface { + deployment: srv1_lab_codex_feedback_deployment, + harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of the lab costing no operator time beyond the run" as NonEmptyStr), + }, + DeploymentRiskInterface { + deployment: srv2_preview_codex_feedback_deployment, + harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of the preview costing no operator time beyond the run" as NonEmptyStr), + }, + DeploymentRiskInterface { + deployment: srv2_deploy_deployment, + harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of srv2-deploy costing no operator time beyond the run" as NonEmptyStr), + }, + DeploymentRiskInterface { + deployment: srv2_lab_v1_deletion_deployment, + harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of the lab costing no operator time beyond the run" as NonEmptyStr), + }, + DeploymentRiskInterface { + deployment: macbook_local_deployment, + harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a reset of the macbook-local instance root costing no operator time beyond the run" as NonEmptyStr), + }, +] diff --git a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag new file mode 100644 index 00000000000..6a0a44dc22d --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag @@ -0,0 +1,32 @@ +module gunbc.recurring_failure_mode.a_test_deployment_acts_on_the_prod_deployments_state + +import std.types { NonEmptyStr } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data a_test_deployment_acts_on_the_prod_deployments_state: RecurringFailureMode = RecurringFailureMode { + identity: "a_test_deployment_acts_on_the_prod_deployments_state" as NonEmptyStr, + + receipts: [ + "INVALID STATE: a deployment that is not the prod-role holder reads, writes, tears down or impersonates state, a singleton or an external attachment that belongs to the deployment that is. Before gunbc.deployment_risk no carrier distinguished the two: which deployment was prod was spelled only in constructor names (srv1_live_dashboard_instance), in literals (gunbc.live_deploy.spec gunbc_approval_broker_owning_instance = \"srv1-live\"), and in host paths every srv1 deployment shared.", + + "HARM: the wrong-instance action lands on the one deployment whose mistakes cost the operator's working day and the shared approval channel, while the deployment that performed it is disposable. The asymmetry is the whole point -- a lab exists so its mistakes cost nothing, and this class is the lab's mistake being charged to prod.", + + "RECEIPTS, four in four different mechanisms, which is what makes it a class and not an incident. (1) A lab twin's retract removed the prod artifact store, because produced artifacts were rostered Owned and Owned means torn down by retract (gunbc.live_deploy.spec, the owned-roster note). (2) serve_function was dropped from the spec and the emitter fell back to a literal, so a lab twin served as prod (gunbc.live_deploy.spec, the serve_function note). (3) A lab apply would have overwritten prod's approval-broker unit -- the reasoning that kept the broker out of a twin's retract covered only half the hazard (review 68094, gunbc.live_deploy.spec). (4) belt_actuate_workdir projected srv1_gunbc_repo_root, so a belt tick ran `git worktree add` into srv1's prod tree (gunbc.roadmap_belt_tick_cli).", + + "DISTINGUISHING FACTS: the shared resource is reached by NAME or PATH (a literal, a host-layout row, a constructor) rather than through a derived role; the acting deployment has no class of its own to refuse on; and each repair so far was local to the link where the harm appeared, so the next mechanism reached the same state again (DESIGN 6b).", + + "RUNG FOUND AT: 1, mitigatable -- each specimen was caught or contained after it was written, by review, a red claim, or an emit-time refusal for that one path.", + + "CEILING: 3, structurally guaranteed. Once every external attachment and role-following singleton is reached only through gunbc.deployment_risk resolve_deployment_bindings over prod_role_selection, a TestRisk deployment has no route to a ProdRisk binding that the resolver does not refuse. It cannot be 4: a host path is still a string any author can spell, so the state stays describable in source.", + + "CLIMB STARTED BY THE MODEL PR: the class is derived, the bindings resolve per exact class, and test.claim.deployment_risk_witness_test carries the discriminating RED (a TestRisk deployment refuses rather than resolving the prod topic and app) beside its positive control. That covers the two attachments the binding set carries today and none of the four receipts above, so the row's rung is unchanged by it.", + + "NEXT-RUNG TRIGGER: every pin that names the prod deployment directly (the approval broker owner, fabric storage placement, the approval client/store/trust, the serve and dispatch entry points that call srv1_live_dashboard_instance, the apply's prod peer, fleet_reach_endpoint) resolves through prod_role_selection, AND every attachment the srv1 lab shares with prod today (ntfy, Codex account and home, admitted ref, publication identity, R2/GCP/workload identity, OIDC) resolves through DeploymentExternalBindingSet or refuses. One pin or one binding is not sufficient: the class is the route, and any remaining named route is the next receipt.", + ], + + evidence: [ + DeclarationRef { module_path: "gunbc.deployment_risk", decl_name: "resolve_deployment_bindings", field: WholeDeclaration }, + DeclarationRef { module_path: "test.claim.deployment_risk_witness_test", decl_name: "a_test_deployment_refuses_rather_than_resolving_the_prod_bindings", field: WholeDeclaration }, + ], +} diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index f2022605cd1..a9bc92da211 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -6,6 +6,18 @@ import std.measure { ByteSize } import gunbc.runner_slot_desired { gunbc_runner_slot_desired } import extdeps.tmux { TmuxServerScope, DefaultTmuxServer, NamedTmuxServer } import gunbc.ensure { EnsureObserveOnly } +import gunbc.deployment_risk { + DeploymentId, + DeploymentRiskClass, + deployment_risk_class, + prod_role_selection, + srv1_daily_workspace_deployment, + srv1_lab_codex_feedback_deployment, + srv2_preview_codex_feedback_deployment, + srv2_deploy_deployment, + srv2_lab_v1_deletion_deployment, + macbook_local_deployment, +} import gunbc.fleet_main_revision { fleet_desired_ref_name } import gunbc.provider_account { AdmittedAccountBinding, @@ -183,7 +195,12 @@ type DispatchStateScope attempt_state_root: FilePath, } +// deployment IS THE CONVERGENCE SUBJECT THIS INSTANCE REALIZES (gunbc.deployment_risk). It is a +// required field so every constructor answers it; the instance's risk class is DERIVED from it +// through prod_role_selection (dashboard_instance_risk_class) and never from instance_id, host, +// or a constructor's name -- srv1_live is a migration-era name and determines nothing. type HostDashboardInstance { + deployment: DeploymentId instance_id: NonEmptyStr host_identity: NonEmptyStr provision_parent_root: FilePath @@ -254,6 +271,11 @@ type DashboardAdmittedRevisionSource { // The fleet's authority, unchanged: origin advertises refs/fleet/desired. Named here rather than // spelled at each instance so that the fleet answer stays one value across every fleet host. +// THE INSTANCE'S RISK CLASS, derived through the one switchable row. Nothing else may answer it. +fn dashboard_instance_risk_class(instance: HostDashboardInstance) -> DeploymentRiskClass { + deployment_risk_class(selection: prod_role_selection, deployment: instance.deployment) +} + data fleet_admitted_revision_source: DashboardAdmittedRevisionSource = DashboardAdmittedRevisionSource { remote: "origin", ref_name: fleet_desired_ref_name, @@ -793,6 +815,7 @@ data macbook_local_codex_home: FilePath = codex_account_state_root( // development checkout is unmodeled. This row makes the machine nameable; it does not answer for // what may be launched on it. fn macbook_local_dashboard_instance() -> HostDashboardInstance { + deployment: macbook_local_deployment, HostDashboardInstance { instance_id: macbook_local_dashboard_id, host_identity: "macbook" as NonEmptyStr, @@ -877,6 +900,7 @@ data macbook_local_admitted_revision_source: DashboardAdmittedRevisionSource = D fn srv1_live_dashboard_instance() -> HostDashboardInstance { HostDashboardInstance { + deployment: srv1_daily_workspace_deployment, instance_id: srv1_dashboard_live_id, host_identity: "srv1" as NonEmptyStr, provision_parent_root: "/opt/gunbc" as FilePath, @@ -955,6 +979,7 @@ fn srv1_live_dashboard_instance() -> HostDashboardInstance { // legitimately serve different refs; the ref belongs to the instance, not the machine. fn isolated_dashboard_instance( + deployment: DeploymentId, instance_id: NonEmptyStr, layout: GunbcHostLayout, root: FilePath, @@ -965,6 +990,7 @@ fn isolated_dashboard_instance( provision_transport: DashboardProvisionTransport, ) -> HostDashboardInstance { HostDashboardInstance { + deployment: deployment, instance_id: instance_id, host_identity: layout.host_identity, provision_parent_root: "/home/briansrls/.local/share" as FilePath, @@ -1009,6 +1035,7 @@ fn isolated_dashboard_instance( fn srv1_lab_dashboard_instance() -> HostDashboardInstance { isolated_dashboard_instance( + deployment: srv1_lab_codex_feedback_deployment, instance_id: srv1_dashboard_lab_id, layout: srv1_host_layout(), root: srv1_dashboard_lab_root, @@ -1022,6 +1049,7 @@ fn srv1_lab_dashboard_instance() -> HostDashboardInstance { fn srv2_preview_dashboard_instance() -> HostDashboardInstance { isolated_dashboard_instance( + deployment: srv2_preview_codex_feedback_deployment, instance_id: srv2_dashboard_preview_id, layout: srv2_host_layout(), root: srv2_dashboard_preview_root, @@ -1103,6 +1131,7 @@ data srv2_deploy_admitted_revision_source: DashboardAdmittedRevisionSource = Das // attempts are published against main (dispatch_base_branch); the admitted revision stays the local ref. fn srv2_deploy_dashboard_instance() -> HostDashboardInstance { HostDashboardInstance { + deployment: srv2_deploy_deployment, instance_id: srv2_deploy_dashboard_id, host_identity: srv2_host_layout().host_identity, provision_parent_root: "/home/briansrls/.local/share" as FilePath, @@ -1147,6 +1176,7 @@ fn srv2_deploy_dashboard_instance() -> HostDashboardInstance { // collision; it is not an ownership determination or an observation of either live listener. fn srv2_lab_dashboard_instance() -> HostDashboardInstance { isolated_dashboard_instance( + deployment: srv2_lab_v1_deletion_deployment, instance_id: srv2_dashboard_lab_id, layout: srv2_host_layout(), root: srv2_dashboard_lab_root, diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag new file mode 100644 index 00000000000..b25e320e6eb --- /dev/null +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -0,0 +1,143 @@ +module test.claim.deployment_risk_witness_test + +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.types { NonEmptyStr, List, Bool } +import extdeps.ntfy.ntfy { NtfyTopic } +import gunbc.auth.approval_notification { approval_ntfy_topic } +import gunbc.auth.github_apps { gunbai_bot_declared, gunbai_ci_declared } +import gunbc.deployment_risk { + DeploymentId, + DeploymentRiskClass, + TestRisk, + ProdRisk, + ProdRoleSelection, + NoProdRole, + ProdRoleHeldBy, + DeploymentExternalBindingSet, + DeploymentBindingResolution, + DeploymentBindingsResolved, + DeploymentBindingSetAbsent, + DeploymentBindingSetAmbiguous, + deployment_risk_class, + resolve_deployment_bindings, + prod_role_selection, + deployment_external_binding_sets, + deployment_risk_interfaces, + srv1_daily_workspace_deployment, + srv1_lab_codex_feedback_deployment, + srv2_deploy_deployment, +} +import gunbc.roadmap_dashboard_instance { + HostDashboardInstance, + dashboard_instance_risk_class, + macbook_local_dashboard_instance, + srv1_live_dashboard_instance, + srv1_lab_dashboard_instance, + srv2_preview_dashboard_instance, + srv2_deploy_dashboard_instance, + srv2_lab_dashboard_instance, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// ── The discriminating RED: a TestRisk deployment never reaches the prod attachments ──────────── +// THE REAL ROW AND THE REAL TABLE. The srv1 lab shares a host with the prod deployment and, before +// this model, shared its ntfy topic and publication app. Through the model it derives TestRisk, +// finds no TestRisk set, and REFUSES -- it does not receive the ProdRisk set that exists one row +// away. +test fn a_test_deployment_refuses_rather_than_resolving_the_prod_bindings() -> Bool { + match resolve_deployment_bindings( + selection: prod_role_selection, + sets: deployment_external_binding_sets, + deployment: srv1_lab_codex_feedback_deployment, + ) { + DeploymentBindingSetAbsent { deployment: d, class: TestRisk } => d == srv1_lab_codex_feedback_deployment + _ => false + } +} + +// THE POSITIVE CONTROL, same row and table: the prod-role holder resolves today's topic and app. +// Without it the RED above would pass for a resolver that refuses everyone. +test fn the_prod_role_holder_resolves_the_prod_topic_and_app() -> Bool { + match resolve_deployment_bindings( + selection: prod_role_selection, + sets: deployment_external_binding_sets, + deployment: srv1_daily_workspace_deployment, + ) { + DeploymentBindingsResolved { deployment: _, bindings: b } => + b.approval_ntfy_topic == approval_ntfy_topic && b.publication_app == gunbai_bot_declared + _ => false + } +} + +// NO BORROWING EVEN WHEN A TEST SET EXISTS. A fixture table carrying both classes: the test +// deployment gets ITS set's topic and app, and the prod one never leaks across. A resolver that +// returned "the first set" or "the prod set" would fail one of the two conjuncts. +data fx_test_topic: NtfyTopic = "gunbc-approvals-test" as NtfyTopic + +data fx_prod_set: DeploymentExternalBindingSet = DeploymentExternalBindingSet { class: ProdRisk, approval_ntfy_topic: approval_ntfy_topic, publication_app: gunbai_bot_declared } + +data fx_both_classes: List = [ + fx_prod_set, + DeploymentExternalBindingSet { class: TestRisk, approval_ntfy_topic: fx_test_topic, publication_app: gunbai_ci_declared }, +] + +test fn each_class_resolves_only_its_own_set() -> Bool { + let test_side = match resolve_deployment_bindings(selection: prod_role_selection, sets: fx_both_classes, deployment: srv1_lab_codex_feedback_deployment) { + DeploymentBindingsResolved { deployment: _, bindings: b } => b.approval_ntfy_topic == fx_test_topic && b.publication_app != gunbai_bot_declared + _ => false + } + let prod_side = match resolve_deployment_bindings(selection: prod_role_selection, sets: fx_both_classes, deployment: srv1_daily_workspace_deployment) { + DeploymentBindingsResolved { deployment: _, bindings: b } => b.approval_ntfy_topic == approval_ntfy_topic + _ => false + } + test_side && prod_side +} + +// TWO SETS FOR ONE CLASS IS A REFUSAL, not a first-match. +test fn two_sets_for_one_class_refuse_as_ambiguous() -> Bool { + let doubled = [fx_prod_set, fx_prod_set] + match resolve_deployment_bindings(selection: prod_role_selection, sets: doubled, deployment: srv1_daily_workspace_deployment) { + DeploymentBindingSetAmbiguous { deployment: _, class: ProdRisk, count: 2 } => true + _ => false + } +} + +// ── The switch: one row moves prod, or turns it off ───────────────────────────────────────────── +test fn no_prod_role_makes_the_former_holder_test_risk_and_it_refuses() -> Bool { + deployment_risk_class(selection: NoProdRole, deployment: srv1_daily_workspace_deployment) == TestRisk + && match resolve_deployment_bindings(selection: NoProdRole, sets: deployment_external_binding_sets, deployment: srv1_daily_workspace_deployment) { + DeploymentBindingSetAbsent { deployment: _, class: TestRisk } => true + _ => false + } +} + +test fn moving_the_role_moves_the_class_and_nothing_else_decides_it() -> Bool { + let moved = ProdRoleHeldBy { deployment: srv2_deploy_deployment } + deployment_risk_class(selection: moved, deployment: srv2_deploy_deployment) == ProdRisk + && deployment_risk_class(selection: moved, deployment: srv1_daily_workspace_deployment) == TestRisk +} + +// ── Every dashboard instance answers, through its deployment ──────────────────────────────────── +// All six constructors, against the real row: exactly the daily workspace derives ProdRisk. +fn dashboard_instances() -> List { + [ + macbook_local_dashboard_instance(), + srv1_live_dashboard_instance(), + srv1_lab_dashboard_instance(), + srv2_preview_dashboard_instance(), + srv2_deploy_dashboard_instance(), + srv2_lab_dashboard_instance(), + ] +} + +test fn exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace() -> Bool { + let prod = filter(dashboard_instances(), i => dashboard_instance_risk_class(instance: i) == ProdRisk) + count(prod) == 1 && all(prod, i => i.deployment == srv1_daily_workspace_deployment) +} + +// EVERY INSTANCE'S DEPLOYMENT EXPOSES THE INTERFACE: its id appears in deployment_risk_interfaces +// exactly once, so no realization exists whose deployment carries no harm bet. +test fn every_dashboard_deployment_exposes_exactly_one_risk_interface() -> Bool { + all(dashboard_instances(), i => count(filter(deployment_risk_interfaces, r => r.deployment == i.deployment)) == 1) +} diff --git a/dag/test/claim/readiness_algebra_witness_test.dag b/dag/test/claim/readiness_algebra_witness_test.dag index 3ac1b53505d..4baaa9e6cac 100644 --- a/dag/test/claim/readiness_algebra_witness_test.dag +++ b/dag/test/claim/readiness_algebra_witness_test.dag @@ -19,6 +19,7 @@ import gunbc.dashboard_provider_readiness { ProviderRuntimeUnavailable, } import gunbc.host_layout { GunbcHostLayout } +import gunbc.deployment_risk { deployment_id } import gunbc.roadmap_dashboard_instance { isolated_dashboard_instance, DashboardActuating, @@ -163,6 +164,7 @@ fn witness_unplaced_executable_layout() -> GunbcHostLayout { test fn witness_red_control_observe_unplaced_executable_runs_the_producer() -> Bool { match observe_dashboard_provider_readiness( instance: isolated_dashboard_instance( + deployment: deployment_id(name: "witness-unplaced-provider" as NonEmptyStr), instance_id: "witness-unplaced-provider" as NonEmptyStr, layout: witness_unplaced_executable_layout(), root: "/witness/root" as FilePath, diff --git a/dag/test/claim/roadmap/roadmap_dashboard_instance_witness_test.dag b/dag/test/claim/roadmap/roadmap_dashboard_instance_witness_test.dag index f3f47a04729..c1b43dec9fe 100644 --- a/dag/test/claim/roadmap/roadmap_dashboard_instance_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_dashboard_instance_witness_test.dag @@ -21,6 +21,7 @@ import gunbc.host_layout { srv1_dispatch_attempt_state_root, srv1_codex_bundled_executable, } +import gunbc.deployment_risk { deployment_id } import gunbc.roadmap_dashboard_instance { fleet_admitted_revision_source, HostDashboardInstance, @@ -517,6 +518,7 @@ fn witness_synthetic_layout() -> GunbcHostLayout { fn witness_synthetic_instance() -> HostDashboardInstance { isolated_dashboard_instance( + deployment: deployment_id(name: "witness-synthetic" as NonEmptyStr), instance_id: "witness-synthetic" as NonEmptyStr, layout: witness_synthetic_layout(), root: "/witness/root" as FilePath, @@ -755,6 +757,7 @@ fn witness_production_slot( ) -> HostDashboardInstance { let binary = dashboard_instance_child(root: root, child: "bin/gunbc") HostDashboardInstance { + deployment: deployment_id(name: instance_id), instance_id: instance_id, host_identity: host_identity, provision_parent_root: "/opt" as FilePath, @@ -936,6 +939,7 @@ fn witness_endpoint_instance( listen_port: Port, ) -> HostDashboardInstance { isolated_dashboard_instance( + deployment: deployment_id(name: instance_id), instance_id: instance_id, layout: layout, root: dashboard_instance_child( @@ -1068,6 +1072,7 @@ test fn declared_dashboard_instance_set_is_the_lookup_authority() -> Bool { fn outside_instance_at(root: FilePath) -> HostDashboardInstance { isolated_dashboard_instance( + deployment: deployment_id(name: "outside-repository-candidate" as NonEmptyStr), instance_id: "outside-repository-candidate" as NonEmptyStr, layout: srv1_host_layout(), root: root, @@ -1112,6 +1117,7 @@ test fn an_outside_instance_on_a_public_root_is_refused() -> Bool { fn outside_instance_claiming_live_id_on_live_footprint() -> HostDashboardInstance { let live = srv1_live_dashboard_instance() HostDashboardInstance { + deployment: live.deployment, instance_id: live.instance_id, host_identity: live.host_identity, provision_parent_root: live.provision_parent_root, From 3f8a470365f38b8a1cfa51d533163e46c412200e Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 17:25:13 +0000 Subject: [PATCH 02/48] deployment risk witness: cite the failure-mode row Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/test/claim/deployment_risk_witness_test.dag | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index b25e320e6eb..43a34f7622a 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -27,6 +27,7 @@ import gunbc.deployment_risk { srv1_lab_codex_feedback_deployment, srv2_deploy_deployment, } +import gunbc.recurring_failure_mode.a_test_deployment_acts_on_the_prod_deployments_state { a_test_deployment_acts_on_the_prod_deployments_state } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, dashboard_instance_risk_class, @@ -141,3 +142,9 @@ test fn exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_wor test fn every_dashboard_deployment_exposes_exactly_one_risk_interface() -> Bool { all(dashboard_instances(), i => count(filter(deployment_risk_interfaces, r => r.deployment == i.deployment)) == 1) } + +// THE FAILURE-MODE ROW RESOLVES AND NAMES THIS WITNESS AS ITS EVIDENCE, so a rename here cannot +// leave the row citing a declaration that no longer exists. +test fn the_wrong_instance_failure_mode_row_cites_this_red() -> Bool { + any(a_test_deployment_acts_on_the_prod_deployments_state.evidence, r => r.decl_name == "a_test_deployment_refuses_rather_than_resolving_the_prod_bindings") +} From 0ae094897e7798db0bd3107327af8a48b94d1e64 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 17:33:35 +0000 Subject: [PATCH 03/48] dashboard instance: place deployment inside the macbook literal Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/roadmap/roadmap_dashboard_instance.dag | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index a9bc92da211..a13e261c213 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -269,13 +269,13 @@ type DashboardAdmittedRevisionSource { ref_name: GitRef } -// The fleet's authority, unchanged: origin advertises refs/fleet/desired. Named here rather than -// spelled at each instance so that the fleet answer stays one value across every fleet host. // THE INSTANCE'S RISK CLASS, derived through the one switchable row. Nothing else may answer it. fn dashboard_instance_risk_class(instance: HostDashboardInstance) -> DeploymentRiskClass { deployment_risk_class(selection: prod_role_selection, deployment: instance.deployment) } +// The fleet's authority, unchanged: origin advertises refs/fleet/desired. Named here rather than +// spelled at each instance so that the fleet answer stays one value across every fleet host. data fleet_admitted_revision_source: DashboardAdmittedRevisionSource = DashboardAdmittedRevisionSource { remote: "origin", ref_name: fleet_desired_ref_name, @@ -815,8 +815,8 @@ data macbook_local_codex_home: FilePath = codex_account_state_root( // development checkout is unmodeled. This row makes the machine nameable; it does not answer for // what may be launched on it. fn macbook_local_dashboard_instance() -> HostDashboardInstance { - deployment: macbook_local_deployment, HostDashboardInstance { + deployment: macbook_local_deployment, instance_id: macbook_local_dashboard_id, host_identity: "macbook" as NonEmptyStr, provision_parent_root: "/Users/briansrls/.local/share" as FilePath, From cfe8304828c3815f9e0495684eab66562da422ba Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 17:44:13 +0000 Subject: [PATCH 04/48] docs/plans: deployment-risk conformance plan and shared-binding table Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/plans/deployment-risk-conformance.md | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/plans/deployment-risk-conformance.md diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md new file mode 100644 index 00000000000..b9ce3d24578 --- /dev/null +++ b/docs/plans/deployment-risk-conformance.md @@ -0,0 +1,40 @@ +# Deployment-risk conformance + +Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruling relayed through silent-lark-156. Home: `gunbc.deployment_risk`. + +## Terms (approved; use these names) + +| Term | Meaning | Is NOT | +|---|---|---| +| `DeploymentId` | one convergence subject whose desired state, mutable state and external attachments move together; an instance realizes it | a host, machine, process, release stage, dashboard `instance_id` | +| `DeploymentRiskClass = TestRisk \| ProdRisk` | coarse harm class, DERIVED from `ProdRoleSelection` only | a number, machine qualification, stage, branch, host | +| `DeploymentHarmMagnitudeBet` | Fermi order of magnitude of harm conditional on doing the wrong thing, typed as a §4d bet | a probability, a fact, the class, an SLA | +| `ProdRoleSelection = NoProdRole \| ProdRoleHeldBy { deployment }` | the sole switch (`prod_role_selection`) | inferred from srv1, "live", main, a host | +| `DeploymentExternalBindingSet` | the external attachments of exactly one class; absent set refuses | a fallback, a shared default, a borrow | +| `DeploymentRiskInterface` | what each deployment exposes (identity, harm bet) and consumes (class, bindings) | a `DeploymentSpec`, an actuator, a stage model | +| `DeploymentReleaseStage` | independent rollout axis | out of scope; next candidate | + +`DesiredDeployment` (selection row) and `DeploymentSpec` (desired realization) stay distinct from `DeploymentId`. `MachineOperatingEnvironment` is replaced by `MachineQualificationPolicy { admits: DeploymentRiskClass }`: a machine admits a class, never holds one. A `ProdRoleHeldBy` assignment refuses unless its host admits `ProdRisk`. + +## Deliverables + +1. **Model** (this PR): `gunbc.deployment_risk`; `HostDashboardInstance.deployment` (required, all six constructors); `dashboard_instance_risk_class`; the failure-mode row `a_test_deployment_acts_on_the_prod_deployments_state`; RED `test.claim.deployment_risk_witness_test`. + - Declared frontier (§3c): `resolve_deployment_bindings` and `dashboard_instance_risk_class` gain production consumers in deliverables 2 and 3.1; the harm bets are read by the deliverable-5 reviewer and decide nothing. +1b. **Machine qualification migration**: one motion over machine_intake (re-census on main first; coordinate warm-crane-577's mtcollins1 untangle lane). eager-gull-22 handed it to this lane. +2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. +3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. +4. **One spec per deployment**; retire the `live_deploy.desired` rung drop by its own trigger. Renames: `srv1_production_desired_deployment → srv1_primary_desired_deployment`, `roadmap_belt_production_spawn_mode → roadmap_belt_operational_spawn_mode`. +5. **Conformance row** `deployment-risk conformance` in `gunbc.design_argument conformance_domains`, once the home is consumed. + +Widened scope (side chat): classify every convergence subject as deployment-bound (Spark serving/experiments/seat grants, fabric storage, approval broker, R2 keyed by class + `BucketPurpose`, IAM targets, DNS/routes/certs, runner workloads, mutable caches), host-shared (Spark Wi-Fi/SSH/admin grants, KVM, base networking, toolchains), or fleet-control (root IAM writer, account admin). Pure computation identity and content-addressed outputs gain no deployment key. Manual convergence requests take `DeploymentConvergence { deployment } | HostResourceConvergence { host, resource } | FleetControlConvergence { operation }`. + +## Shared-binding table (as of deliverable 1) + +| Binding | State | Reason | +|---|---|---| +| ntfy topic | modeled in the binding set; ProdRisk only; TestRisk **refuses** at the resolver | broker path still reads `approval_ntfy_topic` directly until 3.1 | +| GitHub App (publication) | modeled; ProdRisk only; TestRisk **refuses** at the resolver | publishers still name `gunbai_*_declared` until 3.4 | +| Codex account and home | still shared | 3.2 | +| admitted ref | still shared (labs on `refs/fleet/desired`) | 3.3 | +| R2 / GCP / WIF | still shared | 3.5; coordinate royal-moth-86 (#13035) | +| OIDC client | still shared, unmodeled | 3.6 | From 909405e5e331b6e0bd926eaca0ea7737619c9553 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 03:16:15 +0000 Subject: [PATCH 05/48] deployment_risk: defer the harm bet and interface roster to their first consumer (review 74937) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/deployment_risk.dag | 101 +++--------------- .../claim/deployment_risk_witness_test.dag | 7 -- docs/plans/deployment-risk-conformance.md | 3 +- 3 files changed, 17 insertions(+), 94 deletions(-) diff --git a/dag/gunbc/deployment_risk.dag b/dag/gunbc/deployment_risk.dag index 743c10e51f7..9c64488ccbf 100644 --- a/dag/gunbc/deployment_risk.dag +++ b/dag/gunbc/deployment_risk.dag @@ -2,7 +2,6 @@ module gunbc.deployment_risk import std.types { String, NonEmptyStr, Int, List, Bool } import v2.std.collection { Present, Absent } -import extdeps.external_authority { CitedFigureStanding, TranscribedUncited } import extdeps.ntfy.ntfy { NtfyTopic } import gunbc.auth.approval_notification { approval_ntfy_topic } import gunbc.auth.github_apps { DeclaredGitHubApp, gunbai_bot_declared } @@ -12,7 +11,8 @@ import gunbc.auth.github_apps { DeclaredGitHubApp, gunbai_bot_declared } // // 1. WHICH deployment holds the prod role -- ProdRoleSelection, one row, the only switch // 2. WHAT a class attaches to -- DeploymentExternalBindingSet, one per class -// 3. WHAT harm a deployment could do if wrong -- DeploymentHarmMagnitudeBet, a 4d bet +// 3. WHAT harm a deployment could do if wrong -- DeploymentHarmMagnitudeBet, a 4d bet (lands +// with its first consumer; see the interface section below) // // The class is DERIVED from (1) and from nothing else. It is not authored on a deployment, it is // not inferred from a host name, "live", main, or a release stage, and the harm bet never decides @@ -52,7 +52,7 @@ data macbook_local_deployment: DeploymentId = DeploymentId { name: "macbook-loca // ── The class ─────────────────────────────────────────────────────────────────────────────────── // TWO POLES, because that is what the operator ruled ("prod is extreme risk, test is zero risk"). // An ordered scale was considered and refused: every intermediate value would need a consequence -// with a consumer (DESIGN 3c), and none exists. The magnitude lives on the bet, not here. +// with a consumer (DESIGN 3c), and none exists. The magnitude belongs on the bet, not here. type DeploymentRiskClass = TestRisk | ProdRisk @@ -77,21 +77,6 @@ fn deployment_risk_class(selection: ProdRoleSelection, deployment: DeploymentId) } } -// ── The bet ───────────────────────────────────────────────────────────────────────────────────── -// A FERMI ORDER OF MAGNITUDE FOR THE HARM CONDITIONAL ON THE DEPLOYMENT DOING THE WRONG THING -- -// not a probability, not an SLA, and not the class. Harm is denominated in operator-hours to -// recover, because time is the one value DESIGN 1 lets us assume is shared. ZeroHarm is its own -// arm because a decade has no zero. Precedent for the typing: gunbc.spark.v41_capacity_measurement -// V41ByteRangeBet, whose standing carries the reading that would settle it. -type DeploymentHarmMagnitude - = ZeroHarm - | HarmDecade { log10_operator_hours: Int } - -type DeploymentHarmMagnitudeBet { - magnitude: DeploymentHarmMagnitude - standing: CitedFigureStanding -} - // ── The bindings ──────────────────────────────────────────────────────────────────────────────── // THE EXTERNAL ATTACHMENTS OF EXACTLY ONE CLASS. A deployment reaches an external service only // through the set for its own derived class; if that exact set is absent it REFUSES. There is no @@ -151,71 +136,15 @@ fn resolve_deployment_bindings( } } -// ── The interface ─────────────────────────────────────────────────────────────────────────────── -// WHAT EVERY CONVERGED DEPLOYMENT EXPOSES. It declares its identity and its harm bet; it consumes -// its class and its bindings ONLY through deployment_risk_class and resolve_deployment_bindings -// over prod_role_selection and deployment_external_binding_sets. The obligations a conforming -// deployment carries, one per clause of the contract: -// - identity: it names its DeploymentId and never another deployment's roots; -// - class: it never authors a class; it derives one from prod_role_selection; -// - bindings: every external attachment comes from its resolved set, never a literal; -// - refusal: DeploymentBindingSetAbsent / Ambiguous stops the line, it never borrows; -// - retry: its convergence is safe to re-run, and under today's operator policy it converges -// to AwaitingOperatorDisposition rather than self-repairing (gunbc#13124). -// Not a DeploymentSpec (the desired realization convergence consumes), not an actuator, not a host -// interface, not a release-stage model. -type DeploymentRiskInterface { - deployment: DeploymentId - harm: DeploymentHarmMagnitudeBet -} - -fn deployment_risk_interface_class(interface: DeploymentRiskInterface) -> DeploymentRiskClass { - deployment_risk_class(selection: prod_role_selection, deployment: interface.deployment) -} - -fn deployment_risk_interface_bindings(interface: DeploymentRiskInterface) -> DeploymentBindingResolution { - resolve_deployment_bindings( - selection: prod_role_selection, - sets: deployment_external_binding_sets, - deployment: interface.deployment, - ) -} - -// THE BETS, one per deployment. Every one is TranscribedUncited: no incident cost has been -// measured, and the read obligation names what would ground it. The daily workspace is the one -// whose wrong action costs the operator's working day and the shared approval channel; the labs -// and previews are disposable by construction. These values are bets and are consumed by nothing -// that decides -- not the class, not a binding. -fn uncited_harm_bet(magnitude: DeploymentHarmMagnitude, read_obligation: NonEmptyStr) -> DeploymentHarmMagnitudeBet { - DeploymentHarmMagnitudeBet { magnitude: magnitude, standing: TranscribedUncited { read_obligation: read_obligation } } -} - -data deployment_risk_interfaces: List = [ - DeploymentRiskInterface { - deployment: srv1_daily_workspace_deployment, - harm: uncited_harm_bet( - magnitude: HarmDecade { log10_operator_hours: 1 }, - read_obligation: "operator-hours to recover, summed over the wrong-instance incidents filed under gunbc.recurring_failure_mode a_test_deployment_acts_on_the_prod_deployments_state" as NonEmptyStr, - ), - }, - DeploymentRiskInterface { - deployment: srv1_lab_codex_feedback_deployment, - harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of the lab costing no operator time beyond the run" as NonEmptyStr), - }, - DeploymentRiskInterface { - deployment: srv2_preview_codex_feedback_deployment, - harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of the preview costing no operator time beyond the run" as NonEmptyStr), - }, - DeploymentRiskInterface { - deployment: srv2_deploy_deployment, - harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of srv2-deploy costing no operator time beyond the run" as NonEmptyStr), - }, - DeploymentRiskInterface { - deployment: srv2_lab_v1_deletion_deployment, - harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a retract-and-reprovision of the lab costing no operator time beyond the run" as NonEmptyStr), - }, - DeploymentRiskInterface { - deployment: macbook_local_deployment, - harm: uncited_harm_bet(magnitude: ZeroHarm, read_obligation: "a reset of the macbook-local instance root costing no operator time beyond the run" as NonEmptyStr), - }, -] +// ── The interface, and the bet ────────────────────────────────────────────────────────────────── +// DeploymentRiskInterface (each deployment exposes its identity and its DeploymentHarmMagnitudeBet; +// it consumes its class and bindings only through deployment_risk_class and +// resolve_deployment_bindings) and the bet itself are approved terms that land WITH THEIR FIRST +// EXECUTING CONSUMER, not before it (DESIGN 3c; review 74937). That consumer is the operator- +// disposition gate of deliverable 2 (docs/plans/deployment-risk-conformance.md), which is also where +// the bet's magnitude gains a grounded carrier -- an order of magnitude over std.measure Duration -- +// rather than a bare Int named for its unit. Until then the obligations a conforming deployment +// carries are: it names its DeploymentId and never another deployment's roots; it never authors a +// class; every external attachment comes from its resolved set, never a literal; an absent or +// ambiguous set stops the line; and its convergence is safe to re-run, converging under today's +// operator policy to AwaitingOperatorDisposition rather than self-repairing (gunbc#13124). diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 43a34f7622a..f66e74bc3d7 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -22,7 +22,6 @@ import gunbc.deployment_risk { resolve_deployment_bindings, prod_role_selection, deployment_external_binding_sets, - deployment_risk_interfaces, srv1_daily_workspace_deployment, srv1_lab_codex_feedback_deployment, srv2_deploy_deployment, @@ -137,12 +136,6 @@ test fn exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_wor count(prod) == 1 && all(prod, i => i.deployment == srv1_daily_workspace_deployment) } -// EVERY INSTANCE'S DEPLOYMENT EXPOSES THE INTERFACE: its id appears in deployment_risk_interfaces -// exactly once, so no realization exists whose deployment carries no harm bet. -test fn every_dashboard_deployment_exposes_exactly_one_risk_interface() -> Bool { - all(dashboard_instances(), i => count(filter(deployment_risk_interfaces, r => r.deployment == i.deployment)) == 1) -} - // THE FAILURE-MODE ROW RESOLVES AND NAMES THIS WITNESS AS ITS EVIDENCE, so a rename here cannot // leave the row citing a declaration that no longer exists. test fn the_wrong_instance_failure_mode_row_cites_this_red() -> Bool { diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index b9ce3d24578..eb26fab642e 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -19,7 +19,8 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli ## Deliverables 1. **Model** (this PR): `gunbc.deployment_risk`; `HostDashboardInstance.deployment` (required, all six constructors); `dashboard_instance_risk_class`; the failure-mode row `a_test_deployment_acts_on_the_prod_deployments_state`; RED `test.claim.deployment_risk_witness_test`. - - Declared frontier (§3c): `resolve_deployment_bindings` and `dashboard_instance_risk_class` gain production consumers in deliverables 2 and 3.1; the harm bets are read by the deliverable-5 reviewer and decide nothing. + - Declared frontier (§3c): `resolve_deployment_bindings` and `dashboard_instance_risk_class` gain production consumers in deliverables 2 and 3.1. + - Deferred to deliverable 2 (review 74937): `DeploymentRiskInterface` and `DeploymentHarmMagnitudeBet` land with their first executing consumer, the operator-disposition gate (which actions on a deployment need an operator), and the bet's magnitude is grounded as an order of magnitude over `std.measure` `Duration`, not a bare `Int`. 1b. **Machine qualification migration**: one motion over machine_intake (re-census on main first; coordinate warm-crane-577's mtcollins1 untangle lane). eager-gull-22 handed it to this lane. 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. 3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. From c35c4c0a14294e6329a9561d0bb73aee5bb25d2f Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 03:30:43 +0000 Subject: [PATCH 06/48] deployment_risk: attach the deferred-interface annotation to the resolver it describes (DESIGN 4c) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/deployment_risk.dag | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/dag/gunbc/deployment_risk.dag b/dag/gunbc/deployment_risk.dag index 9c64488ccbf..156be94dc22 100644 --- a/dag/gunbc/deployment_risk.dag +++ b/dag/gunbc/deployment_risk.dag @@ -118,6 +118,18 @@ fn binding_sets_for_class( filter(sets, s => s.class == class) } +// ── The interface, and the bet ────────────────────────────────────────────────────────────────── +// DeploymentRiskInterface (each deployment exposes its identity and its DeploymentHarmMagnitudeBet; +// it consumes its class and bindings only through deployment_risk_class and +// resolve_deployment_bindings) and the bet itself are approved terms that land WITH THEIR FIRST +// EXECUTING CONSUMER, not before it (DESIGN 3c; review 74937). That consumer is the operator- +// disposition gate of deliverable 2 (docs/plans/deployment-risk-conformance.md), which is also where +// the bet's magnitude gains a grounded carrier -- an order of magnitude over std.measure Duration -- +// rather than a bare Int named for its unit. Until then the obligations a conforming deployment +// carries are: it names its DeploymentId and never another deployment's roots; it never authors a +// class; every external attachment comes from its resolved set, never a literal; an absent or +// ambiguous set stops the line; and its convergence is safe to re-run, converging under today's +// operator policy to AwaitingOperatorDisposition rather than self-repairing (gunbc#13124). fn resolve_deployment_bindings( selection: ProdRoleSelection, sets: List, @@ -135,16 +147,3 @@ fn resolve_deployment_bindings( } } } - -// ── The interface, and the bet ────────────────────────────────────────────────────────────────── -// DeploymentRiskInterface (each deployment exposes its identity and its DeploymentHarmMagnitudeBet; -// it consumes its class and bindings only through deployment_risk_class and -// resolve_deployment_bindings) and the bet itself are approved terms that land WITH THEIR FIRST -// EXECUTING CONSUMER, not before it (DESIGN 3c; review 74937). That consumer is the operator- -// disposition gate of deliverable 2 (docs/plans/deployment-risk-conformance.md), which is also where -// the bet's magnitude gains a grounded carrier -- an order of magnitude over std.measure Duration -- -// rather than a bare Int named for its unit. Until then the obligations a conforming deployment -// carries are: it names its DeploymentId and never another deployment's roots; it never authors a -// class; every external attachment comes from its resolved set, never a literal; an absent or -// ambiguous set stops the line; and its convergence is safe to re-run, converging under today's -// operator policy to AwaitingOperatorDisposition rather than self-repairing (gunbc#13124). From 85904b1ff966d99cba583176b17e592c96263c7a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 03:37:15 +0000 Subject: [PATCH 07/48] deployment risk D2: role-following singletons resolve the prod-role holder through one lookup prod_role_realization in gunbc.roadmap_dashboard_instance maps prod_role_selection to its HostDashboardInstance (the roster filtered by deployment) and refuses with a typed cause under NoProdRole, an unrealized or an ambiguous holder. The approval broker owner, fabric storage placement, approval client origin and identity trust follow it; ProductionPeer becomes DashboardProtectedSibling derived by host and class. RED + positive control in test.claim.deployment_risk_witness_test. Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/auth/approval_decision_store.dag | 25 ++- dag/gunbc/auth/approval_gate.dag | 21 +- dag/gunbc/auth/approval_request_client.dag | 39 +++- dag/gunbc/fabric/fabric_storage_client.dag | 8 +- dag/gunbc/fabric/fabric_storage_placement.dag | 105 +++++++--- dag/gunbc/fabric/fabric_storage_serve.dag | 6 +- dag/gunbc/fabric/fabric_writer_roster.dag | 5 +- dag/gunbc/live_deploy/emit.dag | 56 ++++-- dag/gunbc/live_deploy/spec.dag | 62 ++++-- ...ent_acts_on_the_prod_deployments_state.dag | 6 + .../roadmap/roadmap_dashboard_instance.dag | 69 +++++++ .../roadmap_dashboard_instance_apply.dag | 190 +++++++++--------- .../approval_decision_store_witness_test.dag | 10 +- .../approval_request_client_witness_test.dag | 17 +- .../claim/deployment_risk_witness_test.dag | 104 ++++++++++ .../fabric_writer_roster_witness_test.dag | 5 +- dag/test/claim/live_deploy/emit_test.dag | 18 +- ..._dashboard_instance_apply_witness_test.dag | 116 +++++------ docs/plans/deployment-risk-conformance.md | 9 +- 19 files changed, 611 insertions(+), 260 deletions(-) diff --git a/dag/gunbc/auth/approval_decision_store.dag b/dag/gunbc/auth/approval_decision_store.dag index b4be17b88fa..2803d726f6b 100644 --- a/dag/gunbc/auth/approval_decision_store.dag +++ b/dag/gunbc/auth/approval_decision_store.dag @@ -18,7 +18,7 @@ import extdeps.filesystem.filesystem_io { Filesystem, FilesystemExactRead, Files import extdeps.tailscale.identity { TailnetIdentity } import extdeps.network.address { IpV4, ip_address_is_loopback } import extdeps.network.ipv4 { parse_ipv4_address, Ipv4Parsed, Ipv4ParseFailed } -import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } +import gunbc.roadmap_dashboard_instance { ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, prod_role_realization, prod_role_realization_refusal_detail } import extdeps.crypto.mac { MacKey, MacKeyId, MacSuite, HmacSha256, MacVerification, mac_verify, mac_key_material_hex_length } import extdeps.languages.json.emit { JsonValue, JsonString, JsonBool, serialize_json, json_object, json_kv, json_string, json_bool } import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, JsonMemberFound, JsonMemberAbsent, JsonMemberDuplicated, JsonMemberNotAnObject, parse_json_document, json_object_unique_member } @@ -213,7 +213,7 @@ type IdentityTrustStanding // THE TRUST STANDING IS DERIVED FROM THE ROW THE UNIT IS EMITTED FROM, never asserted beside it // (review 67264), AND A LOOPBACK BIND ATTRIBUTES NOTHING (reviews 67306, 67348): -// gunbc.roadmap_dashboard_instance srv1_live_dashboard_instance.listen_host is the one fact +// gunbc.roadmap_dashboard_instance prod_role_realization (the holder's listen_host) is the one fact // live_deploy turns into the unit's --host, so the standing reads that field. Any bind other than // loopback keeps the gate closed: the header is self-assertable by whoever reaches the port. A // loopback bind rules out off-host callers only; an on-host process under another POSIX user can @@ -229,21 +229,34 @@ fn approval_identity_trust_for(listen_host: NonEmptyStr) -> IdentityTrustStandin Ipv4Parsed { address: a } => if ip_address_is_loopback(addr: IpV4 { v4: a }) { IdentityHeaderUnattributed { - residual: join(["gunbc-roadmap.service binds --host ", listen_host as String, " (srv1_live_dashboard_instance.listen_host) behind tailscale serve https://srv1.tailecbe08.ts.net/, so off-host callers cannot reach it; an on-host process under another POSIX user still can, so the header is not attributed and the record names the capability's bearer"], "") as NonEmptyStr, + residual: join(["gunbc-roadmap.service binds --host ", listen_host as String, " (the prod-role holder's listen_host) behind tailscale serve https://srv1.tailecbe08.ts.net/, so off-host callers cannot reach it; an on-host process under another POSIX user still can, so the header is not attributed and the record names the capability's bearer"], "") as NonEmptyStr, } } else { IdentityHeaderTrustNotEstablished { - blocker: join(["gunbc-roadmap.service binds --host ", listen_host as String, " (srv1_live_dashboard_instance.listen_host), so a Tailscale-User-Login header can be set by anyone who reaches the port; the gate opens when the row binds loopback"], "") as NonEmptyStr, + blocker: join(["gunbc-roadmap.service binds --host ", listen_host as String, " (the prod-role holder's listen_host), so a Tailscale-User-Login header can be set by anyone who reaches the port; the gate opens when the row binds loopback"], "") as NonEmptyStr, } } Ipv4ParseFailed { error: _ } => IdentityHeaderTrustNotEstablished { - blocker: join(["srv1_live_dashboard_instance.listen_host '", listen_host as String, "' is not an IPv4 address, so the bind cannot be classified as loopback"], "") as NonEmptyStr, + blocker: join(["the prod-role holder's listen_host '", listen_host as String, "' is not an IPv4 address, so the bind cannot be classified as loopback"], "") as NonEmptyStr, } } } -data approval_identity_trust: IdentityTrustStanding = approval_identity_trust_for(listen_host: srv1_live_dashboard_instance().listen_host) +// THE TRUST OF THE INSTANCE THAT SERVES APPROVALS -- the prod-role holder's realization +// (gunbc.roadmap_dashboard_instance prod_role_realization). Under a refused realization no instance +// serves approvals, so trust is NOT established and names the refusal; it is never srv1's by default. +fn approval_identity_trust_under(realization: ProdRoleRealization) -> IdentityTrustStanding { + match realization { + ProdRoleRealizedBy { instance } => approval_identity_trust_for(listen_host: instance.listen_host) + ProdRoleRealizationRefused { cause } => + IdentityHeaderTrustNotEstablished { + blocker: join(["no instance serves approvals: ", prod_role_realization_refusal_detail(cause: cause)], "") as NonEmptyStr, + } + } +} + +data approval_identity_trust: IdentityTrustStanding = approval_identity_trust_under(realization: prod_role_realization()) // ── the store ────────────────────────────────────────────────────────────────────────────────── diff --git a/dag/gunbc/auth/approval_gate.dag b/dag/gunbc/auth/approval_gate.dag index ffa4126b0ec..bb47d479ab8 100644 --- a/dag/gunbc/auth/approval_gate.dag +++ b/dag/gunbc/auth/approval_gate.dag @@ -19,6 +19,7 @@ import gunbc.auth.approval_decision_store { grant_from_approved_decision, GrantD import gunbc.auth.approval_keyring_pins { approval_submission_mac_key_path_env } import gunbc.auth.approval_request_client { sign_stored_request, approval_submit_url, approval_status_url, approval_poll_standing, + approval_loopback_origin, ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused, approval_loopback_origin_refusal_detail, SubmissionMacReady, SubmissionMacKeyNotHex, ApprovalPollStanding, PollApproved, PollDenied, PollExpired, PollStillPending, PollNotFiled, PollUnreadable, } @@ -185,8 +186,14 @@ fn approval_standing_read(escalation_id: NonEmptyStr, expires_at: Timestamp) -> match approval_instant_now() { Absent => StandingClockUnreadable Present { value: observed } => { - let got = http.Client.GetLocalhostBounded(url: approval_status_url(escalation_id: escalation_id)) - StandingReadAt { standing: approval_poll_standing(status: approval_status_from_get(success: got.success, body: got.body), observed_at: observed, expires_at: expires_at), observed_at: observed } + match approval_loopback_origin() { + ApprovalLoopbackOriginRefused { cause } => + StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_loopback_origin_refusal_detail(cause: cause) }, observed_at: observed, expires_at: expires_at), observed_at: observed } + ApprovalLoopbackOriginResolved { origin } => { + let got = http.Client.GetLocalhostBounded(url: approval_status_url(origin: origin, escalation_id: escalation_id)) + StandingReadAt { standing: approval_poll_standing(status: approval_status_from_get(success: got.success, body: got.body), observed_at: observed, expires_at: expires_at), observed_at: observed } + } + } } } } @@ -246,6 +253,14 @@ type ApprovalFiling | ApprovalFilingRefused { reason: String } fn approval_file_stored_request(stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling +{ + match approval_loopback_origin() { + ApprovalLoopbackOriginRefused { cause } => ApprovalFilingRefused { reason: approval_loopback_origin_refusal_detail(cause: cause) as String } + ApprovalLoopbackOriginResolved { origin } => approval_file_stored_request_at(origin: origin, stored: stored, body_file_name: body_file_name, submit_timeout: submit_timeout) + } +} + +fn approval_file_stored_request_at(origin: NonEmptyStr, stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling { let sub_path = actions_variable_trimmed(name: approval_submission_mac_key_path_env) if sub_path == "" { @@ -267,7 +282,7 @@ fn approval_file_stored_request(stored: StoredApprovalRequest, body_file_name: S if !written.success { ApprovalFilingRefused { reason: join(["could not write the submit body: ", written.error], "") } } else { - let posted = http.Client.PostJsonFromFile(url: approval_submit_url(), request_body_file: body_path as NonEmptyStr, max_seconds: to_string(second_count(s: submit_timeout)) as NonEmptyStr) + let posted = http.Client.PostJsonFromFile(url: approval_submit_url(origin: origin), request_body_file: body_path as NonEmptyStr, max_seconds: to_string(second_count(s: submit_timeout)) as NonEmptyStr) let _wiped = Filesystem.Delete(path: body_path) if submission_body_admits_poll(body: posted.body) { ApprovalFiled } else { ApprovalFilingRefused { reason: join(["POST /approvals refused: ", posted.body], "") } } } diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag index f7aceb0336d..7c687be5c8f 100644 --- a/dag/gunbc/auth/approval_request_client.dag +++ b/dag/gunbc/auth/approval_request_client.dag @@ -16,25 +16,50 @@ import gunbc.auth.approval_request_submission { approval_submission_mac_key_id, sign_submission, } import gunbc.fleet_posix_accounts { fleet_operator_email } -import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } +import gunbc.roadmap_dashboard_instance { + HostDashboardInstance, ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, + prod_role_realization, prod_role_realization_refusal_detail, +} data approval_request_submit_path: NonEmptyStr = "/approvals" -fn approval_loopback_origin() -> NonEmptyStr { - let instance = srv1_live_dashboard_instance() +// THE APPROVALS ARE SERVED BY THE PROD-ROLE HOLDER'S INSTANCE (gunbc.deployment_risk +// prod_role_selection), so the loopback origin is that instance's listen address, resolved through +// gunbc.roadmap_dashboard_instance prod_role_realization. Under NoProdRole nobody serves approvals +// and the origin REFUSES with the realization's typed cause; it never falls back to srv1. +type ApprovalLoopbackOrigin + = ApprovalLoopbackOriginResolved { origin: NonEmptyStr } + | ApprovalLoopbackOriginRefused { cause: ProdRoleRealizationRefusal } + +fn approval_loopback_origin_of(instance: HostDashboardInstance) -> NonEmptyStr { concat( concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port)), ) as NonEmptyStr } -fn approval_submit_url() -> NonEmptyStr { - concat(approval_loopback_origin() as String, approval_request_submit_path as String) as NonEmptyStr +fn approval_loopback_origin_under(realization: ProdRoleRealization) -> ApprovalLoopbackOrigin { + match realization { + ProdRoleRealizedBy { instance } => ApprovalLoopbackOriginResolved { origin: approval_loopback_origin_of(instance: instance) } + ProdRoleRealizationRefused { cause } => ApprovalLoopbackOriginRefused { cause: cause } + } +} + +fn approval_loopback_origin() -> ApprovalLoopbackOrigin { + approval_loopback_origin_under(realization: prod_role_realization()) +} + +fn approval_loopback_origin_refusal_detail(cause: ProdRoleRealizationRefusal) -> NonEmptyStr { + join(["approval broker origin unresolved: ", prod_role_realization_refusal_detail(cause: cause)], "") as NonEmptyStr +} + +fn approval_submit_url(origin: NonEmptyStr) -> NonEmptyStr { + concat(origin as String, approval_request_submit_path as String) as NonEmptyStr } -fn approval_status_url(escalation_id: NonEmptyStr) -> NonEmptyStr { +fn approval_status_url(origin: NonEmptyStr, escalation_id: NonEmptyStr) -> NonEmptyStr { concat( - concat(approval_loopback_origin() as String, concat(approval_request_submit_path as String, "/")), + concat(origin as String, concat(approval_request_submit_path as String, "/")), escalation_id as String, ) as NonEmptyStr } diff --git a/dag/gunbc/fabric/fabric_storage_client.dag b/dag/gunbc/fabric/fabric_storage_client.dag index a6bea7668b4..f4fd60ca074 100644 --- a/dag/gunbc/fabric/fabric_storage_client.dag +++ b/dag/gunbc/fabric/fabric_storage_client.dag @@ -20,7 +20,8 @@ import gunbc.fabric_storage_wire { import gunbc.fabric_storage_file_store { FabricStorageFileRoot, fabric_storage_file_head, fabric_storage_file_put, fabric_storage_file_advance, fabric_storage_file_closure, } -import gunbc.fabric_storage_placement { FabricStoragePlacement, FabricStoragePlaced, FabricStorageUnplaced, fabric_storage_door_socket, fabric_storage_route_prefix } +import gunbc.fabric_storage_placement { FabricStoragePlacement, FabricStoragePlaced, FabricStorageUnplaced, FabricStoragePlacementRefused, fabric_storage_route_prefix } +import gunbc.roadmap_dashboard_instance { prod_role_realization_refusal_detail } import product.placement_supply { HostIdentity, host_identity_eq } // THE FABRIC DB AS A CALLER REACHES IT: std.fabric_storage's four operations, each bound to one of @@ -56,8 +57,9 @@ type FabricStorageBinding fn fabric_storage_binding_for(placement: FabricStoragePlacement, executor: HostIdentity) -> FabricStorageBinding { match placement { FabricStorageUnplaced => FabricStorageBindingUnplaced - FabricStoragePlaced { host: h, store_root: _, endpoint: e } => - if host_identity_eq(a: h, b: executor) { FabricStorageDoorSocket { socket: fabric_storage_door_socket() } } + FabricStoragePlacementRefused { cause } => FabricStorageBindingRefused { detail: prod_role_realization_refusal_detail(cause: cause) as NonEmptyStr } + FabricStoragePlaced { host: h, store_root: _, endpoint: e, door_socket: k } => + if host_identity_eq(a: h, b: executor) { FabricStorageDoorSocket { socket: k } } else { FabricStorageServed { endpoint: e } } } } diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index 68a6c6f9c86..1a26cd2f5c1 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -2,8 +2,11 @@ module gunbc.fabric_storage_placement import std.types { String, NonEmptyStr, FilePath, Int, Bool, Port, List } import product.placement_supply { HostIdentity } -import gunbc.fleet_intent_network { operator_host_srv1, fleet_intent_network } -import gunbc.roadmap_dashboard_instance { HostDashboardInstance, dashboard_instance_fabric_storage_root, srv1_live_dashboard_instance, dashboard_instance_child } +import gunbc.fleet_intent_network { fleet_intent_network } +import gunbc.roadmap_dashboard_instance { + HostDashboardInstance, dashboard_instance_fabric_storage_root, dashboard_instance_child, + ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, +} import std.effect_grant { Read, Write, Execute } import gunbc.ownership { Ensured } import gunbc.managed_directory { @@ -16,8 +19,11 @@ import extdeps.access.posix { FileMode, file_mode_bits } // WHERE THE FABRIC DB IS: one host holds the store's files and answers the endpoint. This is the // POLICY fact of the three (DESIGN 3) -- std.fabric_storage is the interface, the file store and the -// served handler are its realization, and which host and which root is this row. Moving the store is -// a row change here; nothing downstream names the host. +// served handler are its realization. WHICH deployment holds it is not a row here: the store is the +// prod deployment's state, so the placement FOLLOWS gunbc.deployment_risk prod_role_selection through +// gunbc.roadmap_dashboard_instance prod_role_realization. Moving prod moves the store; nothing here or +// downstream names a host. Under NoProdRole the placement REFUSES with the realization's typed cause +// (FabricStoragePlacementRefused); it never falls back to srv1. // // ONE PLACEMENT, NOT A REPLICA SET (operator approval 2026-09-19): a head needs exactly one // linearization point, and an unreachable placement refuses acquisitions rather than failing over. @@ -25,7 +31,8 @@ import extdeps.access.posix { FileMode, file_mode_bits } type FabricStoragePlacement = FabricStorageUnplaced - | FabricStoragePlaced { host: HostIdentity, store_root: NonEmptyStr, endpoint: NonEmptyStr } + | FabricStoragePlacementRefused { cause: ProdRoleRealizationRefusal } + | FabricStoragePlaced { host: HostIdentity, store_root: NonEmptyStr, endpoint: NonEmptyStr, door_socket: NonEmptyStr } // The endpoint is the placed host's tailnet name on the store's own HTTPS listener, derived from the // fleet network's tailnet domain rather than spelled: an unset domain leaves the store unplaced @@ -47,12 +54,12 @@ data fabric_storage_https_port: Port = 10000 // admits exactly the writers declared below. tailscale serve proxies to it as root // (extdeps.tailscale.serve tailscale_serve_unix_target), so a proxied request's peer is root and a // local writer's peer is itself -- the two populations the door tells apart. -fn fabric_storage_door_directory_path() -> NonEmptyStr { - dashboard_instance_child(root: fabric_storage_placed_instance().instance_root, child: "fabric-storage-door") as String as NonEmptyStr +fn fabric_storage_door_directory_path(instance: HostDashboardInstance) -> NonEmptyStr { + dashboard_instance_child(root: instance.instance_root, child: "fabric-storage-door") as String as NonEmptyStr } -fn fabric_storage_door_socket() -> NonEmptyStr { - join([fabric_storage_door_directory_path() as String, "/door.sock"], "") as NonEmptyStr +fn fabric_storage_door_socket(instance: HostDashboardInstance) -> NonEmptyStr { + join([fabric_storage_door_directory_path(instance: instance) as String, "/door.sock"], "") as NonEmptyStr } // The peer the tailscale proxy connects as. tailscaled runs as root, and since v1.98.9 only root may @@ -64,18 +71,22 @@ data fabric_storage_route_prefix: String = "/fabric-storage" // The store root is the placed instance's fabric DB root -- one authority with the deployment that // creates it (gunbc.roadmap_dashboard_instance dashboard_instance_fabric_storage_root). -fn fabric_storage_placed_instance() -> HostDashboardInstance { - srv1_live_dashboard_instance() +fn fabric_storage_store_root(instance: HostDashboardInstance) -> NonEmptyStr { + dashboard_instance_fabric_storage_root(instance: instance) as String as NonEmptyStr } -fn fabric_storage_store_root() -> NonEmptyStr { - dashboard_instance_fabric_storage_root(instance: fabric_storage_placed_instance()) as String as NonEmptyStr +// WHETHER AN INSTANCE HOLDS THE PLACEMENT, over a supplied realization so a witness can move the +// selection: it does exactly when it realizes the prod-role holder, and no instance does under a +// refusal. Identity is the instance's own id, not a path comparison. +fn fabric_storage_placed_on_under(realization: ProdRoleRealization, instance: HostDashboardInstance) -> Bool { + match realization { + ProdRoleRealizedBy { instance: holder } => (instance.instance_id as String) == (holder.instance_id as String) + ProdRoleRealizationRefused { cause: _ } => false + } } -// WHETHER AN INSTANCE HOLDS THE PLACEMENT: the deployment owns the store root and the endpoint only -// there. Identity is the instance's own id, not a path comparison. fn fabric_storage_placed_on(instance: HostDashboardInstance) -> Bool { - (instance.instance_id as String) == (fabric_storage_placed_instance().instance_id as String) + fabric_storage_placed_on_under(realization: prod_role_realization(), instance: instance) } fn fabric_storage_endpoint_for(host: HostIdentity) -> NonEmptyStr? { @@ -85,13 +96,28 @@ fn fabric_storage_endpoint_for(host: HostIdentity) -> NonEmptyStr? { } } -fn fabric_storage_placement() -> FabricStoragePlacement { - match fabric_storage_endpoint_for(host: operator_host_srv1) { - Absent => FabricStorageUnplaced - Present { value: e } => FabricStoragePlaced { host: operator_host_srv1, store_root: fabric_storage_store_root(), endpoint: e } +fn fabric_storage_placement_under(realization: ProdRoleRealization) -> FabricStoragePlacement { + match realization { + ProdRoleRealizationRefused { cause } => FabricStoragePlacementRefused { cause: cause } + ProdRoleRealizedBy { instance } => { + let host = instance.host_identity as String as HostIdentity + match fabric_storage_endpoint_for(host: host) { + Absent => FabricStorageUnplaced + Present { value: e } => FabricStoragePlaced { + host: host, + store_root: fabric_storage_store_root(instance: instance), + endpoint: e, + door_socket: fabric_storage_door_socket(instance: instance), + } + } + } } } +fn fabric_storage_placement() -> FabricStoragePlacement { + fabric_storage_placement_under(realization: prod_role_realization()) +} + // ── WHO WRITES THE STORE, AND SO WHAT ITS DIRECTORIES ARE ──────────────────────────────────────── // // ONE PRINCIPAL WRITES THIS STORE'S FILES: the served endpoint (gunbc.live_deploy.emit @@ -140,24 +166,28 @@ fn fabric_storage_directory(member: NonEmptyStr, path: NonEmptyStr, writes: Bool // twice, by the kernel and by the handler. The service owns it; the CI runner (fleet-converge jobs on // the placed host) is its group and needs only to traverse to connect: the socket itself is opened // to every class by `gunbc serve --unix-socket`, so its directory is the whole filesystem wall. -fn fabric_storage_door_directory() -> ManagedDirectory { +fn fabric_storage_door_dependents() -> List { let service = srv1_dashboard_service_principal() + [ + DirectoryDependent { who: service, needs: [Read, Write, Execute] }, + DirectoryDependent { who: fleet_posix_ci_runner_user, needs: [Execute] }, + ] +} + +fn fabric_storage_door_directory(instance: HostDashboardInstance) -> ManagedDirectory { ManagedDirectory { member: "fabric-storage-door", - path: fabric_storage_door_directory_path() as String as FilePath, - owner: service, + path: fabric_storage_door_directory_path(instance: instance) as String as FilePath, + owner: srv1_dashboard_service_principal(), group_principal: fleet_posix_ci_runner_user, - dependents: [ - DirectoryDependent { who: service, needs: [Read, Write, Execute] }, - DirectoryDependent { who: fleet_posix_ci_runner_user, needs: [Execute] }, - ], + dependents: fabric_storage_door_dependents(), ownership: Ensured, entries: EntriesUnmanaged, } } fn fabric_storage_door_local_writers() -> List { - map(fabric_storage_door_directory().dependents, d => d.who.name) + map(fabric_storage_door_dependents(), d => d.who.name) } // ONE ENTRY MODE FOR THE STORE, OR A REFUSAL. Both writable areas derive it from their own @@ -181,8 +211,8 @@ fn fabric_storage_entry_mode_of(objects: ManagedDirectory, heads: ManagedDirecto } } -fn fabric_storage_entry_mode() -> FabricStorageEntryMode { - let areas = fabric_storage_file_root(root: fabric_storage_store_root()) +fn fabric_storage_entry_mode(store_root: NonEmptyStr) -> FabricStorageEntryMode { + let areas = fabric_storage_file_root(root: store_root) fabric_storage_entry_mode_of( objects: fabric_storage_directory(member: "fabric-storage-objects", path: areas.objects, writes: true), heads: fabric_storage_directory(member: "fabric-storage-heads", path: areas.heads, writes: true), @@ -197,19 +227,28 @@ type FabricStoragePlacedRoot | FabricStoragePlacedRootRefused { detail: NonEmptyStr } fn fabric_storage_placed_file_root(store_root: NonEmptyStr) -> FabricStoragePlacedRoot { - match fabric_storage_entry_mode() { + match fabric_storage_entry_mode(store_root: store_root) { FabricStorageEntryModeRefused { detail } => FabricStoragePlacedRootRefused { detail: detail } FabricStorageEntryModeDerived { mode } => FabricStoragePlacedRootReady { root: fabric_storage_file_root_with(root: store_root, entry_mode: Present { value: mode }) } } } +// THE PLACED INSTANCE'S STORE DIRECTORIES, or none when the placement refuses: an apply under +// NoProdRole ensures no store, rather than ensuring srv1's. fn fabric_storage_store_directories() -> List { - let root = fabric_storage_store_root() + match prod_role_realization() { + ProdRoleRealizationRefused { cause: _ } => [] + ProdRoleRealizedBy { instance } => fabric_storage_store_directories_for(instance: instance) + } +} + +fn fabric_storage_store_directories_for(instance: HostDashboardInstance) -> List { + let root = fabric_storage_store_root(instance: instance) let areas = fabric_storage_file_root(root: root) [ managed_directory_admit(d: fabric_storage_directory(member: "fabric-storage-root", path: root, writes: false)), managed_directory_admit(d: fabric_storage_directory(member: "fabric-storage-objects", path: areas.objects, writes: true)), managed_directory_admit(d: fabric_storage_directory(member: "fabric-storage-heads", path: areas.heads, writes: true)), - managed_directory_admit(d: fabric_storage_door_directory()), + managed_directory_admit(d: fabric_storage_door_directory(instance: instance)), ] } diff --git a/dag/gunbc/fabric/fabric_storage_serve.dag b/dag/gunbc/fabric/fabric_storage_serve.dag index 6ff6fbd40bd..ca574b0b0d4 100644 --- a/dag/gunbc/fabric/fabric_storage_serve.dag +++ b/dag/gunbc/fabric/fabric_storage_serve.dag @@ -12,7 +12,8 @@ import gunbc.fabric_storage_file_store { } import gunbc.fabric_storage_wire { FabricDoorPrincipal, fabric_storage_wire_head, fabric_storage_wire_put, fabric_storage_wire_advance, fabric_storage_wire_closure, fabric_storage_wire_identity } import gunbc.fabric_writer_roster { fabric_door_writer_admission_over, fabric_door_principal_of, fabric_served_writer_roster, fabric_door_local_roster, FabricDoorWriterAdmitted, FabricDoorWriterRefused } -import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlaced, FabricStorageUnplaced, fabric_storage_route_prefix, fabric_storage_placed_file_root, FabricStoragePlacedRootReady, FabricStoragePlacedRootRefused } +import gunbc.roadmap_dashboard_instance { prod_role_realization_refusal_detail } +import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlaced, FabricStorageUnplaced, FabricStoragePlacementRefused, fabric_storage_route_prefix, fabric_storage_placed_file_root, FabricStoragePlacedRootReady, FabricStoragePlacedRootRefused } // THE FABRIC DB ENDPOINT: the store side of the served handler bound to std.fabric_storage. One // `gunbc serve` process on the placed host, compiled once, answers each request by running the @@ -183,7 +184,8 @@ fn fabric_storage_serve_handle_over(root: FabricStorageFileRoot, rosters: Fabric fn fabric_storage_serve_handle(method: String, path: String, body: String, tailscale_identity: String, peer_user: String, release_revision: CommitSha) -> ServeWireResponse { match fabric_storage_placement() { FabricStorageUnplaced => text_response(status: 503, body: "fabric_storage/1 the fabric DB is unplaced\n") - FabricStoragePlaced { host: _, store_root: r, endpoint: _ } => + FabricStoragePlacementRefused { cause } => text_response(status: 503, body: join(["fabric_storage/1 ", prod_role_realization_refusal_detail(cause: cause), "\n"], "")) + FabricStoragePlaced { host: _, store_root: r, endpoint: _, door_socket: _ } => match fabric_storage_placed_file_root(store_root: r) { FabricStoragePlacedRootRefused { detail } => text_response(status: 503, body: join(["fabric_storage/1 the store's entry mode is refused: ", detail as String, "\n"], "")) FabricStoragePlacedRootReady { root } => fabric_storage_serve_handle_over(root: root, rosters: FabricDoorRosters { logins: fabric_served_writer_roster(), locals: fabric_door_local_roster() }, method: method, path: path, body: body, tailscale_identity: tailscale_identity, peer_user: peer_user) diff --git a/dag/gunbc/fabric/fabric_writer_roster.dag b/dag/gunbc/fabric/fabric_writer_roster.dag index 4dee22a71d4..64595d39e85 100644 --- a/dag/gunbc/fabric/fabric_writer_roster.dag +++ b/dag/gunbc/fabric/fabric_writer_roster.dag @@ -6,6 +6,7 @@ import product.placement_supply { HostIdentity } import gunbc.fabric_storage_client { FabricStorageBinding, FabricStorageLocalFiles, fabric_storage_binding_for } import gunbc.fabric_storage_file_store { fabric_storage_file_root } import gunbc.fabric_storage_placement { fabric_storage_placement, fabric_storage_store_root, fabric_storage_door_local_writers, fabric_storage_door_proxy_peer } +import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } import std.fabric_storage { FabricStoreUnreachable, FabricWriterRefusal, WriterLoginAbsent, WriterLoginUnrostered, WriterPeerUnrostered, WriterPeerUnattested } import gunbc.fabric_storage_wire { FabricIdentityPresented, FabricIdentityUnproxied, FabricIdentityReadRefused, FabricDoorPrincipal, DoorProxied, DoorLocalPeer, DoorUnattested } import gunbc.fabric_writer_identity_observe { @@ -50,10 +51,12 @@ fn door_unresolved() -> NonEmptyStr { "could not resolve the placed host's tailnet name (curl exit 6)" as NonEmptyStr } +// RECEIPTS NAME WHAT WAS OBSERVED, not who holds the prod role now: the 2026-09-27 srv1 read was of +// srv1-live's store root, so the row names that instance rather than following prod_role_selection. fn fabric_writer_identity_observations() -> List { [ FabricWriterIdentityObservation { run: "36347411725" as NonEmptyStr, receipt: FabricWriterIdentityReceipt { - host: "srv1" as HostIdentity, binding: FabricStorageLocalFiles { root: fabric_storage_file_root(root: fabric_storage_store_root()) }, + host: "srv1" as HostIdentity, binding: FabricStorageLocalFiles { root: fabric_storage_file_root(root: fabric_storage_store_root(instance: srv1_live_dashboard_instance())) }, read: FabricIdentityUnproxied, observed_at: "2026-09-27T20:29Z" as NonEmptyStr } }, FabricWriterIdentityObservation { run: "36347414620" as NonEmptyStr, receipt: FabricWriterIdentityReceipt { host: "srv3" as HostIdentity, binding: recorded_binding(host: "srv3"), diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index 921aca48e7f..e3b40317f42 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -121,10 +121,9 @@ import gunbc.live_deploy.slice_bounds { repository_convergence_slice_memory_max, repository_convergence_slice_memory_high, repository_convergence_slice_memory_swap_max, } -import gunbc.fabric_storage_placement { fabric_storage_door_socket } import gunbc.live_deploy.spec { deployment_slice_unit_name, deployment_slice_unit_path, - deployment_fabric_storage_unit_name, deployment_fabric_storage_serve_endpoint, deployment_approval_broker_front_door_endpoints, + deployment_fabric_storage_unit_name, deployment_fabric_storage_serve_endpoint, deployment_fabric_storage_door_socket, deployment_approval_broker_front_door_endpoints, gunbc_approval_broker_unit_name, gunbc_approval_broker_unit_path, deployment_owns_the_host_approval_broker, gunbc_approval_broker_slice_unit_name, gunbc_approval_broker_slice_unit_path, @@ -533,7 +532,7 @@ fn live_deploy_serve_unit_file(spec: DeploymentSpec, revision: ReleaseRevisionBi // unbounded hold of the serial accept loop into a typed refusal exactly as they do there. The process // keeps no state between requests -- the store is the files under the ensured fabric DB directory -- // so Restart=on-failure loses nothing. -fn live_deploy_fabric_storage_unit_file(spec: DeploymentSpec, revision: ReleaseRevisionBinding) -> SystemdUnitFile { +fn live_deploy_fabric_storage_unit_file(spec: DeploymentSpec, revision: ReleaseRevisionBinding, door_socket: NonEmptyStr) -> SystemdUnitFile { let svc = spec.service ServiceUnitFile { unit: [ @@ -552,7 +551,7 @@ fn live_deploy_fabric_storage_unit_file(spec: DeploymentSpec, revision: ReleaseR svc.serve_binary as String, " serve --source-root dag --source-root src/v2", " --entry dag/gunbc/fabric/fabric_storage_serve.dag --function fabric_storage_serve_handle", - " --unix-socket ", fabric_storage_door_socket() as String, + " --unix-socket ", door_socket as String, " --release-revision ", release_revision_execstart_text(binding: revision), eval_budget_flag_text(flag: "--eval-budget-cpu-ms", limit: svc.serve_cpu_limit), eval_budget_flag_text(flag: "--eval-budget-wall-ms", limit: svc.serve_wall_limit), @@ -563,13 +562,21 @@ fn live_deploy_fabric_storage_unit_file(spec: DeploymentSpec, revision: ReleaseR } } -fn apply_fabric_storage_unit_write_step(spec: DeploymentSpec, revision: ReleaseRevisionBinding) -> PipelineStep { +fn apply_fabric_storage_unit_write_step(spec: DeploymentSpec, revision: ReleaseRevisionBinding, door_socket: NonEmptyStr) -> PipelineStep { deploy_raw(command: deploy_stage_write_command( stage_name: deployment_fabric_storage_unit_name(names: spec.names) as String, - content: render(doc: doc_text(text: serialize_systemd_unit_file(file: live_deploy_fabric_storage_unit_file(spec: spec, revision: revision))), proto: live_deploy_protocol), + content: render(doc: doc_text(text: serialize_systemd_unit_file(file: live_deploy_fabric_storage_unit_file(spec: spec, revision: revision, door_socket: door_socket))), proto: live_deploy_protocol), )) } +// A fabric storage step for a spec whose instance is not in the roster has no door socket to name; +// the marker is not valid shell, so the stage fails rather than serving an invented path. +data fabric_storage_door_unknown_instance_poison: String = "__GUNBC_DEPLOY_REFUSED__ fabric storage step reached with a deployment whose instance_id resolves to no HostDashboardInstance, so it has no door socket (gunbc.live_deploy.spec deployment_fabric_storage_door_socket). instance=" + +fn fabric_storage_door_unknown_step(spec: DeploymentSpec) -> PipelineStep { + deploy_raw(command: join([fabric_storage_door_unknown_instance_poison, spec.instance_id as String, "\n"], "")) +} + fn emit_systemd_unit_doc(spec: DeploymentSpec, revision: ReleaseRevisionBinding) -> Doc { doc_text(text: serialize_systemd_unit_file(file: live_deploy_serve_unit_file(spec: spec, revision: revision))) } @@ -2805,8 +2812,10 @@ fn emit_artifact_upsert(art: DeploymentArtifactStep, spec: DeploymentSpec, revis TailscaleServeMapping => [ tailscale_serve_apply_step(spec: spec), ] - FabricStorageServeUnit => [ - apply_fabric_storage_unit_write_step(spec: spec, revision: revision), + FabricStorageServeUnit => match deployment_fabric_storage_door_socket(spec: spec) { + Absent => [fabric_storage_door_unknown_step(spec: spec)] + Present { value: door_socket } => [ + apply_fabric_storage_unit_write_step(spec: spec, revision: revision, door_socket: door_socket), deploy_raw(command: deploy_stage_install_command( stage_name: deployment_fabric_storage_unit_name(names: spec.names) as String, mode: install_file_shared_mode, @@ -2815,12 +2824,16 @@ fn emit_artifact_upsert(art: DeploymentArtifactStep, spec: DeploymentSpec, revis deploy_effect(inv: systemctl_daemon_reload_effect()), deploy_effect(inv: systemctl_enable_effect(unit: deployment_fabric_storage_unit_name(names: spec.names))), deploy_effect(inv: systemctl_restart_effect(unit: deployment_fabric_storage_unit_name(names: spec.names))), - ] - FabricStorageServeMapping => [ - deploy_raw(command: shell_privileged_command_text_of_argv( - argv: concat(["tailscale"], tailscale_serve_enable_argv(endpoint: deployment_fabric_storage_serve_endpoint())), - )), - ] + ] + } + FabricStorageServeMapping => match deployment_fabric_storage_door_socket(spec: spec) { + Absent => [fabric_storage_door_unknown_step(spec: spec)] + Present { value: door_socket } => [ + deploy_raw(command: shell_privileged_command_text_of_argv( + argv: concat(["tailscale"], tailscale_serve_enable_argv(endpoint: deployment_fabric_storage_serve_endpoint(door_socket: door_socket))), + )), + ] + } ApprovalBrokerFrontDoor => map( deployment_approval_broker_front_door_endpoints(), e => deploy_raw(command: shell_privileged_command_text_of_argv(argv: concat(["tailscale"], tailscale_serve_enable_argv(endpoint: e)))), @@ -2868,11 +2881,14 @@ fn emit_artifact_teardown(art: DeploymentArtifactStep, spec: DeploymentSpec) -> TailscaleServeMapping => [ tailscale_serve_teardown_step(spec: spec), ] - FabricStorageServeMapping => [ - deploy_raw(command: shell_privileged_command_text_of_argv( - argv: concat(["tailscale"], tailscale_serve_off_argv(endpoint: deployment_fabric_storage_serve_endpoint())), - )), - ] + FabricStorageServeMapping => match deployment_fabric_storage_door_socket(spec: spec) { + Absent => [fabric_storage_door_unknown_step(spec: spec)] + Present { value: door_socket } => [ + deploy_raw(command: shell_privileged_command_text_of_argv( + argv: concat(["tailscale"], tailscale_serve_off_argv(endpoint: deployment_fabric_storage_serve_endpoint(door_socket: door_socket))), + )), + ] + } ApprovalBrokerFrontDoor => map( deployment_approval_broker_front_door_endpoints(), e => deploy_raw(command: shell_privileged_command_text_of_argv(argv: concat(["tailscale"], tailscale_serve_off_argv(endpoint: e)))), @@ -3267,7 +3283,7 @@ fn approval_broker_dark_install_intent( // -- a stage that reports success having installed nothing, which is the absorbing fallback DESIGN // section 5 forbids at exactly the seam where the operator is being told a stage completed. The // marker is not valid shell, so the stage fails and names the owner. -data approval_broker_dark_install_not_owner_poison: String = "__GUNBC_DEPLOY_REFUSED__ approval-broker DARK INSTALL was reached with a deployment that does not own this host's broker unit. gunbc-approval-broker.service is a HOST singleton owned by gunbc.live_deploy.spec gunbc_approval_broker_owning_instance; a twin apply installing it would point the host's broker at the twin's tree and restart it over production's process (review 68094). This marker is not valid deploy shell, so the stage fails loudly rather than emitting a script that installs nothing and reports success. instance=" +data approval_broker_dark_install_not_owner_poison: String = "__GUNBC_DEPLOY_REFUSED__ approval-broker DARK INSTALL was reached with a deployment that does not own this host's broker unit. gunbc-approval-broker.service is a HOST singleton owned by the realization of the prod-role holder (gunbc.live_deploy.spec instance_owns_the_host_approval_broker over gunbc.deployment_risk prod_role_selection), and is owned by nobody under NoProdRole; a twin apply installing it would point the host's broker at the twin's tree and restart it over production's process (review 68094). This marker is not valid deploy shell, so the stage fails loudly rather than emitting a script that installs nothing and reports success. instance=" data approval_broker_dark_install_emit_refused_poison: String = "__GUNBC_ORCH_EMIT_REFUSED__ approval-broker DARK INSTALL intent emission was rejected by v2.compiler.emit_orchestration; this marker is not valid deploy shell, so stage 1 of the cutover fails loudly rather than a hand-spelled fallback masking the refusal (DESIGN section 5: refuse, never widen)\n" diff --git a/dag/gunbc/live_deploy/spec.dag b/dag/gunbc/live_deploy/spec.dag index ea1a8eb61ef..3bd3074a860 100644 --- a/dag/gunbc/live_deploy/spec.dag +++ b/dag/gunbc/live_deploy/spec.dag @@ -45,6 +45,13 @@ import gunbc.fabric_storage_placement { fabric_storage_store_directories, fabric import gunbc.managed_directory { ManagedDirectoryAdmission, managed_directory_admission_path } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, + DashboardInstanceForId, + DashboardInstanceIdUnknown, + dashboard_instance_for_id, + ProdRoleRealization, + ProdRoleRealizedBy, + ProdRoleRealizationRefused, + prod_role_realization, dashboard_instance_dispatch_worktree_root, dashboard_instance_dispatch_attempt_state_root, dashboard_instance_compute_root, @@ -410,20 +417,15 @@ data approval_broker_serve_entry_file: NonEmptyStr = "dag/gunbc/auth/approval_br // // SO OWNERSHIP IS DECLARED AND THE EMITTER READS IT. One instance per host owns the broker unit; // every other spec emits no broker step at all, writes nothing and restarts nothing. -// THE INSTANCE THAT OWNS THE HOST'S BROKER. It is srv1-live because that is where the approval -// store is: gunbc.auth.approval_decision_store approval_decision_store_root is one path on one -// host, and the whole point of a single-writer store is that exactly one process opens it. +// THE INSTANCE THAT OWNS THE HOST'S BROKER IS THE REALIZATION OF THE PROD-ROLE HOLDER +// (gunbc.deployment_risk prod_role_selection, resolved by gunbc.roadmap_dashboard_instance +// prod_role_realization). The approval store is the prod deployment's state -- one single-writer +// path that exactly one process opens -- so the broker follows the role, not a host or an +// instance_id literal. Moving prod is an edit to the selection row and to nothing here. // -// IT IS A DECLARED OWNER RATHER THAN A DERIVATION FROM THE INSTANCE because "which deployment runs -// the approval loop" is not recoverable from any field an instance carries -- srv1-live and -// srv1-lab are the same shape on the same host, and the only thing that distinguishes them for this -// purpose is that one of them was chosen. Deriving it would mean inventing a rule that happens to -// select production today. -// -// A SECOND HOST SERVING APPROVALS IS WHAT RETIRES THIS ROW, at which point the owner becomes a -// per-host lookup and the store root becomes host-derived with it. Until then a single row is the -// honest shape: there is one approval loop, and it has one home. -data gunbc_approval_broker_owning_instance: NonEmptyStr = "srv1-live" +// UNDER NoProdRole NOBODY OWNS IT. The realization refuses with a typed cause and every spec emits +// no broker step; there is no fallback to srv1. A second host serving approvals still retires the +// single-owner shape, at which point ownership becomes per-host. data gunbc_approval_broker_unit_name: NonEmptyStr = "gunbc-approval-broker.service" @@ -453,7 +455,22 @@ fn deployment_owns_the_host_approval_broker(spec: DeploymentSpec) -> Bool { // THE ONE OWNERSHIP COMPARISON, read by the spec-level predicate above and by the front-door member, // which is built before a spec exists and so holds only the instance. fn instance_owns_the_host_approval_broker(instance_id: NonEmptyStr) -> Bool { - (instance_id as String) == (gunbc_approval_broker_owning_instance as String) + instance_owns_the_host_approval_broker_under( + realization: prod_role_realization(), + instance_id: instance_id, + ) +} + +// THE SAME COMPARISON OVER A SUPPLIED REALIZATION, so a witness can move the selection and watch +// ownership follow without editing the production row. +fn instance_owns_the_host_approval_broker_under( + realization: ProdRoleRealization, + instance_id: NonEmptyStr, +) -> Bool { + match realization { + ProdRoleRealizedBy { instance: holder } => (holder.instance_id as String) == (instance_id as String) + ProdRoleRealizationRefused { cause: _ } => false + } } fn gunbc_approval_broker_unit_path() -> NonEmptyStr { @@ -645,11 +662,22 @@ fn deployment_fabric_storage_unit_path(names: DeploymentNames) -> NonEmptyStr { join(["/etc/systemd/system/", deployment_fabric_storage_unit_name(names: names) as String], "") as NonEmptyStr } -fn deployment_fabric_storage_serve_endpoint() -> TailscaleServeEndpoint { +// THE DOOR SOCKET OF THIS SPEC'S OWN INSTANCE, by the roster's id lookup. Apply reaches it only on +// the instance that holds the placement (deployment_fabric_storage_steps); a teardown after prod has +// moved still names the socket of the instance being torn down, not the new holder's. An unknown id +// is Absent and the emitter refuses. +fn deployment_fabric_storage_door_socket(spec: DeploymentSpec) -> NonEmptyStr? { + match dashboard_instance_for_id(instance_id: spec.instance_id as String) { + DashboardInstanceForId { instance } => Present { value: fabric_storage_door_socket(instance: instance) } + DashboardInstanceIdUnknown { instance_id: _ } => none + } +} + +fn deployment_fabric_storage_serve_endpoint(door_socket: NonEmptyStr) -> TailscaleServeEndpoint { TailscaleServeEndpoint { https_port: fabric_storage_https_port, mount: ServeRootMount, - backend: tailscale_serve_unix_target(path: fabric_storage_door_socket()), + backend: tailscale_serve_unix_target(path: door_socket), } } @@ -1085,7 +1113,7 @@ fn deployment_fabric_storage_steps(instance: HostDashboardInstance, names: Deplo if fabric_storage_placed_on(instance: instance) { [ DeploymentArtifactStep { kind: FabricStorageServeUnit, path: deployment_fabric_storage_unit_path(names: names) }, - DeploymentArtifactStep { kind: FabricStorageServeMapping, path: tailscale_serve_route_member_path(endpoint: deployment_fabric_storage_serve_endpoint()) }, + DeploymentArtifactStep { kind: FabricStorageServeMapping, path: tailscale_serve_route_member_path(endpoint: deployment_fabric_storage_serve_endpoint(door_socket: fabric_storage_door_socket(instance: instance))) }, ] } else { [] diff --git a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag index 6a0a44dc22d..8b24360ac47 100644 --- a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag +++ b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag @@ -22,11 +22,17 @@ data a_test_deployment_acts_on_the_prod_deployments_state: RecurringFailureMode "CLIMB STARTED BY THE MODEL PR: the class is derived, the bindings resolve per exact class, and test.claim.deployment_risk_witness_test carries the discriminating RED (a TestRisk deployment refuses rather than resolving the prod topic and app) beside its positive control. That covers the two attachments the binding set carries today and none of the four receipts above, so the row's rung is unchanged by it.", + "PINS REPOINTED BY DELIVERABLE 2: the role-following singletons now resolve the holder through gunbc.roadmap_dashboard_instance prod_role_realization -- one lookup, the instance roster filtered by its own deployment field -- and REFUSE with a typed ProdRoleRealizationRefusal under NoProdRole, an unrealized holder or an ambiguous one: the approval broker owner (gunbc.live_deploy.spec instance_owns_the_host_approval_broker; the srv1-live literal is deleted), fabric storage placement (gunbc.fabric_storage_placement fabric_storage_placement, FabricStoragePlacementRefused; operator_host_srv1 no longer named), the approval client origin (gunbc.auth.approval_request_client approval_loopback_origin), the identity trust (gunbc.auth.approval_decision_store approval_identity_trust_under), and the apply's protected siblings (gunbc.roadmap_dashboard_instance_apply dashboard_protected_siblings, derived by host and class). test.claim.deployment_risk_witness_test moving_the_prod_role_moves_the_broker_owner_and_the_fabric_storage_placement is the RED: a fixture selection moves the owner and the placement, and a mutant that pins the owner back to srv1-live fails it.", + + "PINS REMAINING, each with its reason. (a) The nullary serve, belt, dispatch and launch entry points that call srv1_live_dashboard_instance (gunbc.roadmap_serve roadmap_serve_handle and its wrappers, roadmap_belt_actuate, roadmap_dispatch_actuator, dispatch_preflight, roadmap_launch_deployment_cli) are srv1-live's OWN entry points -- HostDashboardInstance.serve_function names roadmap_serve_handle for srv1-live and a sibling roadmap_serve_handle_ for every other instance -- so they are self-identification, not a prod pin, and repointing them to the holder would make srv1-live act on the new holder's state once prod moves, which is this class. Their honest fix is deliverable 4 (one spec per deployment, no nullary default instance). (b) roadmap_belt_actuate belt_dispatch_instance_cli defaults an absent instance env to srv1-live: a default, not a role, left for the belt owner's lane. (c) gunbc.auth.approval_broker_endpoint approval_broker_host_identity still names srv1-live's host for the GCP IAM converge job's runner label: workflow emission has no typed refusal arm to carry NoProdRole. (d) gunbc.fleet_reach_endpoint fleet_probe_remote_user_for's srv1/srv2 test is which hosts carry the gunbc-fleet account -- a host-shared fact, not a prod pin.", + "NEXT-RUNG TRIGGER: every pin that names the prod deployment directly (the approval broker owner, fabric storage placement, the approval client/store/trust, the serve and dispatch entry points that call srv1_live_dashboard_instance, the apply's prod peer, fleet_reach_endpoint) resolves through prod_role_selection, AND every attachment the srv1 lab shares with prod today (ntfy, Codex account and home, admitted ref, publication identity, R2/GCP/workload identity, OIDC) resolves through DeploymentExternalBindingSet or refuses. One pin or one binding is not sufficient: the class is the route, and any remaining named route is the next receipt.", ], evidence: [ DeclarationRef { module_path: "gunbc.deployment_risk", decl_name: "resolve_deployment_bindings", field: WholeDeclaration }, DeclarationRef { module_path: "test.claim.deployment_risk_witness_test", decl_name: "a_test_deployment_refuses_rather_than_resolving_the_prod_bindings", field: WholeDeclaration }, + DeclarationRef { module_path: "gunbc.roadmap_dashboard_instance", decl_name: "prod_role_realization_in", field: WholeDeclaration }, + DeclarationRef { module_path: "test.claim.deployment_risk_witness_test", decl_name: "moving_the_prod_role_moves_the_broker_owner_and_the_fabric_storage_placement", field: WholeDeclaration }, ], } diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index a13e261c213..d7de6a431ae 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -3,12 +3,16 @@ module gunbc.roadmap_dashboard_instance import extdeps.tools.env { env_absolute_program } import std.types { String, NonEmptyStr, Int, Bool, List, FilePath, Port, GitRef } import std.measure { ByteSize } +import v2.std.integer { integer_int_to_decimal_string } import gunbc.runner_slot_desired { gunbc_runner_slot_desired } import extdeps.tmux { TmuxServerScope, DefaultTmuxServer, NamedTmuxServer } import gunbc.ensure { EnsureObserveOnly } import gunbc.deployment_risk { DeploymentId, DeploymentRiskClass, + ProdRoleSelection, + NoProdRole, + ProdRoleHeldBy, deployment_risk_class, prod_role_selection, srv1_daily_workspace_deployment, @@ -759,6 +763,71 @@ fn dashboard_instance_for_id(instance_id: String) -> DashboardInstanceById { } } +// THE PROD-ROLE HOLDER'S REALIZATION -- the ONE lookup from the role's DeploymentId to the +// HostDashboardInstance that realizes it (gunbc.deployment_risk). It is the instance roster +// filtered by its own deployment field, so there is no second id-to-instance table to drift. +// Every role-following singleton (approval broker owner, fabric storage placement, the +// serve/dispatch entry points) asks THIS, never srv1 by name. Under NoProdRole it REFUSES with a +// typed cause; there is no arm that falls back to srv1, and a holder with zero or several +// realizations is its own refusal rather than "the first". +type ProdRoleRealizationRefusal + = ProdRoleUnheld + | ProdRoleHolderUnrealized { deployment: DeploymentId } + | ProdRoleHolderAmbiguous { deployment: DeploymentId, count: Int } + +type ProdRoleRealization + = ProdRoleRealizedBy { instance: HostDashboardInstance } + | ProdRoleRealizationRefused { cause: ProdRoleRealizationRefusal } + +fn prod_role_realization_in( + selection: ProdRoleSelection, + instances: List, +) -> ProdRoleRealization { + match selection { + NoProdRole => ProdRoleRealizationRefused { cause: ProdRoleUnheld } + ProdRoleHeldBy { deployment: holder } => { + let realizing = instances |> filter(i => i.deployment == holder) + match realizing |> first { + Absent => ProdRoleRealizationRefused { cause: ProdRoleHolderUnrealized { deployment: holder } } + Present { value: only } => + if count(realizing) == 1 { + ProdRoleRealizedBy { instance: only } + } else { + ProdRoleRealizationRefused { cause: ProdRoleHolderAmbiguous { deployment: holder, count: count(realizing) } } + } + } + } + } +} + +// The production question: the real switch over the real roster. +fn prod_role_realization() -> ProdRoleRealization { + prod_role_realization_in(selection: prod_role_selection, instances: public_builtin_dashboard_instances()) +} + +fn prod_role_realization_refusal_detail(cause: ProdRoleRealizationRefusal) -> String { + match cause { + ProdRoleUnheld => "prod role refused: prod_role_selection is NoProdRole; no deployment holds it" + ProdRoleHolderUnrealized { deployment } => + join(["prod role refused: holder ", deployment.name as String, " has no HostDashboardInstance"], "") + ProdRoleHolderAmbiguous { deployment, count: n } => + join(["prod role refused: holder ", deployment.name as String, " has ", integer_int_to_decimal_string(value: n), " instances"], "") + } +} + +// Does THIS instance realize the prod-role holder? False under every refusal: a refused role has +// no holder, so no instance may act as one. +fn instance_realizes_prod_role_in( + selection: ProdRoleSelection, + instances: List, + instance: HostDashboardInstance, +) -> Bool { + match prod_role_realization_in(selection: selection, instances: instances) { + ProdRoleRealizedBy { instance: holder } => (holder.instance_id as String) == (instance.instance_id as String) + ProdRoleRealizationRefused { cause: _ } => false + } +} + data macbook_local_dashboard_id: NonEmptyStr = "macbook-local" as NonEmptyStr data macbook_operator_home_root: FilePath = "/Users/briansrls" as FilePath diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance_apply.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance_apply.dag index 49d0e36dd04..a088cc6c3b1 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance_apply.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance_apply.dag @@ -65,6 +65,7 @@ import gunbc.roadmap_dashboard_instance_observe { } import extdeps.uri { Uri, Http, uri_wire } import extdeps.languages.json.parse { json_text_parseable } +import gunbc.deployment_risk { ProdRoleSelection, ProdRisk, deployment_risk_class, prod_role_selection } import gunbc.roadmap_dashboard_instance { dashboard_instance_health_identity_prefix, DashboardProvisionTransport, @@ -91,7 +92,7 @@ import gunbc.roadmap_dashboard_instance { DashboardInstanceConverged, DashboardInstanceApply, DashboardInstanceRefused, - srv1_live_dashboard_instance, + public_builtin_dashboard_instances, srv1_lab_dashboard_instance, srv2_lab_dashboard_instance, dashboard_instance_provider_state_root, @@ -769,81 +770,90 @@ fn dashboard_production_snapshots_equal( && before.health == after.health } -// PRODUCTION IS A PEER ON THE APPLYING HOST, and until this type existed the parameter forced every -// apply to name one whether the host had one or not. Both uses assume locality without saying so: -// the isolation check compares owned paths on one filesystem, and the liveness read-back curls -// 127.0.0.1 at production's declared port. Passing a remote instance satisfies neither — it -// compares this machine's paths against another machine's declarations, then probes a loopback port -// nobody here is serving.\n\nThat is exactly how srv2's first apply failed. srv1-live was passed as -// production because the parameter demanded an instance; its repo_root happened to exist here as a -// hand-made symlink so the git half of the probe SUCCEEDED, and the refusal surfaced only as a curl -// connection error against port 8080 — a true message about a check that should never have run. The -// remedy for that refusal is not to start a server; it is to stop claiming srv1 is a peer of -// anything on srv2.\n\nNoProductionPeer is a DECLARATION, not a skipped check. A host with no -// production dashboard has nothing to be disjoint from and nothing whose liveness could be -// disturbed, so both obligations are discharged by the shape of the argument rather than silently -// bypassed inside the checks — and the reading below stays type-distinct across before and after, -// so an apply cannot pair a real snapshot with an absent one and call the difference untouched. - -type DashboardProductionPeer - = ProductionPeer { instance: HostDashboardInstance } - | NoProductionPeer { host: NonEmptyStr } - -type DashboardProductionReading - = ProductionRead { snapshot: DashboardProductionSnapshot } - | ProductionUnpeered { host: NonEmptyStr } - -fn dashboard_peer_isolation_ok( +// THE APPLYING HOST'S PROTECTED SIBLINGS, DERIVED -- never passed by name. A sibling is another +// instance on the SAME host (host_identity), and it is protected exactly when its deployment derives +// ProdRisk through gunbc.deployment_risk prod_role_selection. Moving prod moves which sibling an +// apply must stay disjoint from and leave untouched; under NoProdRole no sibling is protected. +// +// Both obligations assume locality: the isolation check compares owned paths on one filesystem, and +// the liveness read-back curls 127.0.0.1 at the sibling's declared port. That is why the population +// is filtered by host rather than taken from the whole roster: srv2's first apply failed when +// srv1-live was passed as its "production peer" -- a hand-made symlink let the git half of the probe +// succeed and the refusal surfaced as a curl error against a port nobody on srv2 serves. A sibling +// on another host is not a sibling, and an empty population is a derived fact, not a skipped check. +// The readings are paired by instance_id across before and after, so an apply cannot pair a real +// snapshot with a missing one and call the difference untouched. +type DashboardProtectedSibling { + instance: HostDashboardInstance +} + +fn dashboard_protected_siblings_in( + selection: ProdRoleSelection, + instances: List, + instance: HostDashboardInstance, +) -> List { + instances + |> filter(s => + (s.host_identity as String) == (instance.host_identity as String) + && !((s.instance_id as String) == (instance.instance_id as String)) + && deployment_risk_class(selection: selection, deployment: s.deployment) == ProdRisk + ) + |> map(s => DashboardProtectedSibling { instance: s }) +} + +fn dashboard_protected_siblings(instance: HostDashboardInstance) -> List { + dashboard_protected_siblings_in( + selection: prod_role_selection, + instances: public_builtin_dashboard_instances(), + instance: instance, + ) +} + +type DashboardProtectedSiblingReading { + instance_id: NonEmptyStr + snapshot: DashboardProductionSnapshot +} + +fn dashboard_sibling_isolation_ok( instance: HostDashboardInstance, - peer: DashboardProductionPeer, + siblings: List, ) -> Bool { - match peer { - ProductionPeer { instance: production } => - dashboard_instances_have_disjoint_owned_paths(a: instance, b: production) - && !(instance.listen_port == production.listen_port) - NoProductionPeer { host: _ } => true - } + all(siblings, s => + dashboard_instances_have_disjoint_owned_paths(a: instance, b: s.instance) + && !(instance.listen_port == s.instance.listen_port) + ) } -fn dashboard_production_read(peer: DashboardProductionPeer) -> DashboardProductionReading { - match peer { - ProductionPeer { instance: production } => - ProductionRead { snapshot: dashboard_production_snapshot(production: production) } - NoProductionPeer { host } => ProductionUnpeered { host: host } - } +fn dashboard_protected_sibling_read(siblings: List) -> List { + map(siblings, s => DashboardProtectedSiblingReading { + instance_id: s.instance.instance_id, + snapshot: dashboard_production_snapshot(production: s.instance), + }) } -fn dashboard_production_reading_refused(reading: DashboardProductionReading) -> Bool { - match reading { - ProductionRead { snapshot } => !snapshot.observed - ProductionUnpeered { host: _ } => false - } +fn dashboard_protected_sibling_reading_refused(readings: List) -> Bool { + any(readings, r => !r.snapshot.observed) } -fn dashboard_production_reading_detail(reading: DashboardProductionReading) -> String { - match reading { - ProductionRead { snapshot } => snapshot.detail - ProductionUnpeered { host } => join(["no production peer on ", host as String], "") +fn dashboard_protected_sibling_reading_detail(readings: List) -> String { + if count(readings) == 0 { + "no protected sibling on this host" + } else { + join(map(readings, r => join([r.instance_id as String, ": ", r.snapshot.detail], "")), "; ") } } -fn dashboard_production_readings_equal( - before: DashboardProductionReading, - after: DashboardProductionReading, +fn dashboard_protected_sibling_readings_equal( + before: List, + after: List, ) -> Bool { - match before { - ProductionRead { snapshot: b } => - match after { - ProductionRead { snapshot: a } => - dashboard_production_snapshots_equal(before: b, after: a) - ProductionUnpeered { host: _ } => false - } - ProductionUnpeered { host: bh } => - match after { - ProductionRead { snapshot: _ } => false - ProductionUnpeered { host: ah } => bh == ah - } - } + count(before) == count(after) + && all(before, b => + count(after |> filter(a => + (a.instance_id as String) == (b.instance_id as String) + && dashboard_production_snapshots_equal(before: b.snapshot, after: a.snapshot) + )) == 1 + ) } fn dashboard_source_anchor_matches( @@ -1301,24 +1311,24 @@ fn dashboard_bootstrap_parent_decision( fn dashboard_instance_apply_preflight( instance: HostDashboardInstance, - production: DashboardProductionPeer, + siblings: List, ) -> DashboardApplyPreflightResult { if !dashboard_instance_paths_are_owned(instance: instance) - || !dashboard_peer_isolation_ok(instance: instance, peer: production) { + || !dashboard_sibling_isolation_ok(instance: instance, siblings: siblings) { dashboard_apply_preflight_refused( instance: instance, step: "isolation-preflight", detail: "instance path or port isolation failed", ) } else { - let production_read = dashboard_production_read(peer: production) - if dashboard_production_reading_refused(reading: production_read) { + let production_read = dashboard_protected_sibling_read(siblings: siblings) + if dashboard_protected_sibling_reading_refused(readings: production_read) { dashboard_apply_preflight_refused( instance: instance, step: "production-preflight", detail: join([ "production read-back refused: ", - dashboard_production_reading_detail(reading: production_read), + dashboard_protected_sibling_reading_detail(readings: production_read), ], ""), ) } else { @@ -1641,11 +1651,11 @@ fn dashboard_apply_refused( fn dashboard_instance_apply_after_preflight( instance: HostDashboardInstance, - production: DashboardProductionPeer, + siblings: List, desired_source_revision: CommitSha, ) -> DashboardInstanceApplyResult { if !dashboard_instance_paths_are_owned(instance: instance) - || !dashboard_peer_isolation_ok(instance: instance, peer: production) { + || !dashboard_sibling_isolation_ok(instance: instance, siblings: siblings) { dashboard_apply_refused( instance: instance, step: "isolation-preflight", @@ -1653,14 +1663,14 @@ fn dashboard_instance_apply_after_preflight( receipts: [], ) } else { - let production_before = dashboard_production_read(peer: production) - if dashboard_production_reading_refused(reading: production_before) { + let production_before = dashboard_protected_sibling_read(siblings: siblings) + if dashboard_protected_sibling_reading_refused(readings: production_before) { dashboard_apply_refused( instance: instance, step: "production-preflight", detail: join([ "production read-back refused before mutation: ", - dashboard_production_reading_detail(reading: production_before), + dashboard_protected_sibling_reading_detail(readings: production_before), ], ""), receipts: [], ) @@ -1718,8 +1728,8 @@ fn dashboard_instance_apply_after_preflight( receipts: applied.receipts, ) } else { - let production_after = dashboard_production_read(peer: production) - let untouched = dashboard_production_readings_equal( + let production_after = dashboard_protected_sibling_read(siblings: siblings) + let untouched = dashboard_protected_sibling_readings_equal( before: production_before, after: production_after, ) @@ -1816,11 +1826,11 @@ fn dashboard_apply_bootstrap_then_escalate( fn dashboard_instance_apply( instance: HostDashboardInstance, - production: DashboardProductionPeer, + siblings: List, ) -> DashboardInstanceApplyResult { match dashboard_instance_apply_preflight( instance: instance, - production: production, + siblings: siblings, ) { DashboardApplyPreflightRefused { instance_id: _, step, detail } => dashboard_apply_refused( @@ -1846,7 +1856,7 @@ fn dashboard_instance_apply( } => dashboard_instance_apply_after_preflight( instance: instance, - production: production, + siblings: siblings, desired_source_revision: source_revision, ) } @@ -1954,14 +1964,14 @@ fn srv2_deploy_ensure_roots_cli() -> ProcessExit { fn srv1_lab_dashboard_apply() -> DashboardInstanceApplyResult { dashboard_instance_apply( instance: srv1_lab_dashboard_instance(), - production: ProductionPeer { instance: srv1_live_dashboard_instance() }, + siblings: dashboard_protected_siblings(instance: srv1_lab_dashboard_instance()), ) } fn srv1_lab_dashboard_preflight() -> DashboardApplyPreflightResult { dashboard_instance_apply_preflight( instance: srv1_lab_dashboard_instance(), - production: ProductionPeer { instance: srv1_live_dashboard_instance() }, + siblings: dashboard_protected_siblings(instance: srv1_lab_dashboard_instance()), ) } @@ -2018,26 +2028,24 @@ fn srv1_lab_dashboard_apply_wet() -> ProcessExit { // witness: the mechanism was real, general, and complete, and it was simply never bound to the // second instance. An instance row declared without an actuation entry reads exactly like one that // has been provisioned and is refusing for a good reason, and the page cannot tell the operator -// which it is looking at.\n\nsrv1's lab passes srv1_live as its peer; srv2's lab passes -// NoProductionPeer, and the difference is not a convenience. An earlier draft of this very row -// asserted the opposite — that srv1_live was the right peer for both labs because /opt/gunbc exists -// on this machine too — and that reasoning was wrong in the way dashboard_production_peer_note now -// records: the paths here are hand-made symlinks, not a production dashboard, so the isolation -// check compared srv2's paths against srv1's declarations and the liveness probe curled a loopback -// port nobody serves. Being disjoint from something that is not there is not a property worth -// checking; declaring that it is not there is. +// which it is looking at.\n\nEach lab's protected siblings are DERIVED (dashboard_protected_siblings): +// today srv1's lab derives srv1-live, the prod-role holder on its host, and srv2's lab derives none. +// An earlier draft asserted srv1_live was the right peer for both labs because /opt/gunbc exists on +// srv2 too; the paths there are hand-made symlinks, not a dashboard, so the isolation check compared +// srv2's paths against srv1's declarations and the liveness probe curled a loopback port nobody +// serves. The host filter is what keeps that from recurring. fn srv2_lab_dashboard_apply() -> DashboardInstanceApplyResult { dashboard_instance_apply( instance: srv2_lab_dashboard_instance(), - production: NoProductionPeer { host: "srv2" as NonEmptyStr }, + siblings: dashboard_protected_siblings(instance: srv2_lab_dashboard_instance()), ) } fn srv2_lab_dashboard_preflight() -> DashboardApplyPreflightResult { dashboard_instance_apply_preflight( instance: srv2_lab_dashboard_instance(), - production: NoProductionPeer { host: "srv2" as NonEmptyStr }, + siblings: dashboard_protected_siblings(instance: srv2_lab_dashboard_instance()), ) } diff --git a/dag/test/claim/approval_decision_store_witness_test.dag b/dag/test/claim/approval_decision_store_witness_test.dag index 3a40330014a..a731a43912c 100644 --- a/dag/test/claim/approval_decision_store_witness_test.dag +++ b/dag/test/claim/approval_decision_store_witness_test.dag @@ -5,7 +5,8 @@ import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } import std.scoped_authorization { AttemptIdentity } import extdeps.crypto.mac { MacKey, HmacSha256, mac_verify, MacVerified, MacTagMismatch, mac_key_material_hex_length, mac_suite_key_size } import std.measure { byte_size_count } -import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } +import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance, prod_role_realization, prod_role_realization_in, public_builtin_dashboard_instances } +import gunbc.deployment_risk { NoProdRole } import extdeps.tailscale.identity { tailnet_identity_of } import extdeps.filesystem.filesystem_io { filesystem_exact_read } import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny, approval_capability_signing_input } @@ -15,7 +16,7 @@ import gunbc.auth.approval_decision_store { StoredApprovalRequest, stored_request_json, parse_stored_record, StoredRequestParsed, StoredDecisionParsed, StoredRecordUnreadable, claims_for, capability_path_segment, parse_capability_segment, CapabilitySegmentParsed, CapabilitySegmentMalformed, - IdentityHeaderTrustEstablished, IdentityHeaderUnattributed, IdentityHeaderTrustNotEstablished, approval_identity_trust, approval_identity_trust_for, + IdentityHeaderTrustEstablished, IdentityHeaderUnattributed, IdentityHeaderTrustNotEstablished, approval_identity_trust, approval_identity_trust_for, approval_identity_trust_under, redeem_over_store, RedemptionIdentityUntrusted, } import extdeps.ietf.http_semantics { POST, GET } @@ -135,7 +136,8 @@ test fn fixture_tags_verify_over_the_stored_claims_and_not_across_decisions() -> // THE TRUST STANDING FOLLOWS THE INSTANCE ROW AND NOTHING ELSE: loopback opens the gate ONLY as // Unattributed (never as Established -- a loopback bind does not attribute the on-host peer, so // the record names the bearer), any other bind or an unparseable host keeps it closed, and the production row is -// exactly the derivation over srv1_live_dashboard_instance.listen_host -- so the gate cannot be +// exactly the derivation over the prod-role holder's listen_host (srv1-live under today's +// selection) -- so the gate cannot be // opened by editing a row beside the one the unit is emitted from. test fn identity_trust_is_derived_from_the_instance_bind() -> Bool { match approval_identity_trust_for(listen_host: "127.0.0.1") { IdentityHeaderUnattributed { residual: _ } => true _ => false } @@ -143,6 +145,8 @@ test fn identity_trust_is_derived_from_the_instance_bind() -> Bool { && match approval_identity_trust_for(listen_host: "0.0.0.0") { IdentityHeaderTrustNotEstablished { blocker: _ } => true _ => false } && match approval_identity_trust_for(listen_host: "srv1.internal") { IdentityHeaderTrustNotEstablished { blocker: _ } => true _ => false } && approval_identity_trust == approval_identity_trust_for(listen_host: srv1_live_dashboard_instance().listen_host) + && approval_identity_trust == approval_identity_trust_under(realization: prod_role_realization()) + && match approval_identity_trust_under(realization: prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances())) { IdentityHeaderTrustNotEstablished { blocker: _ } => true _ => false } } // AN UNTRUSTED IDENTITY HEADER REFUSES BEFORE THE SEGMENT IS EVEN PARSED -- with a well-formed diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag index 721a546e382..c765103be84 100644 --- a/dag/test/claim/approval_request_client_witness_test.dag +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -4,6 +4,7 @@ import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.types { Bool, NonEmptyStr, String } import gunbc.auth.approval_request_client { approval_submit_url, approval_status_url, approval_loopback_origin, approval_request_submit_path, + ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused, approval_poll_standing, PollStillPending, PollApproved, PollExpired, PollUnreadable, StoredApprovalRequest, } @@ -32,12 +33,18 @@ fn fixture_stored() -> StoredApprovalRequest { } } -test fn submit_and_status_urls_follow_the_srv1_instance_listen_bind() -> Bool { +// The real origin is the prod-role holder's listen bind -- srv1-live under today's selection. +test fn submit_and_status_urls_follow_the_prod_role_holders_listen_bind() -> Bool { let instance = srv1_live_dashboard_instance() - let origin = approval_loopback_origin() as String - origin == concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) - && approval_submit_url() as String == concat(origin, approval_request_submit_path as String) - && approval_status_url(escalation_id: "esc-1") as String == concat(concat(origin, concat(approval_request_submit_path as String, "/")), "esc-1") + match approval_loopback_origin() { + ApprovalLoopbackOriginRefused { cause: _ } => false + ApprovalLoopbackOriginResolved { origin: o } => { + let origin = o as String + origin == concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) + && approval_submit_url(origin: o) as String == concat(origin, approval_request_submit_path as String) + && approval_status_url(origin: o, escalation_id: "esc-1") as String == concat(concat(origin, concat(approval_request_submit_path as String, "/")), "esc-1") + } + } } test fn submit_body_nests_the_stored_request_as_a_json_string() -> Bool { diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index f66e74bc3d7..11587a95e28 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -6,6 +6,7 @@ import extdeps.ntfy.ntfy { NtfyTopic } import gunbc.auth.approval_notification { approval_ntfy_topic } import gunbc.auth.github_apps { gunbai_bot_declared, gunbai_ci_declared } import gunbc.deployment_risk { + deployment_id, DeploymentId, DeploymentRiskClass, TestRisk, @@ -36,6 +37,26 @@ import gunbc.roadmap_dashboard_instance { srv2_preview_dashboard_instance, srv2_deploy_dashboard_instance, srv2_lab_dashboard_instance, + ProdRoleRealization, + ProdRoleRealizedBy, + ProdRoleRealizationRefused, + ProdRoleUnheld, + ProdRoleHolderUnrealized, + prod_role_realization, + prod_role_realization_in, + public_builtin_dashboard_instances, +} +import gunbc.live_deploy.spec { instance_owns_the_host_approval_broker_under } +import gunbc.fabric_storage_placement { + FabricStoragePlacementRefused, + fabric_storage_placed_on_under, + fabric_storage_placement_under, +} +import gunbc.auth.approval_request_client { + ApprovalLoopbackOriginResolved, + ApprovalLoopbackOriginRefused, + approval_loopback_origin_under, + approval_loopback_origin_of, } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -141,3 +162,86 @@ test fn exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_wor test fn the_wrong_instance_failure_mode_row_cites_this_red() -> Bool { any(a_test_deployment_acts_on_the_prod_deployments_state.evidence, r => r.decl_name == "a_test_deployment_refuses_rather_than_resolving_the_prod_bindings") } + +// ── The role-following singletons: broker owner, fabric storage placement, approval origin ────── +// THE DISCRIMINATING RED (deployment-risk plan, deliverable 2). A FIXTURE selection moves the role +// to srv2-deploy over the REAL instance roster, and every singleton that follows the role moves with +// it: the broker owner is srv2-deploy and no longer srv1-live, the fabric store is placed on +// srv2-deploy and not on srv1-live, and approvals are reached at srv2-deploy's listen address. A pin +// back to srv1 by name -- an instance_id literal, srv1_live_dashboard_instance(), operator_host_srv1 +// -- keeps answering srv1-live and fails the srv1-negative conjuncts. +fn fx_moved_realization() -> ProdRoleRealization { + prod_role_realization_in( + selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }, + instances: public_builtin_dashboard_instances(), + ) +} + +test fn moving_the_prod_role_moves_the_broker_owner_and_the_fabric_storage_placement() -> Bool { + let moved = fx_moved_realization() + let srv2 = srv2_deploy_dashboard_instance() + let srv1 = srv1_live_dashboard_instance() + let origin_moved = match approval_loopback_origin_under(realization: moved) { + ApprovalLoopbackOriginResolved { origin } => + (origin as String) == (approval_loopback_origin_of(instance: srv2) as String) + && !((origin as String) == (approval_loopback_origin_of(instance: srv1) as String)) + ApprovalLoopbackOriginRefused { cause: _ } => false + } + instance_owns_the_host_approval_broker_under(realization: moved, instance_id: srv2.instance_id) + && !instance_owns_the_host_approval_broker_under(realization: moved, instance_id: srv1.instance_id) + && fabric_storage_placed_on_under(realization: moved, instance: srv2) + && !fabric_storage_placed_on_under(realization: moved, instance: srv1) + && origin_moved +} + +// UNDER NoProdRole EVERY ROLE-FOLLOWER REFUSES WITH THE TYPED CAUSE -- nobody owns the broker, the +// store is not placed anywhere, and approvals have no origin. A fallback to srv1 fails every conjunct. +test fn under_no_prod_role_the_broker_owner_and_the_fabric_storage_placement_refuse() -> Bool { + let none = prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances()) + let realization_refused = match none { + ProdRoleRealizationRefused { cause: ProdRoleUnheld } => true + _ => false + } + let placement_refused = match fabric_storage_placement_under(realization: none) { + FabricStoragePlacementRefused { cause: ProdRoleUnheld } => true + _ => false + } + let origin_refused = match approval_loopback_origin_under(realization: none) { + ApprovalLoopbackOriginRefused { cause: ProdRoleUnheld } => true + _ => false + } + realization_refused + && placement_refused + && origin_refused + && all(public_builtin_dashboard_instances(), i => + !instance_owns_the_host_approval_broker_under(realization: none, instance_id: i.instance_id) + && !fabric_storage_placed_on_under(realization: none, instance: i) + ) +} + +// A HOLDER WITH NO REALIZATION REFUSES TOO, rather than resolving "the first instance". +test fn a_prod_role_holder_with_no_instance_refuses_as_unrealized() -> Bool { + let ghost = deployment_id(name: "no-such-deployment" as NonEmptyStr) + match prod_role_realization_in(selection: ProdRoleHeldBy { deployment: ghost }, instances: public_builtin_dashboard_instances()) { + ProdRoleRealizationRefused { cause: ProdRoleHolderUnrealized { deployment: d } } => d == ghost + _ => false + } +} + +// THE POSITIVE CONTROL, over the REAL selection and the production entry points: today srv1-live +// realizes the holder, owns the broker and holds the placement, and srv1's lab does neither. Without +// it the REDs above would pass for resolvers that refuse everyone. +test fn the_real_selection_places_the_broker_and_the_store_on_its_holder() -> Bool { + let real = prod_role_realization() + let live = srv1_live_dashboard_instance() + let lab = srv1_lab_dashboard_instance() + let holder_is_live = match real { + ProdRoleRealizedBy { instance } => (instance.instance_id as String) == (live.instance_id as String) + ProdRoleRealizationRefused { cause: _ } => false + } + holder_is_live + && instance_owns_the_host_approval_broker_under(realization: real, instance_id: live.instance_id) + && !instance_owns_the_host_approval_broker_under(realization: real, instance_id: lab.instance_id) + && fabric_storage_placed_on_under(realization: real, instance: live) + && !fabric_storage_placed_on_under(realization: real, instance: lab) +} diff --git a/dag/test/claim/fabric/fabric_writer_roster_witness_test.dag b/dag/test/claim/fabric/fabric_writer_roster_witness_test.dag index 6d19e2dd216..7e8ce2275b8 100644 --- a/dag/test/claim/fabric/fabric_writer_roster_witness_test.dag +++ b/dag/test/claim/fabric/fabric_writer_roster_witness_test.dag @@ -14,6 +14,7 @@ import gunbc.fabric_writer_roster { fabric_door_writer_admission_over, fabric_door_principal_of, fabric_door_local_roster, FabricDoorWriterAdmitted, FabricDoorWriterRefused, } import gunbc.fabric_storage_placement { fabric_storage_directory, fabric_storage_store_root, fabric_storage_door_directory } +import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } import gunbc.managed_directory { ManagedDirectory, managed_directory_permissions, dependent_class_of, OwnerClass, GroupClass, OtherClass } import gunbc.fleet_posix_accounts { fleet_posix_operator_user, fleet_posix_ci_runner_user } @@ -116,7 +117,7 @@ fn class_writes(d: ManagedDirectory, who: PosixUser) -> Bool { } test fn the_door_admits_the_operator_and_the_ci_runner_locally_and_no_one_else() -> Bool { - let door = fabric_storage_door_directory() + let door = fabric_storage_door_directory(instance: srv1_live_dashboard_instance()) let roster = fabric_door_local_roster() class_traverses(d: door, who: fleet_posix_operator_user) && class_traverses(d: door, who: fleet_posix_ci_runner_user) && class_traverses(d: door, who: outsider()) == false @@ -126,7 +127,7 @@ test fn the_door_admits_the_operator_and_the_ci_runner_locally_and_no_one_else() } test fn the_placed_store_areas_admit_no_writer_but_the_service() -> Bool { - let areas = fabric_storage_file_root(root: fabric_storage_store_root()) + let areas = fabric_storage_file_root(root: fabric_storage_store_root(instance: srv1_live_dashboard_instance())) let objects = fabric_storage_directory(member: "fabric-storage-objects", path: areas.objects, writes: true) let heads = fabric_storage_directory(member: "fabric-storage-heads", path: areas.heads, writes: true) class_writes(d: objects, who: fleet_posix_operator_user) && class_writes(d: heads, who: fleet_posix_operator_user) diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index 02e3cf83b37..ccd348d257e 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -10,7 +10,7 @@ import test.claim.live_deploy.apply_emission_projection { pipeline_steps_text, a import test.claim.live_deploy.deploy_release_fixture { deploy_witness_executable_digest, deploy_witness_release_revision, deploy_witness_other_release_revision, deploy_witness_full_install_plan, deploy_witness_release_target } import std.types { Bool, String, List, NonEmptyStr, Int } import std.os.types { Apt } -import gunbc.fabric_storage_placement { fabric_storage_door_socket, fabric_storage_door_directory_path, fabric_storage_https_port, fabric_storage_entry_mode, FabricStorageEntryModeDerived, FabricStorageEntryModeRefused } +import gunbc.fabric_storage_placement { fabric_storage_door_socket, fabric_storage_door_directory_path, fabric_storage_store_root, fabric_storage_https_port, fabric_storage_entry_mode, FabricStorageEntryModeDerived, FabricStorageEntryModeRefused } import extdeps.access.posix { file_mode_octal } import gunbc.fabric_storage_file_store { fabric_storage_file_root } import extdeps.systemd.unit_file { serialize_systemd_unit_file } @@ -26,6 +26,7 @@ import gunbc.live_deploy.spec { deployment_fabric_storage_steps, deployment_approval_broker_front_door_steps, deployment_fabric_storage_serve_endpoint, + deployment_fabric_storage_door_socket, EnsuredPackage, EnsuredManagedHostDirectory, TailscalePackage, @@ -1363,7 +1364,7 @@ test fn the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_ let files = fabric_storage_file_root(root: dashboard_instance_fabric_storage_root(instance: srv1_live_dashboard_instance()) as String as NonEmptyStr) let objects = srv1_fabric_area_ensure(path: files.objects as String) let heads = srv1_fabric_area_ensure(path: files.heads as String) - let door = srv1_fabric_area_ensure(path: fabric_storage_door_directory_path() as String) + let door = srv1_fabric_area_ensure(path: fabric_storage_door_directory_path(instance: srv1_live_dashboard_instance()) as String) string_contains(s: objects, pattern: "0700") && string_contains(s: objects, pattern: "briansrls") && !string_contains(s: objects, pattern: "ghrunner") && !string_contains(s: objects, pattern: "REFUSED") && string_contains(s: heads, pattern: "0700") && !string_contains(s: heads, pattern: "ghrunner") && !string_contains(s: heads, pattern: "REFUSED") && string_contains(s: door, pattern: "2710") && string_contains(s: door, pattern: "ghrunner") && !string_contains(s: door, pattern: "REFUSED") @@ -1393,7 +1394,7 @@ test fn the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone() - // the refusal when a directory grants o+x, are witnessed on synthetic principals in // test.claim.managed_directory_witness. test fn the_placed_fabric_store_derives_entry_mode_0400() -> Bool { - match fabric_storage_entry_mode() { + match fabric_storage_entry_mode(store_root: fabric_storage_store_root(instance: srv1_live_dashboard_instance())) { FabricStorageEntryModeDerived { mode } => file_mode_octal(mode: mode) == "0400" FabricStorageEntryModeRefused { detail: _ } => false } @@ -1785,14 +1786,17 @@ test fn the_receipt_command_substitutes_the_digest_rather_than_splicing_it() -> // port any local account could reach with a forged identity header), and its route is tailscale // serve's 10000 listener at the root mount, proxying to exactly that socket. test fn the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it() -> Bool { - let unit = serialize_systemd_unit_file(file: live_deploy_fabric_storage_unit_file(spec: deployment_spec_srv1(), revision: RevisionBoundAtEmission { revision: deploy_witness_release_revision })) - let route = deployment_fabric_storage_serve_endpoint() + let socket = fabric_storage_door_socket(instance: srv1_live_dashboard_instance()) + let unit = serialize_systemd_unit_file(file: live_deploy_fabric_storage_unit_file(spec: deployment_spec_srv1(), revision: RevisionBoundAtEmission { revision: deploy_witness_release_revision }, door_socket: socket)) + let route = deployment_fabric_storage_serve_endpoint(door_socket: socket) + let derived = match deployment_fabric_storage_door_socket(spec: deployment_spec_srv1()) { Present { value: k } => (k as String) == (socket as String) Absent => false } unit.contains("--function fabric_storage_serve_handle") - && unit.contains(join(["--unix-socket ", fabric_storage_door_socket() as String], "")) + && derived + && unit.contains(join(["--unix-socket ", socket as String], "")) && !unit.contains("--host") && !unit.contains("--port") && route.https_port == fabric_storage_https_port - && (route.backend as String) == join(["unix:", fabric_storage_door_socket() as String], "") + && (route.backend as String) == join(["unix:", socket as String], "") && (match route.mount { ServeRootMount => true ServeSetPath { path: _ } => false }) } diff --git a/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag b/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag index 3d169d4d871..11ad6b887e7 100644 --- a/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag @@ -1,6 +1,7 @@ module test.claim.roadmap_dashboard_instance_apply_witness -import std.types { Bool, String, NonEmptyStr, FilePath, GitRef } +import std.types { Bool, String, NonEmptyStr, FilePath, GitRef, List } +import gunbc.deployment_risk { NoProdRole, ProdRoleHeldBy, prod_role_selection, srv2_deploy_deployment } import extdeps.git { shape_git_bundle_create_argv, shape_git_is_shallow_repository_argv, @@ -21,6 +22,9 @@ import gunbc.host_layout { srv1_codex_bundled_executable } import gunbc.roadmap_dashboard_instance { srv1_lab_dashboard_instance, srv1_live_dashboard_instance, + srv2_lab_dashboard_instance, + srv2_deploy_dashboard_instance, + public_builtin_dashboard_instances, dashboard_instance_provider_state_root, dashboard_instance_provider_executable, dashboard_instances_have_disjoint_owned_paths, @@ -28,18 +32,16 @@ import gunbc.roadmap_dashboard_instance { dashboard_instance_dispatch_platform_executables, } import gunbc.roadmap_dashboard_instance_apply { - DashboardProductionPeer, - ProductionPeer, - NoProductionPeer, - DashboardProductionReading, - ProductionRead, - ProductionUnpeered, + DashboardProtectedSibling, + DashboardProtectedSiblingReading, + dashboard_protected_siblings, + dashboard_protected_siblings_in, DashboardProductionSnapshot, - dashboard_peer_isolation_ok, - dashboard_production_read, - dashboard_production_reading_refused, - dashboard_production_reading_detail, - dashboard_production_readings_equal, + dashboard_sibling_isolation_ok, + dashboard_protected_sibling_read, + dashboard_protected_sibling_reading_refused, + dashboard_protected_sibling_reading_detail, + dashboard_protected_sibling_readings_equal, DashboardProvisionStepReceipt, DashboardInstanceApplyResult, DashboardApplyRefused, @@ -583,61 +585,59 @@ test fn dashboard_unreachable_root_refuses_before_choosing_a_parent() -> Bool { } } -// THE ARM THAT MATTERS IS THE MIXED ONE. An unpeered host has no production liveness to compare, so the tempting shortcut is to let an absent reading compare equal to anything and move on — which would let an apply that STARTED with production served and ENDED with it unreachable report untouched, the precise reading the post-apply check exists to prevent. Equality is therefore required to agree on the arm as well as the contents: two reads compare their snapshots, two unpeered readings compare their hosts, and one of each is never equal regardless of what happened in between.\n\nThe isolation claim is the counterpart. NoProductionPeer discharges the disjointness obligation because there is no peer to overlap with, but it must not discharge the instance's OWN ownership requirement — an unowned path is unowned whether or not a production dashboard shares the machine, and preflight checks the two separately for that reason. -fn witness_peered() -> DashboardProductionPeer { - ProductionPeer { instance: srv1_live_dashboard_instance() } +// THE MISSING READING IS THE ONE THAT MATTERS. A host with no protected sibling has no liveness to +// compare; the tempting shortcut is to let a missing reading compare equal to anything, which would +// let an apply that STARTED with the sibling served and ENDED with it unreachable report untouched. +// So readings pair by instance_id: two equal populations compare equal, two empty ones compare +// equal, and a sibling present on one side only is never equal.\n\nThe isolation claim is the +// counterpart. An empty sibling population discharges disjointness because there is nothing to +// overlap with, but it must not discharge the instance's OWN ownership requirement. +fn witness_sibling_reading() -> DashboardProtectedSiblingReading { + DashboardProtectedSiblingReading { + instance_id: srv1_live_dashboard_instance().instance_id, + snapshot: DashboardProductionSnapshot { observed: true, head: "abc123", health: "ok", detail: "" }, + } } -fn witness_unpeered() -> DashboardProductionPeer { - NoProductionPeer { host: "srv2" as NonEmptyStr } +test fn protected_sibling_readings_never_equate_absent_with_observed() -> Bool { + let read = [witness_sibling_reading()] + let none: List = [] + dashboard_protected_sibling_readings_equal(before: read, after: read) + && dashboard_protected_sibling_readings_equal(before: none, after: none) + && !dashboard_protected_sibling_readings_equal(before: read, after: none) + && !dashboard_protected_sibling_readings_equal(before: none, after: read) } -fn witness_reading_read() -> DashboardProductionReading { - ProductionRead { - snapshot: DashboardProductionSnapshot { - observed: true, - head: "abc123", - health: "ok", - detail: "", - }, - } +test fn a_host_without_a_protected_sibling_refuses_nothing_and_reads_nothing() -> Bool { + let none = dashboard_protected_sibling_read(siblings: dashboard_protected_siblings(instance: srv2_lab_dashboard_instance())) + count(none) == 0 + && !dashboard_protected_sibling_reading_refused(readings: none) + && string_contains(s: dashboard_protected_sibling_reading_detail(readings: none), pattern: "no protected sibling") } -test fn production_reading_never_equates_absent_with_observed() -> Bool { - let read = witness_reading_read() - let unpeered = ProductionUnpeered { host: "srv2" as NonEmptyStr } - dashboard_production_readings_equal(before: read, after: read) - && dashboard_production_readings_equal(before: unpeered, after: unpeered) - && !dashboard_production_readings_equal(before: read, after: unpeered) - && !dashboard_production_readings_equal(before: unpeered, after: read) - && !dashboard_production_readings_equal( - before: unpeered, - after: ProductionUnpeered { host: "srv1" as NonEmptyStr }, - ) +// THE SIBLING IS DERIVED FROM THE SELECTION AND THE HOST. Today srv1's lab is protected from +// srv1-live and srv2's lab from nothing; move the role to srv2-deploy and the protection moves with +// it; under NoProdRole no lab has a protected sibling. A sibling list passed by name would fail the +// moved-selection conjuncts. +fn sibling_ids(xs: List) -> List { + map(xs, x => x.instance.instance_id as String) } -test fn unpeered_host_refuses_nothing_and_reads_nothing() -> Bool { - !dashboard_production_reading_refused(reading: dashboard_production_read(peer: witness_unpeered())) - && string_contains( - s: dashboard_production_reading_detail( - reading: dashboard_production_read(peer: witness_unpeered()), - ), - pattern: "no production peer on srv2", - ) +test fn protected_siblings_follow_the_prod_role_selection_within_the_host() -> Bool { + let roster = public_builtin_dashboard_instances() + let today = prod_role_selection + let moved = ProdRoleHeldBy { deployment: srv2_deploy_deployment } + sibling_ids(xs: dashboard_protected_siblings_in(selection: today, instances: roster, instance: srv1_lab_dashboard_instance())) == [srv1_live_dashboard_instance().instance_id as String] + && count(dashboard_protected_siblings_in(selection: today, instances: roster, instance: srv2_lab_dashboard_instance())) == 0 + && count(dashboard_protected_siblings_in(selection: moved, instances: roster, instance: srv1_lab_dashboard_instance())) == 0 + && sibling_ids(xs: dashboard_protected_siblings_in(selection: moved, instances: roster, instance: srv2_lab_dashboard_instance())) == [srv2_deploy_dashboard_instance().instance_id as String] + && count(dashboard_protected_siblings_in(selection: NoProdRole, instances: roster, instance: srv1_lab_dashboard_instance())) == 0 } -test fn peer_isolation_still_requires_the_instance_to_own_its_paths() -> Bool { - dashboard_peer_isolation_ok( - instance: srv1_lab_dashboard_instance(), - peer: witness_peered(), - ) - && dashboard_peer_isolation_ok( - instance: srv1_lab_dashboard_instance(), - peer: witness_unpeered(), - ) - && !dashboard_peer_isolation_ok( - instance: srv1_live_dashboard_instance(), - peer: witness_peered(), - ) +test fn sibling_isolation_still_requires_the_instance_to_own_its_paths() -> Bool { + let srv1_siblings = dashboard_protected_siblings(instance: srv1_lab_dashboard_instance()) + dashboard_sibling_isolation_ok(instance: srv1_lab_dashboard_instance(), siblings: srv1_siblings) + && dashboard_sibling_isolation_ok(instance: srv1_lab_dashboard_instance(), siblings: []) + && !dashboard_sibling_isolation_ok(instance: srv1_live_dashboard_instance(), siblings: srv1_siblings) } diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index eb26fab642e..3729ffd4c32 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -20,16 +20,18 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli 1. **Model** (this PR): `gunbc.deployment_risk`; `HostDashboardInstance.deployment` (required, all six constructors); `dashboard_instance_risk_class`; the failure-mode row `a_test_deployment_acts_on_the_prod_deployments_state`; RED `test.claim.deployment_risk_witness_test`. - Declared frontier (§3c): `resolve_deployment_bindings` and `dashboard_instance_risk_class` gain production consumers in deliverables 2 and 3.1. - - Deferred to deliverable 2 (review 74937): `DeploymentRiskInterface` and `DeploymentHarmMagnitudeBet` land with their first executing consumer, the operator-disposition gate (which actions on a deployment need an operator), and the bet's magnitude is grounded as an order of magnitude over `std.measure` `Duration`, not a bare `Int`. + - Declared frontier (review 74937; merry-tern-58 ruling 2026-10-04): `DeploymentRiskInterface`, `DeploymentHarmMagnitudeBet` and the operator-disposition gate are **deferred until an operator policy differs by class**. Today's policy (gunbc#13124: automatic repair is "no" for every deployment) is class-independent, so a gate consulting the class would decide nothing (§3c decoration), and a class-dependent policy is the operator's call. No carrier is minted before then. Design note for when the bet lands: its magnitude is an order of magnitude over `Duration` (today `std.types` `Duration`; std has no order-of-magnitude carrier yet), e.g. a decade exponent beside its unit, typed with `extdeps.external_authority` `CitedFigureStanding`, and it never decides the class. 1b. **Machine qualification migration**: one motion over machine_intake (re-census on main first; coordinate warm-crane-577's mtcollins1 untangle lane). eager-gull-22 handed it to this lane. 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. + - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous`). Consumers: broker owner (`instance_owns_the_host_approval_broker`, the `"srv1-live"` literal deleted), fabric storage placement (`FabricStoragePlacementRefused`; `operator_host_srv1` no longer named), approval client origin, identity trust, and `DashboardProtectedSibling` (same-host siblings whose class is `ProdRisk`). RED in `test.claim.deployment_risk_witness_test`. + - Not repointed, with reasons (failure-mode row carries them): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`), so they move to per-deployment names in deliverable 4, not to the role; `belt_dispatch_instance_cli`'s absent-env default; `approval_broker_host_identity` (workflow runner label, no refusal arm in emission); `fleet_reach_endpoint` (host-shared account fact, not a prod pin). 3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. 4. **One spec per deployment**; retire the `live_deploy.desired` rung drop by its own trigger. Renames: `srv1_production_desired_deployment → srv1_primary_desired_deployment`, `roadmap_belt_production_spawn_mode → roadmap_belt_operational_spawn_mode`. 5. **Conformance row** `deployment-risk conformance` in `gunbc.design_argument conformance_domains`, once the home is consumed. Widened scope (side chat): classify every convergence subject as deployment-bound (Spark serving/experiments/seat grants, fabric storage, approval broker, R2 keyed by class + `BucketPurpose`, IAM targets, DNS/routes/certs, runner workloads, mutable caches), host-shared (Spark Wi-Fi/SSH/admin grants, KVM, base networking, toolchains), or fleet-control (root IAM writer, account admin). Pure computation identity and content-addressed outputs gain no deployment key. Manual convergence requests take `DeploymentConvergence { deployment } | HostResourceConvergence { host, resource } | FleetControlConvergence { operation }`. -## Shared-binding table (as of deliverable 1) +## Shared-binding table (as of deliverable 2) | Binding | State | Reason | |---|---|---| @@ -38,4 +40,7 @@ Widened scope (side chat): classify every convergence subject as deployment-boun | Codex account and home | still shared | 3.2 | | admitted ref | still shared (labs on `refs/fleet/desired`) | 3.3 | | R2 / GCP / WIF | still shared | 3.5; coordinate royal-moth-86 (#13035) | +| approval broker unit (singleton) | follows the role via `prod_role_realization`; NoProdRole → no owner | deliverable 2 | +| fabric storage placement (singleton) | follows the role; NoProdRole → `FabricStoragePlacementRefused` | deliverable 2 | +| approval loopback origin / identity trust | follows the role; NoProdRole → origin refused, trust not established | deliverable 2 | | OIDC client | still shared, unmodeled | 3.6 | From 392b28e2b98d3e3dcd36ee3f202f15f256e8e0ad Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 04:01:05 +0000 Subject: [PATCH 08/48] deployment_risk: DeploymentId identifies the Deployment, it is not the subject (side-chat ruling) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/deployment_risk.dag | 9 +++++---- dag/gunbc/roadmap/roadmap_dashboard_instance.dag | 2 +- docs/plans/deployment-risk-conformance.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dag/gunbc/deployment_risk.dag b/dag/gunbc/deployment_risk.dag index 156be94dc22..98c76e0c373 100644 --- a/dag/gunbc/deployment_risk.dag +++ b/dag/gunbc/deployment_risk.dag @@ -29,10 +29,11 @@ import gunbc.auth.github_apps { DeclaredGitHubApp, gunbai_bot_declared } // MachineOperatingEnvironment lands in its own one-motion migration on top of this module. // ── The subject ───────────────────────────────────────────────────────────────────────────────── -// ONE CONVERGENCE SUBJECT whose desired state, mutable state and external attachments move -// together. Not a host, a machine, a process, a release stage, or a dashboard instance_id: an -// instance (gunbc.roadmap_dashboard_instance HostDashboardInstance) is a realization of a -// deployment and carries this id, it does not stand for it. A record rather than a branded string +// THE STABLE IDENTITY OF ONE Deployment -- the convergence subject whose desired state, mutable +// state and external attachments move together. The id identifies the Deployment; it is not the +// Deployment. Not a host, a machine, a process, a release stage, or a dashboard instance_id: an +// instance (gunbc.roadmap_dashboard_instance HostDashboardInstance) realizes that Deployment and +// carries this identity, it does not stand for it. A record rather than a branded string // so equality is structural and cannot be satisfied by an unrelated string of the same spelling. type DeploymentId { name: NonEmptyStr diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index a13e261c213..0054151469a 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -195,7 +195,7 @@ type DispatchStateScope attempt_state_root: FilePath, } -// deployment IS THE CONVERGENCE SUBJECT THIS INSTANCE REALIZES (gunbc.deployment_risk). It is a +// deployment IDENTIFIES THE CONVERGENCE SUBJECT THIS INSTANCE REALIZES (gunbc.deployment_risk). It is a // required field so every constructor answers it; the instance's risk class is DERIVED from it // through prod_role_selection (dashboard_instance_risk_class) and never from instance_id, host, // or a constructor's name -- srv1_live is a migration-era name and determines nothing. diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index eb26fab642e..0aaf1789486 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -6,7 +6,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli | Term | Meaning | Is NOT | |---|---|---| -| `DeploymentId` | one convergence subject whose desired state, mutable state and external attachments move together; an instance realizes it | a host, machine, process, release stage, dashboard `instance_id` | +| `DeploymentId` | the stable identity of one Deployment whose desired state, mutable state, and external attachments move together; an instance realizes that Deployment and carries this identity | a host, machine, process, release stage, dashboard `instance_id` | | `DeploymentRiskClass = TestRisk \| ProdRisk` | coarse harm class, DERIVED from `ProdRoleSelection` only | a number, machine qualification, stage, branch, host | | `DeploymentHarmMagnitudeBet` | Fermi order of magnitude of harm conditional on doing the wrong thing, typed as a §4d bet | a probability, a fact, the class, an SLA | | `ProdRoleSelection = NoProdRole \| ProdRoleHeldBy { deployment }` | the sole switch (`prod_role_selection`) | inferred from srv1, "live", main, a host | From 97a316426f81023c1e6c8bfbee645a8deeda07b6 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 04:10:37 +0000 Subject: [PATCH 09/48] D2 witnesses: 'none' is a keyword; rename locals Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/test/claim/deployment_risk_witness_test.dag | 12 ++++++------ ...map_dashboard_instance_apply_witness_test.dag | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 11587a95e28..f2916f3c7eb 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -197,16 +197,16 @@ test fn moving_the_prod_role_moves_the_broker_owner_and_the_fabric_storage_place // UNDER NoProdRole EVERY ROLE-FOLLOWER REFUSES WITH THE TYPED CAUSE -- nobody owns the broker, the // store is not placed anywhere, and approvals have no origin. A fallback to srv1 fails every conjunct. test fn under_no_prod_role_the_broker_owner_and_the_fabric_storage_placement_refuse() -> Bool { - let none = prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances()) - let realization_refused = match none { + let unheld = prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances()) + let realization_refused = match unheld { ProdRoleRealizationRefused { cause: ProdRoleUnheld } => true _ => false } - let placement_refused = match fabric_storage_placement_under(realization: none) { + let placement_refused = match fabric_storage_placement_under(realization: unheld) { FabricStoragePlacementRefused { cause: ProdRoleUnheld } => true _ => false } - let origin_refused = match approval_loopback_origin_under(realization: none) { + let origin_refused = match approval_loopback_origin_under(realization: unheld) { ApprovalLoopbackOriginRefused { cause: ProdRoleUnheld } => true _ => false } @@ -214,8 +214,8 @@ test fn under_no_prod_role_the_broker_owner_and_the_fabric_storage_placement_ref && placement_refused && origin_refused && all(public_builtin_dashboard_instances(), i => - !instance_owns_the_host_approval_broker_under(realization: none, instance_id: i.instance_id) - && !fabric_storage_placed_on_under(realization: none, instance: i) + !instance_owns_the_host_approval_broker_under(realization: unheld, instance_id: i.instance_id) + && !fabric_storage_placed_on_under(realization: unheld, instance: i) ) } diff --git a/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag b/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag index 11ad6b887e7..3ade749ada3 100644 --- a/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag @@ -601,18 +601,18 @@ fn witness_sibling_reading() -> DashboardProtectedSiblingReading { test fn protected_sibling_readings_never_equate_absent_with_observed() -> Bool { let read = [witness_sibling_reading()] - let none: List = [] + let empty: List = [] dashboard_protected_sibling_readings_equal(before: read, after: read) - && dashboard_protected_sibling_readings_equal(before: none, after: none) - && !dashboard_protected_sibling_readings_equal(before: read, after: none) - && !dashboard_protected_sibling_readings_equal(before: none, after: read) + && dashboard_protected_sibling_readings_equal(before: empty, after: empty) + && !dashboard_protected_sibling_readings_equal(before: read, after: empty) + && !dashboard_protected_sibling_readings_equal(before: empty, after: read) } test fn a_host_without_a_protected_sibling_refuses_nothing_and_reads_nothing() -> Bool { - let none = dashboard_protected_sibling_read(siblings: dashboard_protected_siblings(instance: srv2_lab_dashboard_instance())) - count(none) == 0 - && !dashboard_protected_sibling_reading_refused(readings: none) - && string_contains(s: dashboard_protected_sibling_reading_detail(readings: none), pattern: "no protected sibling") + let empty = dashboard_protected_sibling_read(siblings: dashboard_protected_siblings(instance: srv2_lab_dashboard_instance())) + count(empty) == 0 + && !dashboard_protected_sibling_reading_refused(readings: empty) + && string_contains(s: dashboard_protected_sibling_reading_detail(readings: empty), pattern: "no protected sibling") } // THE SIBLING IS DERIVED FROM THE SELECTION AND THE HOST. Today srv1's lab is protected from From 1e649489991cf8695f3ba7b022de985909a9687e Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 05:32:25 +0000 Subject: [PATCH 10/48] deployment_risk: key the binding table by class so a duplicate set is unwritable (review 75332) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/deployment_risk.dag | 51 ++++++++++--------- .../claim/deployment_risk_witness_test.dag | 40 ++++++--------- 2 files changed, 43 insertions(+), 48 deletions(-) diff --git a/dag/gunbc/deployment_risk.dag b/dag/gunbc/deployment_risk.dag index 98c76e0c373..b04312f36ec 100644 --- a/dag/gunbc/deployment_risk.dag +++ b/dag/gunbc/deployment_risk.dag @@ -89,34 +89,45 @@ fn deployment_risk_class(selection: ProdRoleSelection, deployment: DeploymentId) // GitHub App. The Codex account and home, admitted ref, R2/GCP/workload identity and OIDC client // join as fields in the split PRs that move each one (deployment-risk plan, deliverable 3), and // each of those PRs adds the TestRisk side or leaves its refusal standing. +// +// ONE SLOT PER CLASS, BY CONSTRUCTION (review 75332). The table is keyed by class rather than a list +// of class-tagged rows, so a second ProdRisk set has no place to be written and no resolver arm has +// to catch it after the fact (DESIGN 5: unwritable, not validated). An empty slot is the only +// refusal the resolver can return. type DeploymentExternalBindingSet { - class: DeploymentRiskClass approval_ntfy_topic: NtfyTopic publication_app: DeclaredGitHubApp } +type DeploymentExternalBindingTable { + prod: DeploymentExternalBindingSet? + test: DeploymentExternalBindingSet? +} + // The ProdRisk set is today's single global binding, now named as the prod class's. NO TestRisk // set exists: no test-side topic or app has been provisioned, and minting one is an outward-facing // effect the operator approves first. So every TestRisk deployment refuses here, which is the // correct state until a test resource exists -- not a gap to paper over. -data deployment_external_binding_sets: List = [ - DeploymentExternalBindingSet { - class: ProdRisk, +data deployment_external_binding_table: DeploymentExternalBindingTable = DeploymentExternalBindingTable { + prod: Present { value: DeploymentExternalBindingSet { approval_ntfy_topic: approval_ntfy_topic, publication_app: gunbai_bot_declared, - }, -] + } }, + test: Absent, +} type DeploymentBindingResolution - = DeploymentBindingsResolved { deployment: DeploymentId, bindings: DeploymentExternalBindingSet } + = DeploymentBindingsResolved { deployment: DeploymentId, class: DeploymentRiskClass, bindings: DeploymentExternalBindingSet } | DeploymentBindingSetAbsent { deployment: DeploymentId, class: DeploymentRiskClass } - | DeploymentBindingSetAmbiguous { deployment: DeploymentId, class: DeploymentRiskClass, count: Int } -fn binding_sets_for_class( - sets: List, +fn binding_set_for_class( + table: DeploymentExternalBindingTable, class: DeploymentRiskClass, -) -> List { - filter(sets, s => s.class == class) +) -> DeploymentExternalBindingSet? { + match class { + ProdRisk => table.prod + TestRisk => table.test + } } // ── The interface, and the bet ────────────────────────────────────────────────────────────────── @@ -128,23 +139,17 @@ fn binding_sets_for_class( // the bet's magnitude gains a grounded carrier -- an order of magnitude over std.measure Duration -- // rather than a bare Int named for its unit. Until then the obligations a conforming deployment // carries are: it names its DeploymentId and never another deployment's roots; it never authors a -// class; every external attachment comes from its resolved set, never a literal; an absent or -// ambiguous set stops the line; and its convergence is safe to re-run, converging under today's +// class; every external attachment comes from its resolved set, never a literal; an absent set +// stops the line; and its convergence is safe to re-run, converging under today's // operator policy to AwaitingOperatorDisposition rather than self-repairing (gunbc#13124). fn resolve_deployment_bindings( selection: ProdRoleSelection, - sets: List, + table: DeploymentExternalBindingTable, deployment: DeploymentId, ) -> DeploymentBindingResolution { let class = deployment_risk_class(selection: selection, deployment: deployment) - let exact = binding_sets_for_class(sets: sets, class: class) - match exact.first() { + match binding_set_for_class(table: table, class: class) { Absent => DeploymentBindingSetAbsent { deployment: deployment, class: class } - Present { value: only } => - if count(exact) == 1 { - DeploymentBindingsResolved { deployment: deployment, bindings: only } - } else { - DeploymentBindingSetAmbiguous { deployment: deployment, class: class, count: count(exact) } - } + Present { value: b } => DeploymentBindingsResolved { deployment: deployment, class: class, bindings: b } } } diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index f66e74bc3d7..1cd9f422a0d 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -2,6 +2,7 @@ module test.claim.deployment_risk_witness_test import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.types { NonEmptyStr, List, Bool } +import v2.std.collection { Present, Absent } import extdeps.ntfy.ntfy { NtfyTopic } import gunbc.auth.approval_notification { approval_ntfy_topic } import gunbc.auth.github_apps { gunbai_bot_declared, gunbai_ci_declared } @@ -14,14 +15,14 @@ import gunbc.deployment_risk { NoProdRole, ProdRoleHeldBy, DeploymentExternalBindingSet, + DeploymentExternalBindingTable, DeploymentBindingResolution, DeploymentBindingsResolved, DeploymentBindingSetAbsent, - DeploymentBindingSetAmbiguous, deployment_risk_class, resolve_deployment_bindings, prod_role_selection, - deployment_external_binding_sets, + deployment_external_binding_table, srv1_daily_workspace_deployment, srv1_lab_codex_feedback_deployment, srv2_deploy_deployment, @@ -48,7 +49,7 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly test fn a_test_deployment_refuses_rather_than_resolving_the_prod_bindings() -> Bool { match resolve_deployment_bindings( selection: prod_role_selection, - sets: deployment_external_binding_sets, + table: deployment_external_binding_table, deployment: srv1_lab_codex_feedback_deployment, ) { DeploymentBindingSetAbsent { deployment: d, class: TestRisk } => d == srv1_lab_codex_feedback_deployment @@ -61,10 +62,10 @@ test fn a_test_deployment_refuses_rather_than_resolving_the_prod_bindings() -> B test fn the_prod_role_holder_resolves_the_prod_topic_and_app() -> Bool { match resolve_deployment_bindings( selection: prod_role_selection, - sets: deployment_external_binding_sets, + table: deployment_external_binding_table, deployment: srv1_daily_workspace_deployment, ) { - DeploymentBindingsResolved { deployment: _, bindings: b } => + DeploymentBindingsResolved { deployment: _, class: _, bindings: b } => b.approval_ntfy_topic == approval_ntfy_topic && b.publication_app == gunbai_bot_declared _ => false } @@ -75,38 +76,27 @@ test fn the_prod_role_holder_resolves_the_prod_topic_and_app() -> Bool { // returned "the first set" or "the prod set" would fail one of the two conjuncts. data fx_test_topic: NtfyTopic = "gunbc-approvals-test" as NtfyTopic -data fx_prod_set: DeploymentExternalBindingSet = DeploymentExternalBindingSet { class: ProdRisk, approval_ntfy_topic: approval_ntfy_topic, publication_app: gunbai_bot_declared } - -data fx_both_classes: List = [ - fx_prod_set, - DeploymentExternalBindingSet { class: TestRisk, approval_ntfy_topic: fx_test_topic, publication_app: gunbai_ci_declared }, -] +data fx_both_classes: DeploymentExternalBindingTable = DeploymentExternalBindingTable { + prod: Present { value: DeploymentExternalBindingSet { approval_ntfy_topic: approval_ntfy_topic, publication_app: gunbai_bot_declared } }, + test: Present { value: DeploymentExternalBindingSet { approval_ntfy_topic: fx_test_topic, publication_app: gunbai_ci_declared } }, +} test fn each_class_resolves_only_its_own_set() -> Bool { - let test_side = match resolve_deployment_bindings(selection: prod_role_selection, sets: fx_both_classes, deployment: srv1_lab_codex_feedback_deployment) { - DeploymentBindingsResolved { deployment: _, bindings: b } => b.approval_ntfy_topic == fx_test_topic && b.publication_app != gunbai_bot_declared + let test_side = match resolve_deployment_bindings(selection: prod_role_selection, table: fx_both_classes, deployment: srv1_lab_codex_feedback_deployment) { + DeploymentBindingsResolved { deployment: _, class: _, bindings: b } => b.approval_ntfy_topic == fx_test_topic && b.publication_app != gunbai_bot_declared _ => false } - let prod_side = match resolve_deployment_bindings(selection: prod_role_selection, sets: fx_both_classes, deployment: srv1_daily_workspace_deployment) { - DeploymentBindingsResolved { deployment: _, bindings: b } => b.approval_ntfy_topic == approval_ntfy_topic + let prod_side = match resolve_deployment_bindings(selection: prod_role_selection, table: fx_both_classes, deployment: srv1_daily_workspace_deployment) { + DeploymentBindingsResolved { deployment: _, class: _, bindings: b } => b.approval_ntfy_topic == approval_ntfy_topic _ => false } test_side && prod_side } -// TWO SETS FOR ONE CLASS IS A REFUSAL, not a first-match. -test fn two_sets_for_one_class_refuse_as_ambiguous() -> Bool { - let doubled = [fx_prod_set, fx_prod_set] - match resolve_deployment_bindings(selection: prod_role_selection, sets: doubled, deployment: srv1_daily_workspace_deployment) { - DeploymentBindingSetAmbiguous { deployment: _, class: ProdRisk, count: 2 } => true - _ => false - } -} - // ── The switch: one row moves prod, or turns it off ───────────────────────────────────────────── test fn no_prod_role_makes_the_former_holder_test_risk_and_it_refuses() -> Bool { deployment_risk_class(selection: NoProdRole, deployment: srv1_daily_workspace_deployment) == TestRisk - && match resolve_deployment_bindings(selection: NoProdRole, sets: deployment_external_binding_sets, deployment: srv1_daily_workspace_deployment) { + && match resolve_deployment_bindings(selection: NoProdRole, table: deployment_external_binding_table, deployment: srv1_daily_workspace_deployment) { DeploymentBindingSetAbsent { deployment: _, class: TestRisk } => true _ => false } From 35dcd87044f2861966956442bc4454c00709ac42 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 05:52:09 +0000 Subject: [PATCH 11/48] D2: one holder-identity comparison, prod_role_realized_by, consumed by broker owner and fabric placement (review 75352) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/fabric/fabric_storage_placement.dag | 7 ++----- dag/gunbc/live_deploy/spec.dag | 8 ++------ dag/gunbc/roadmap/roadmap_dashboard_instance.dag | 15 ++++++--------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index 1a26cd2f5c1..0fbcd32a09a 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -5,7 +5,7 @@ import product.placement_supply { HostIdentity } import gunbc.fleet_intent_network { fleet_intent_network } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, dashboard_instance_fabric_storage_root, dashboard_instance_child, - ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, + ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, prod_role_realized_by, } import std.effect_grant { Read, Write, Execute } import gunbc.ownership { Ensured } @@ -79,10 +79,7 @@ fn fabric_storage_store_root(instance: HostDashboardInstance) -> NonEmptyStr { // selection: it does exactly when it realizes the prod-role holder, and no instance does under a // refusal. Identity is the instance's own id, not a path comparison. fn fabric_storage_placed_on_under(realization: ProdRoleRealization, instance: HostDashboardInstance) -> Bool { - match realization { - ProdRoleRealizedBy { instance: holder } => (instance.instance_id as String) == (holder.instance_id as String) - ProdRoleRealizationRefused { cause: _ } => false - } + prod_role_realized_by(realization: realization, instance_id: instance.instance_id) } fn fabric_storage_placed_on(instance: HostDashboardInstance) -> Bool { diff --git a/dag/gunbc/live_deploy/spec.dag b/dag/gunbc/live_deploy/spec.dag index 3bd3074a860..f8ae09b2cd1 100644 --- a/dag/gunbc/live_deploy/spec.dag +++ b/dag/gunbc/live_deploy/spec.dag @@ -49,9 +49,8 @@ import gunbc.roadmap_dashboard_instance { DashboardInstanceIdUnknown, dashboard_instance_for_id, ProdRoleRealization, - ProdRoleRealizedBy, - ProdRoleRealizationRefused, prod_role_realization, + prod_role_realized_by, dashboard_instance_dispatch_worktree_root, dashboard_instance_dispatch_attempt_state_root, dashboard_instance_compute_root, @@ -467,10 +466,7 @@ fn instance_owns_the_host_approval_broker_under( realization: ProdRoleRealization, instance_id: NonEmptyStr, ) -> Bool { - match realization { - ProdRoleRealizedBy { instance: holder } => (holder.instance_id as String) == (instance_id as String) - ProdRoleRealizationRefused { cause: _ } => false - } + prod_role_realized_by(realization: realization, instance_id: instance_id) } fn gunbc_approval_broker_unit_path() -> NonEmptyStr { diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index e096889b6b9..b40a9bc120d 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -815,15 +815,12 @@ fn prod_role_realization_refusal_detail(cause: ProdRoleRealizationRefusal) -> St } } -// Does THIS instance realize the prod-role holder? False under every refusal: a refused role has -// no holder, so no instance may act as one. -fn instance_realizes_prod_role_in( - selection: ProdRoleSelection, - instances: List, - instance: HostDashboardInstance, -) -> Bool { - match prod_role_realization_in(selection: selection, instances: instances) { - ProdRoleRealizedBy { instance: holder } => (holder.instance_id as String) == (instance.instance_id as String) +// IS THIS instance_id THE PROD-ROLE HOLDER'S REALIZATION, under a supplied realization: the ONE +// holder-identity comparison every role-following singleton (broker owner, fabric placement) asks. +// False under every refusal: a refused role has no holder, so no instance may act as one. +fn prod_role_realized_by(realization: ProdRoleRealization, instance_id: NonEmptyStr) -> Bool { + match realization { + ProdRoleRealizedBy { instance: holder } => (holder.instance_id as String) == (instance_id as String) ProdRoleRealizationRefused { cause: _ } => false } } From ddff566a9ae19f1f4d16aa1e887f7fdddb7f6c7b Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 08:23:53 +0000 Subject: [PATCH 12/48] D2 re-rule: role singletons as one token-built subject with a convergence gate (side-chat NO-LAND blockers 1-5) - ProdRoleHolder sole_constructor token; specs carry RoleSingletonHolding and build broker, front door, fabric unit/route/socket/root/directories from it (no instance_id ownership). - Fabric store directories leave the srv1 target and land on the holder's spec. - gunbc.live_deploy.role_singleton_convergence: desired realization x observation of every roster host gates each apply: Noop | Install (nothing observed) | typed refusal (move, removal, unobservable, ambiguous, ownerless broker). - GCP IAM converge job runs on the holder's host; refused job under NoProdRole. - macbook_local serves roadmap_serve_handle_macbook_local; roster-wide serve_function control. - Failure-mode row and plan agree on honest non-pins and the next trigger. Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/auth/approval_broker_endpoint.dag | 22 +- dag/gunbc/auth/approval_decision_store.dag | 2 +- dag/gunbc/auth/approval_request_client.dag | 2 +- dag/gunbc/fabric/fabric_storage_placement.dag | 28 +- dag/gunbc/fleet/fleet_converge_workflow.dag | 59 +++- dag/gunbc/live_deploy/apply.dag | 28 +- dag/gunbc/live_deploy/emit.dag | 2 +- .../role_singleton_convergence.dag | 202 ++++++++++++++ dag/gunbc/live_deploy/spec.dag | 133 +++++---- ...ent_acts_on_the_prod_deployments_state.dag | 9 +- .../roadmap/roadmap_dashboard_instance.dag | 22 +- .../deployed_tree_remote_witness_test.dag | 2 + .../claim/deployment_risk_witness_test.dag | 257 ++++++++++++++---- dag/test/claim/live_deploy/emit_test.dag | 13 +- ...ploy_unit_emission_oracle_witness_test.dag | 12 +- .../serve_function_binding_witness_test.dag | 58 ++++ docs/plans/deployment-risk-conformance.md | 11 +- 17 files changed, 685 insertions(+), 177 deletions(-) create mode 100644 dag/gunbc/live_deploy/role_singleton_convergence.dag create mode 100644 dag/test/claim/roadmap/serve_function_binding_witness_test.dag diff --git a/dag/gunbc/auth/approval_broker_endpoint.dag b/dag/gunbc/auth/approval_broker_endpoint.dag index efbf9ae7351..cd2cd32ebc4 100644 --- a/dag/gunbc/auth/approval_broker_endpoint.dag +++ b/dag/gunbc/auth/approval_broker_endpoint.dag @@ -8,7 +8,9 @@ import extdeps.external_authority { ExternalAuthority, CitedFigureStanding, Cite import gunbc.auth.approval_ntfy_deployment { srv1_tailnet_base_url_from, srv1_tailnet_host_from } import gunbc.fleet_intent_network { fleet_intent_network } import gunbc.serve_liveness { serve_liveness_path } -import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } +import gunbc.roadmap_dashboard_instance { + ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, +} import gunbc.auth.approval_device_wire { approval_device_request_path_prefix, approval_device_enrollment_path_prefix } // ── WHERE THE APPROVAL BROKER ANSWERS ──────────────────────────────────────────────────────── @@ -96,11 +98,19 @@ data approval_broker_listen_port: Port = 8085 // door is the only public path" true rather than merely intended. data approval_broker_listen_host: NonEmptyStr = "127.0.0.1" -// THE HOST THE BROKER RUNS ON, derived from the instance that owns its unit rather than spelled: a -// run that files and polls over the loopback approval client (the GCP IAM converge job) has to run -// there. -fn approval_broker_host_identity() -> NonEmptyStr { - srv1_live_dashboard_instance().host_identity +// THE HOST THE BROKER RUNS ON, derived from the prod-role holder that owns its unit (the same +// resolution the loopback client origin follows): a run that files and polls over that client (the +// GCP IAM converge job) has to run there. Under a refused realization there is no such host, and the +// refusal is carried so the job that needs it is emitted refused rather than pinned to srv1. +type ApprovalBrokerHost + = ApprovalBrokerHostResolved { host: NonEmptyStr } + | ApprovalBrokerHostRefused { cause: ProdRoleRealizationRefusal } + +fn approval_broker_host_under(realization: ProdRoleRealization) -> ApprovalBrokerHost { + match realization { + ProdRoleRealizedBy { holder } => ApprovalBrokerHostResolved { host: holder.instance.host_identity } + ProdRoleRealizationRefused { cause } => ApprovalBrokerHostRefused { cause: cause } + } } fn approval_broker_loopback_base_url(port: Port) -> NonEmptyStr { diff --git a/dag/gunbc/auth/approval_decision_store.dag b/dag/gunbc/auth/approval_decision_store.dag index 2803d726f6b..9664824d6b6 100644 --- a/dag/gunbc/auth/approval_decision_store.dag +++ b/dag/gunbc/auth/approval_decision_store.dag @@ -248,7 +248,7 @@ fn approval_identity_trust_for(listen_host: NonEmptyStr) -> IdentityTrustStandin // serves approvals, so trust is NOT established and names the refusal; it is never srv1's by default. fn approval_identity_trust_under(realization: ProdRoleRealization) -> IdentityTrustStanding { match realization { - ProdRoleRealizedBy { instance } => approval_identity_trust_for(listen_host: instance.listen_host) + ProdRoleRealizedBy { holder } => approval_identity_trust_for(listen_host: holder.instance.listen_host) ProdRoleRealizationRefused { cause } => IdentityHeaderTrustNotEstablished { blocker: join(["no instance serves approvals: ", prod_role_realization_refusal_detail(cause: cause)], "") as NonEmptyStr, diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag index 7c687be5c8f..ebcb67a9607 100644 --- a/dag/gunbc/auth/approval_request_client.dag +++ b/dag/gunbc/auth/approval_request_client.dag @@ -40,7 +40,7 @@ fn approval_loopback_origin_of(instance: HostDashboardInstance) -> NonEmptyStr { fn approval_loopback_origin_under(realization: ProdRoleRealization) -> ApprovalLoopbackOrigin { match realization { - ProdRoleRealizedBy { instance } => ApprovalLoopbackOriginResolved { origin: approval_loopback_origin_of(instance: instance) } + ProdRoleRealizedBy { holder } => ApprovalLoopbackOriginResolved { origin: approval_loopback_origin_of(instance: holder.instance) } ProdRoleRealizationRefused { cause } => ApprovalLoopbackOriginRefused { cause: cause } } } diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index 0fbcd32a09a..2e651f1b1ce 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -5,7 +5,7 @@ import product.placement_supply { HostIdentity } import gunbc.fleet_intent_network { fleet_intent_network } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, dashboard_instance_fabric_storage_root, dashboard_instance_child, - ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, prod_role_realized_by, + ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, } import std.effect_grant { Read, Write, Execute } import gunbc.ownership { Ensured } @@ -75,17 +75,6 @@ fn fabric_storage_store_root(instance: HostDashboardInstance) -> NonEmptyStr { dashboard_instance_fabric_storage_root(instance: instance) as String as NonEmptyStr } -// WHETHER AN INSTANCE HOLDS THE PLACEMENT, over a supplied realization so a witness can move the -// selection: it does exactly when it realizes the prod-role holder, and no instance does under a -// refusal. Identity is the instance's own id, not a path comparison. -fn fabric_storage_placed_on_under(realization: ProdRoleRealization, instance: HostDashboardInstance) -> Bool { - prod_role_realized_by(realization: realization, instance_id: instance.instance_id) -} - -fn fabric_storage_placed_on(instance: HostDashboardInstance) -> Bool { - fabric_storage_placed_on_under(realization: prod_role_realization(), instance: instance) -} - fn fabric_storage_endpoint_for(host: HostIdentity) -> NonEmptyStr? { match fleet_intent_network.tailnet_domain { Absent => none @@ -96,7 +85,8 @@ fn fabric_storage_endpoint_for(host: HostIdentity) -> NonEmptyStr? { fn fabric_storage_placement_under(realization: ProdRoleRealization) -> FabricStoragePlacement { match realization { ProdRoleRealizationRefused { cause } => FabricStoragePlacementRefused { cause: cause } - ProdRoleRealizedBy { instance } => { + ProdRoleRealizedBy { holder } => { + let instance = holder.instance let host = instance.host_identity as String as HostIdentity match fabric_storage_endpoint_for(host: host) { Absent => FabricStorageUnplaced @@ -230,15 +220,9 @@ fn fabric_storage_placed_file_root(store_root: NonEmptyStr) -> FabricStoragePlac } } -// THE PLACED INSTANCE'S STORE DIRECTORIES, or none when the placement refuses: an apply under -// NoProdRole ensures no store, rather than ensuring srv1's. -fn fabric_storage_store_directories() -> List { - match prod_role_realization() { - ProdRoleRealizationRefused { cause: _ } => [] - ProdRoleRealizedBy { instance } => fabric_storage_store_directories_for(instance: instance) - } -} - +// THE PLACED INSTANCE'S STORE DIRECTORIES. Which instance is placed is the caller's holder token +// (gunbc.live_deploy.spec deployment_role_singleton_directories), so the directories land on the +// same target as the unit and route. fn fabric_storage_store_directories_for(instance: HostDashboardInstance) -> List { let root = fabric_storage_store_root(instance: instance) let areas = fabric_storage_file_root(root: root) diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index ffbdbc15a80..bc534ea280b 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -109,7 +109,9 @@ import gunbc.machine_intake_mtcollins1_maintenance_hold { mtcollins1_unit_hold_s import gunbc.auth.gcp_iam_converge_federation { gcp_iam_converge_environment, gcp_iam_observe_wif_provider_resource } import gunbc.auth.gcp_iam_converge { iam_observe_service_account, iam_converge_service_account, iam_converge_token_lifetime } import gunbc.auth.gcp_iam_converge_run { gcp_iam_converge_step_timeout_minutes, iam_converge_token_env, gcp_iam_converge_approved_output } -import gunbc.auth.approval_broker_endpoint { approval_broker_host_identity } +import gunbc.auth.approval_broker_endpoint { approval_broker_host_under, ApprovalBrokerHostResolved, ApprovalBrokerHostRefused } +import gunbc.roadmap_dashboard_instance { ProdRoleRealization, ProdRoleRealizationRefusal, prod_role_realization, prod_role_realization_refusal_detail } +import extdeps.exec.command { shell_quote } import gunbc.auth.mtcollins1_boot_federation { mtcollins1_boot_environment, mtcollins1_boot_wif_provider_resource, mtcollins1_boot_service_account, } @@ -4138,11 +4140,64 @@ fn fleet_converge_gcp_iam_apply_step() -> Step { } } +// THE JOB RUNS WHERE THE APPROVAL BROKER RUNS, which follows the prod role +// (gunbc.auth.approval_broker_endpoint approval_broker_host_under). Under a refused realization it is +// still EMITTED, so a dispatch of this mode is not silently absent, but as a refused job whose only +// step fails with the typed cause: no federation, no approval filing, no host it could be pinned to. fn fleet_converge_gcp_iam_converge_job() -> Job { + fleet_converge_gcp_iam_converge_job_under(realization: prod_role_realization()) +} + +fn fleet_converge_gcp_iam_converge_job_under(realization: ProdRoleRealization) -> Job { + match approval_broker_host_under(realization: realization) { + ApprovalBrokerHostResolved { host } => fleet_converge_gcp_iam_converge_job_on(host: host) + ApprovalBrokerHostRefused { cause } => fleet_converge_gcp_iam_converge_refused_job(cause: cause) + } +} + +fn fleet_converge_gcp_iam_converge_refused_job(cause: ProdRoleRealizationRefusal) -> Job { + Job { + id: fleet_converge_mode_job_id(mode: GcpIamConverge) as String, + name: none, + runner: SelfHosted { labels: ["self-hosted", "linux", "arm64"] }, + environment: none, + steps: [ + RunStep { + name: Present { value: "REFUSED: the approval broker has no host (prod role unresolved)" }, + id: none, + run: join(["echo ", shell_quote(arg: join(["REFUSED gunbc.fleet_converge_workflow gcp-iam-converge: ", prod_role_realization_refusal_detail(cause: cause)], "")), " >&2; exit 1"], ""), + shell: none, + env: none, + working_directory: none, + if_condition: none, + continue_on_error: none, + timeout_minutes: Present { value: 1 } + }, + ], + needs: fleet_converge_job_needs(id: fleet_converge_mode_job_id(mode: GcpIamConverge)), + env: none, + outputs: none, + if_condition: Present { value: fleet_converge_gcp_iam_converge_step_if }, + timeout_minutes: Present { value: 5 }, + continue_on_error: none, + concurrency: none, + permissions: Present { + value: WorkflowPermissions { + contents: Present { value: PermRead }, + pull_requests: none, + issues: none, + actions: none, + id_token: none, + } + } + } +} + +fn fleet_converge_gcp_iam_converge_job_on(host: NonEmptyStr) -> Job { Job { id: fleet_converge_mode_job_id(mode: GcpIamConverge) as String, name: none, - runner: SelfHosted { labels: ["self-hosted", "linux", "arm64", approval_broker_host_identity() as String] }, + runner: SelfHosted { labels: ["self-hosted", "linux", "arm64", host as String] }, environment: Present { value: gcp_iam_converge_environment as String }, steps: [ fleet_converge_event_sha_checkout_step(), diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index fae22aec28e..c7e0c30527f 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -64,6 +64,11 @@ import gunbc.host_effect { import gunbc.host_effect_realize { host_effect_apply_gated } import gunbc.live_deploy.member_observe { deployment_effect_plan, deployment_identity_readback, IdentityReadbackConverged, IdentityReadbackDrift, IdentityReadbackRefused } import gunbc.live_deploy.member_identity { EffectsDerived, EffectsRefused, decision_label } +import gunbc.live_deploy.role_singleton_convergence { + role_singleton_convergence, observe_role_singletons, role_singleton_refusal_detail, + RoleSingletonNoop, RoleSingletonInstall, RoleSingletonRefused, +} +import gunbc.roadmap_dashboard_instance { prod_role_realization } import gunbc.auth.approval_broker_cutover { CutoverAdmission, StageAdmitted, StageRefused, dark_install_release_discharged, cutover_refusal_reason, @@ -264,10 +269,31 @@ fn live_deploy_ensure_dependency( ) } +// THE ROLE SINGLETONS ARE ASSESSED BEFORE ANYTHING ELSE (gunbc.live_deploy.role_singleton_convergence): +// the desired prod-role realization against an observation of every roster host. A move, a removal, +// an unobservable host, two holders or an unmapped broker stops the line here, before any member is +// planned -- so no apply starts a second, empty singleton while the old one still runs. +fn live_deploy_apply_mutation_via_transport( + spec: DeploymentSpec, + candidate: CandidateRelease, + transport: HostEffectTransport, + access: DeployAccess +) -> Reconciliation { + match role_singleton_convergence(desired: prod_role_realization(), observed: observe_role_singletons(applying: spec, own: transport)) { + RoleSingletonRefused { cause } => + NotConverged { + reason: join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], ""), + applied: live_deploy_preflight_intent(access: access), + } + RoleSingletonNoop => live_deploy_apply_member_plan_via_transport(spec: spec, candidate: candidate, transport: transport, access: access) + RoleSingletonInstall { holder: _ } => live_deploy_apply_member_plan_via_transport(spec: spec, candidate: candidate, transport: transport, access: access) + } +} + // THE PLAN IS DERIVED FROM THE HOST BEFORE THE MUTATION IS BUILT (gunbc.live_deploy.member_observe): // the same evaluation that decides also acts, and a plan that could not be derived refuses here with // every member's cause, installing nothing -- never the whole roster as a fallback. -fn live_deploy_apply_mutation_via_transport( +fn live_deploy_apply_member_plan_via_transport( spec: DeploymentSpec, candidate: CandidateRelease, transport: HostEffectTransport, diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index e3b40317f42..5ef9d801514 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -3283,7 +3283,7 @@ fn approval_broker_dark_install_intent( // -- a stage that reports success having installed nothing, which is the absorbing fallback DESIGN // section 5 forbids at exactly the seam where the operator is being told a stage completed. The // marker is not valid shell, so the stage fails and names the owner. -data approval_broker_dark_install_not_owner_poison: String = "__GUNBC_DEPLOY_REFUSED__ approval-broker DARK INSTALL was reached with a deployment that does not own this host's broker unit. gunbc-approval-broker.service is a HOST singleton owned by the realization of the prod-role holder (gunbc.live_deploy.spec instance_owns_the_host_approval_broker over gunbc.deployment_risk prod_role_selection), and is owned by nobody under NoProdRole; a twin apply installing it would point the host's broker at the twin's tree and restart it over production's process (review 68094). This marker is not valid deploy shell, so the stage fails loudly rather than emitting a script that installs nothing and reports success. instance=" +data approval_broker_dark_install_not_owner_poison: String = "__GUNBC_DEPLOY_REFUSED__ approval-broker DARK INSTALL was reached with a deployment that does not own this host's broker unit. gunbc-approval-broker.service is a HOST singleton owned by the spec that holds the prod role (gunbc.live_deploy.spec RoleSingletonHolding over gunbc.deployment_risk prod_role_selection), and is owned by nobody under NoProdRole; a twin apply installing it would point the host's broker at the twin's tree and restart it over production's process (review 68094). This marker is not valid deploy shell, so the stage fails loudly rather than emitting a script that installs nothing and reports success. instance=" data approval_broker_dark_install_emit_refused_poison: String = "__GUNBC_ORCH_EMIT_REFUSED__ approval-broker DARK INSTALL intent emission was rejected by v2.compiler.emit_orchestration; this marker is not valid deploy shell, so stage 1 of the cutover fails loudly rather than a hand-spelled fallback masking the refusal (DESIGN section 5: refuse, never widen)\n" diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag new file mode 100644 index 00000000000..55c246ff7bb --- /dev/null +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -0,0 +1,202 @@ +module gunbc.live_deploy.role_singleton_convergence + +import std.types { String, NonEmptyStr, List, Bool } +import v2.std.collection { Present, Absent } +import gunbc.deployment_risk { DeploymentId } +import gunbc.roadmap_dashboard_instance { + ProdRoleHolder, + ProdRoleRealization, + ProdRoleRealizedBy, + ProdRoleRealizationRefused, +} +import gunbc.host_effect { HostEffectTransport, SshShell } +import gunbc.live_deploy.spec { + DeploymentSpec, + deployment_fabric_storage_unit_path, + gunbc_approval_broker_unit_path, +} +import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster } +import gunbc.live_deploy.member_observe { + ObservationArm, + observation_arm, + entry_presence, + EntryPresence, + EntryPresent, + EntryAbsent, + EntryPresenceIndeterminate, +} + +// THE ROLE-FOLLOWING SINGLETONS AS A CONVERGENCE SUBJECT. The approval broker and the fabric store +// (unit, route, socket, root and directories -- one subject, gunbc.live_deploy.spec +// RoleSingletonHolding) are installed only by the spec that holds the prod role, and a spec's apply +// reconciles its owned members against `observed: []`. So a role move, or NoProdRole, used to leave +// the old singleton running and, on a move, start a second EMPTY one beside it -- the store's state +// does not travel with a unit. This module assesses the DESIRED realization (prod_role_selection +// resolved) against an INDEPENDENT OBSERVATION of every roster host (DESIGN 3d), before any apply. +// +// ONLY THE FIRST INSTALL IS IMPLEMENTED. Noop when the observed holder is the desired one (or nothing +// is desired and nothing is observed); Install when a valid holder is desired and NOTHING is observed +// anywhere. Every other transition REFUSES with a typed cause: a move (persistent state would have to +// travel), a removal under NoProdRole, a host that cannot be observed (unobserved is never absent -- +// that reading is exactly how Install would start the second empty service), two observed holders, +// and a broker present on a host where no roster deployment's store unit names its owner. Move and +// Remove are refusal causes today and become arms of RoleSingletonConvergence when their actuation +// lands; neither renames the arms that exist. +// +// THE HOST UNIVERSE IS THE ROSTER'S TARGETS (desired_deployment_roster), derived, not listed. A +// singleton on a host outside the roster is INVISIBLE to this observation: a bounded residual whose +// trigger is a roster row for that host, stated in docs/plans/deployment-risk-conformance.md. + +type RoleSingletonRefusal + = RoleSingletonMoveUnimplemented { observed: DeploymentId, desired: DeploymentId } + | RoleSingletonRemovalUnimplemented { observed: DeploymentId } + | RoleSingletonHostUnobservable { host: NonEmptyStr, cause: String } + | RoleSingletonHoldersAmbiguous { observed: List } + | RoleSingletonOwnerUnmapped { host: NonEmptyStr } + +type RoleSingletonConvergence + = RoleSingletonNoop + | RoleSingletonInstall { holder: ProdRoleHolder } + | RoleSingletonRefused { cause: RoleSingletonRefusal } + +// ── The observation ───────────────────────────────────────────────────────────────────────────── +// ONE HOST'S READING. A holder is a roster deployment whose fabric storage unit (named per +// deployment, so its presence maps to exactly one roster row) is present on that host. The broker +// unit is host-global and carries no deployment identity, so it is read only for presence: a broker +// with no holder beside it is a singleton nobody can be named as owning, and refuses. +type HostRoleSingletonReading + = HostRoleSingletonsRead { host: NonEmptyStr, holders: List, broker_present: Bool } + | HostRoleSingletonsUnobservable { host: NonEmptyStr, cause: String } + +type RoleSingletonObservation + = RoleSingletonsObserved { holders: List } + | RoleSingletonObservationRefused { cause: RoleSingletonRefusal } + +fn distinct_ids(ids: List) -> List { + fold(ids, init: [], f: (acc, d) => if any(acc, a => a == d) { acc } else { concat(acc, [d]) }) +} + +// THE PURE FOLD OVER PER-HOST READINGS, so the transition REDs supply readings at this boundary +// while the wet observation below runs the real legs. +fn role_singleton_observation_of(readings: List) -> RoleSingletonObservation { + let unobservable = readings |> flat_map(r => match r { + HostRoleSingletonsUnobservable { host, cause } => [RoleSingletonHostUnobservable { host: host, cause: cause }] + HostRoleSingletonsRead { host: _, holders: _, broker_present: _ } => [] + }) + let unmapped = readings |> flat_map(r => match r { + HostRoleSingletonsRead { host, holders, broker_present } => + if broker_present && count(holders) == 0 { [RoleSingletonOwnerUnmapped { host: host }] } else { [] } + HostRoleSingletonsUnobservable { host: _, cause: _ } => [] + }) + let holders = distinct_ids(ids: readings |> flat_map(r => match r { + HostRoleSingletonsRead { host: _, holders: hs, broker_present: _ } => hs + HostRoleSingletonsUnobservable { host: _, cause: _ } => [] + })) + match unobservable |> first { + Present { value: c } => RoleSingletonObservationRefused { cause: c } + Absent => match unmapped |> first { + Present { value: c } => RoleSingletonObservationRefused { cause: c } + Absent => + if count(holders) > 1 { RoleSingletonObservationRefused { cause: RoleSingletonHoldersAmbiguous { observed: holders } } } + else { RoleSingletonsObserved { holders: holders } } + } + } +} + +// ── The decision ──────────────────────────────────────────────────────────────────────────────── +fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingletonObservation) -> RoleSingletonConvergence { + match observed { + RoleSingletonObservationRefused { cause } => RoleSingletonRefused { cause: cause } + RoleSingletonsObserved { holders } => match holders |> first { + Absent => match desired { + ProdRoleRealizedBy { holder } => RoleSingletonInstall { holder: holder } + ProdRoleRealizationRefused { cause: _ } => RoleSingletonNoop + } + Present { value: seen } => match desired { + ProdRoleRealizedBy { holder } => + if holder.instance.deployment == seen { RoleSingletonNoop } + else { RoleSingletonRefused { cause: RoleSingletonMoveUnimplemented { observed: seen, desired: holder.instance.deployment } } } + ProdRoleRealizationRefused { cause: _ } => RoleSingletonRefused { cause: RoleSingletonRemovalUnimplemented { observed: seen } } + } + } + } +} + +fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { + match cause { + RoleSingletonMoveUnimplemented { observed, desired } => + join(["role singletons refused: the prod role moved from ", observed.name as String, " to ", desired.name as String, " while the old singletons are still observed; moving their persistent state is not implemented"], "") + RoleSingletonRemovalUnimplemented { observed } => + join(["role singletons refused: no deployment holds the prod role but ", observed.name as String, "'s singletons are still observed; removal is not implemented"], "") + RoleSingletonHostUnobservable { host, cause: c } => + join(["role singletons refused: host ", host as String, " could not be observed (", c, "); unobserved is not absent"], "") + RoleSingletonHoldersAmbiguous { observed } => + join(["role singletons refused: ", to_string(count(observed)), " deployments are observed holding them: ", join(map(observed, d => d.name as String), ", ")], "") + RoleSingletonOwnerUnmapped { host } => + join(["role singletons refused: the approval broker is present on ", host as String, " but no roster deployment's fabric storage unit names its owner"], "") + } +} + +// ── The wet observation ───────────────────────────────────────────────────────────────────────── +// Each roster host is read over the transport that reaches it from the applying host: the apply's +// own transport for the host it is applying to, ssh to the target's declared ssh_host for every other +// roster host. A host whose legs refuse is unobservable and stops the line. +fn presence_reading(p: EntryPresence) -> Bool? { + match p { + EntryPresent => Present { value: true } + EntryAbsent => Present { value: false } + EntryPresenceIndeterminate { cause: _ } => none + } +} + +fn presence_cause(p: EntryPresence) -> String { + match p { + EntryPresenceIndeterminate { cause } => cause + EntryPresent => "" + EntryAbsent => "" + } +} + +fn host_role_singleton_reading(host: NonEmptyStr, rows: List, arm: ObservationArm) -> HostRoleSingletonReading { + let broker = entry_presence(arm: arm, path: gunbc_approval_broker_unit_path() as String) + let stores = map(rows, r => DeploymentStorePresence { + deployment: r.instance.deployment, + presence: entry_presence(arm: arm, path: deployment_fabric_storage_unit_path(names: r.names) as String), + }) + let indeterminate = concat( + match presence_reading(p: broker) { Absent => [presence_cause(p: broker)] Present { value: _ } => [] }, + stores |> flat_map(s => match presence_reading(p: s.presence) { Absent => [presence_cause(p: s.presence)] Present { value: _ } => [] }), + ) + match indeterminate |> first { + Present { value: cause } => HostRoleSingletonsUnobservable { host: host, cause: cause } + Absent => HostRoleSingletonsRead { + host: host, + holders: stores |> filter(s => match s.presence { EntryPresent => true _ => false }) |> map(s => s.deployment), + broker_present: match broker { EntryPresent => true _ => false }, + } + } +} + +type DeploymentStorePresence { + deployment: DeploymentId + presence: EntryPresence +} + +fn roster_hosts(rows: List) -> List { + fold(rows, init: [], f: (acc, r) => if any(acc, h => (h as String) == (r.target.ssh_host as String)) { acc } else { concat(acc, [r.target.ssh_host]) }) +} + +fn transport_to(host: NonEmptyStr, applying: DeploymentSpec, own: HostEffectTransport) -> HostEffectTransport { + if (host as String) == (applying.target.ssh_host as String) { own } else { SshShell { ssh_host: host } } +} + +fn observe_role_singletons(applying: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonObservation { + let rows = desired_deployment_roster() + role_singleton_observation_of(readings: map(roster_hosts(rows: rows), h => + host_role_singleton_reading( + host: h, + rows: rows |> filter(r => (r.target.ssh_host as String) == (h as String)), + arm: observation_arm(transport: transport_to(host: h, applying: applying, own: own)), + ) + )) +} diff --git a/dag/gunbc/live_deploy/spec.dag b/dag/gunbc/live_deploy/spec.dag index f8ae09b2cd1..da383bfe926 100644 --- a/dag/gunbc/live_deploy/spec.dag +++ b/dag/gunbc/live_deploy/spec.dag @@ -41,7 +41,7 @@ import gunbc.host_layout { srv1_dispatch_attempt_state_root, srv1_devboot_artifact_store_root, } -import gunbc.fabric_storage_placement { fabric_storage_store_directories, fabric_storage_placed_on, fabric_storage_https_port, fabric_storage_door_socket } +import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_https_port, fabric_storage_door_socket } import gunbc.managed_directory { ManagedDirectoryAdmission, managed_directory_admission_path } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, @@ -50,7 +50,9 @@ import gunbc.roadmap_dashboard_instance { dashboard_instance_for_id, ProdRoleRealization, prod_role_realization, - prod_role_realized_by, + ProdRoleHolder, + ProdRoleRealizedBy, + ProdRoleRealizationRefused, dashboard_instance_dispatch_worktree_root, dashboard_instance_dispatch_attempt_state_root, dashboard_instance_compute_root, @@ -443,30 +445,33 @@ fn gunbc_repository_convergence_slice_unit_path() -> NonEmptyStr { join(["/etc/systemd/system/", gunbc_repository_convergence_slice_unit_name as String], "") as NonEmptyStr } -// A SPEC THAT DOES NOT OWN THE BROKER EMITS NO BROKER STEP. This is the predicate the emitter -// consults; it is not a validation standing beside a write that would otherwise happen, because the -// steps are CONSTRUCTED from it -- a non-owning apply produces an empty list, so there is nothing to -// skip and nothing to get wrong. -fn deployment_owns_the_host_approval_broker(spec: DeploymentSpec) -> Bool { - instance_owns_the_host_approval_broker(instance_id: spec.instance_id) -} - -// THE ONE OWNERSHIP COMPARISON, read by the spec-level predicate above and by the front-door member, -// which is built before a spec exists and so holds only the instance. -fn instance_owns_the_host_approval_broker(instance_id: NonEmptyStr) -> Bool { - instance_owns_the_host_approval_broker_under( - realization: prod_role_realization(), - instance_id: instance_id, - ) +// THE ROLE-FOLLOWING SINGLETONS A SPEC HOLDS -- the approval broker unit and front door, the fabric +// storage unit, route, socket, root and directories -- as ONE subject, carried on the spec as a +// holder token (gunbc.roadmap_dashboard_instance ProdRoleHolder, sole_constructor: only a successful +// resolution of prod_role_selection mints one). Every singleton effect is BUILT FROM the token's +// instance, never from a caller-supplied instance_id, so no spec can take ownership by spelling one. +// Which spec carries the token is decided by the role model's own identity (DeploymentId, a +// structural record), and under every refused realization no spec holds anything. +type RoleSingletonHolding + = HoldsProdRoleSingletons { holder: ProdRoleHolder } + | HoldsNoRoleSingleton + +fn role_singleton_holding_under(realization: ProdRoleRealization, instance: HostDashboardInstance) -> RoleSingletonHolding { + match realization { + ProdRoleRealizedBy { holder } => + if holder.instance.deployment == instance.deployment { HoldsProdRoleSingletons { holder: holder } } else { HoldsNoRoleSingleton } + ProdRoleRealizationRefused { cause: _ } => HoldsNoRoleSingleton + } } -// THE SAME COMPARISON OVER A SUPPLIED REALIZATION, so a witness can move the selection and watch -// ownership follow without editing the production row. -fn instance_owns_the_host_approval_broker_under( - realization: ProdRoleRealization, - instance_id: NonEmptyStr, -) -> Bool { - prod_role_realized_by(realization: realization, instance_id: instance_id) +// A SPEC THAT DOES NOT HOLD THE ROLE EMITS NO BROKER STEP. This is the predicate the emitter +// consults; the steps are CONSTRUCTED from it -- a non-holding apply produces an empty list, so there +// is nothing to skip and nothing to get wrong. +fn deployment_owns_the_host_approval_broker(spec: DeploymentSpec) -> Bool { + match spec.role_singletons { + HoldsProdRoleSingletons { holder: _ } => true + HoldsNoRoleSingleton => false + } } fn gunbc_approval_broker_unit_path() -> NonEmptyStr { @@ -647,7 +652,7 @@ fn deployment_belt_unit_stem(names: DeploymentNames) -> String { } // THE FABRIC DB ENDPOINT'S UNIT, derived like the belt's: one per deployment identity, and emitted -// only by the deployment whose instance holds the fabric DB placement (fabric_storage_placed_on). It is +// only by the deployment whose instance holds the fabric DB placement (RoleSingletonHolding). It is // deployment state -- a retract removes the unit and the tailscale mapping -- while the store's files // are an ensured host directory no retract touches (FabricStorageStoreDirectory). fn deployment_fabric_storage_unit_name(names: DeploymentNames) -> NonEmptyStr { @@ -658,14 +663,12 @@ fn deployment_fabric_storage_unit_path(names: DeploymentNames) -> NonEmptyStr { join(["/etc/systemd/system/", deployment_fabric_storage_unit_name(names: names) as String], "") as NonEmptyStr } -// THE DOOR SOCKET OF THIS SPEC'S OWN INSTANCE, by the roster's id lookup. Apply reaches it only on -// the instance that holds the placement (deployment_fabric_storage_steps); a teardown after prod has -// moved still names the socket of the instance being torn down, not the new holder's. An unknown id -// is Absent and the emitter refuses. +// THE DOOR SOCKET OF THE HOLDER THIS SPEC CARRIES, built from the token's instance like every other +// role singleton. A spec holding no role has no socket, and the emitter refuses rather than invent one. fn deployment_fabric_storage_door_socket(spec: DeploymentSpec) -> NonEmptyStr? { - match dashboard_instance_for_id(instance_id: spec.instance_id as String) { - DashboardInstanceForId { instance } => Present { value: fabric_storage_door_socket(instance: instance) } - DashboardInstanceIdUnknown { instance_id: _ } => none + match spec.role_singletons { + HoldsProdRoleSingletons { holder } => Present { value: fabric_storage_door_socket(instance: holder.instance) } + HoldsNoRoleSingleton => none } } @@ -972,6 +975,7 @@ type DeploymentSpec { approval_broker: DeploymentServiceConfig names: DeploymentNames instance_id: NonEmptyStr + role_singletons: RoleSingletonHolding } // TWO CONSUMERS, ONE SCOPE-FREE ROUTE LABEL (KEYING-0 follow-on, node://adhoc-907f1da5-c20): @@ -1039,6 +1043,7 @@ type DeploymentSpec { fn deployment_owned_steps( instance: HostDashboardInstance, names: DeploymentNames, + holding: RoleSingletonHolding, ) -> List { concat([ DeploymentArtifactStep { kind: ServeBinary, path: instance.serve_binary as String as NonEmptyStr }, @@ -1061,8 +1066,8 @@ fn deployment_owned_steps( path: tailscale_serve_route_member_path(endpoint: names.tailscale_serve_endpoint), }, ], concat( - deployment_fabric_storage_steps(instance: instance, names: names), - deployment_approval_broker_front_door_steps(instance: instance), + deployment_fabric_storage_steps(holding: holding, names: names), + deployment_approval_broker_front_door_steps(holding: holding), )) } @@ -1083,13 +1088,14 @@ fn deployment_owned_steps( // One member, two mappings: its identity is the /approve mount's route key and its apply and // teardown realize every endpoint approval_broker_front_door_endpoints returns, the way // GunbcSourceTree's one member owns its sync unit and env file. It is emitted only by the instance -// that owns the host's broker (instance_owns_the_host_approval_broker). +// that owns the host's broker (RoleSingletonHolding). fn deployment_approval_broker_front_door_endpoints() -> List { approval_broker_front_door_endpoints(https_port: tailscale_default_https_port, backend_port: approval_broker_listen_port) } -fn deployment_approval_broker_front_door_steps(instance: HostDashboardInstance) -> List { - if instance_owns_the_host_approval_broker(instance_id: instance.instance_id) && approval_front_door_mounts_installed() { +fn deployment_approval_broker_front_door_steps(holding: RoleSingletonHolding) -> List { + let holds = match holding { HoldsProdRoleSingletons { holder: _ } => true HoldsNoRoleSingleton => false } + if holds && approval_front_door_mounts_installed() { [ DeploymentArtifactStep { kind: ApprovalBrokerFrontDoor, @@ -1105,14 +1111,26 @@ fn deployment_approval_broker_front_door_steps(instance: HostDashboardInstance) // references the binary and tree installed above) and then its tailscale route (routes to a live // service, the same reason the dashboard's mapping is last). Empty on every instance that does not // hold the placement. -fn deployment_fabric_storage_steps(instance: HostDashboardInstance, names: DeploymentNames) -> List { - if fabric_storage_placed_on(instance: instance) { - [ +fn deployment_fabric_storage_steps(holding: RoleSingletonHolding, names: DeploymentNames) -> List { + match holding { + HoldsProdRoleSingletons { holder } => [ DeploymentArtifactStep { kind: FabricStorageServeUnit, path: deployment_fabric_storage_unit_path(names: names) }, - DeploymentArtifactStep { kind: FabricStorageServeMapping, path: tailscale_serve_route_member_path(endpoint: deployment_fabric_storage_serve_endpoint(door_socket: fabric_storage_door_socket(instance: instance))) }, + DeploymentArtifactStep { kind: FabricStorageServeMapping, path: tailscale_serve_route_member_path(endpoint: deployment_fabric_storage_serve_endpoint(door_socket: fabric_storage_door_socket(instance: holder.instance))) }, ] - } else { - [] + HoldsNoRoleSingleton => [] + } +} + +// THE FABRIC STORE'S DIRECTORIES TRAVEL WITH ITS UNIT AND ROUTE. They used to be a member of the srv1 +// target's ensured directories, so moving the role moved the unit to srv2 and left the directories +// on srv1. Derived from the same holding, they land on exactly the target that gets the unit. +fn deployment_role_singleton_directories(holding: RoleSingletonHolding) -> List { + match holding { + HoldsProdRoleSingletons { holder } => map(fabric_storage_store_directories_for(instance: holder.instance), a => DeploymentDependencyStep { + kind: FabricStorageStoreDirectory, + subject: EnsuredManagedHostDirectory { admission: a }, + }) + HoldsNoRoleSingleton => [] } } @@ -1161,7 +1179,7 @@ fn deployment_ensured_packages() -> List { // // AND AN AREA THE WRITER CANNOT WRITE IS NOT A STORE EITHER. The three directories are managed: // their owner, group and mode come from the writers gunbc.fabric_storage_placement -// fabric_storage_store_directories declares (the service principal and the CI job principal), not +// fabric_storage_store_directories_for declares (the service principal and the CI job principal), not // from gunbc_service_user at the shared 0755 -- which is what refused fleet-converge run // 36139624393's claim permission_denied. install -d re-applies owner, group and mode to a directory // that already exists, so the next apply repairs the areas #12126 created. @@ -1174,10 +1192,7 @@ fn deployment_ensured_srv1_host_directories() -> List owner: gunbc_service_user, }, }, - ], concat(map(fabric_storage_store_directories(), a => DeploymentDependencyStep { - kind: FabricStorageStoreDirectory, - subject: EnsuredManagedHostDirectory { admission: a }, - }), [ + ], [ DeploymentDependencyStep { kind: CodexRuntimeReleaseDirectory, subject: EnsuredHostDirectory { @@ -1185,7 +1200,7 @@ fn deployment_ensured_srv1_host_directories() -> List owner: gunbc_service_user, }, }, - ])) + ]) } fn deployment_ensured_steps(target: DeploymentHostTarget) -> List { @@ -1274,11 +1289,12 @@ fn deployment_steps_apply_order( instance: HostDashboardInstance, target: DeploymentHostTarget, names: DeploymentNames, + holding: RoleSingletonHolding, ) -> List { concat( - map(deployment_ensured_steps(target: target), s => Dependency { step: s }), + map(concat(deployment_ensured_steps(target: target), deployment_role_singleton_directories(holding: holding)), s => Dependency { step: s }), map( - deployment_owned_steps(instance: instance, names: names), + deployment_owned_steps(instance: instance, names: names, holding: holding), s => ArtifactStep { step: s }, ) ) @@ -1389,9 +1405,22 @@ fn deployment_spec_for( target: DeploymentHostTarget, names: DeploymentNames, ) -> DeploymentSpec { + deployment_spec_for_under(realization: prod_role_realization(), instance: instance, target: target, names: names) +} + +// THE SAME CONSTRUCTION OVER A SUPPLIED REALIZATION, so a witness can move the role and read every +// spec the roster would apply without editing the production selection. +fn deployment_spec_for_under( + realization: ProdRoleRealization, + instance: HostDashboardInstance, + target: DeploymentHostTarget, + names: DeploymentNames, +) -> DeploymentSpec { + let holding = role_singleton_holding_under(realization: realization, instance: instance) DeploymentSpec { target: target, - steps: deployment_steps_apply_order(instance: instance, target: target, names: names), + steps: deployment_steps_apply_order(instance: instance, target: target, names: names, holding: holding), + role_singletons: holding, service: deployment_service_config( instance: instance, names: names, diff --git a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag index 8b24360ac47..445605436de 100644 --- a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag +++ b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag @@ -22,17 +22,18 @@ data a_test_deployment_acts_on_the_prod_deployments_state: RecurringFailureMode "CLIMB STARTED BY THE MODEL PR: the class is derived, the bindings resolve per exact class, and test.claim.deployment_risk_witness_test carries the discriminating RED (a TestRisk deployment refuses rather than resolving the prod topic and app) beside its positive control. That covers the two attachments the binding set carries today and none of the four receipts above, so the row's rung is unchanged by it.", - "PINS REPOINTED BY DELIVERABLE 2: the role-following singletons now resolve the holder through gunbc.roadmap_dashboard_instance prod_role_realization -- one lookup, the instance roster filtered by its own deployment field -- and REFUSE with a typed ProdRoleRealizationRefusal under NoProdRole, an unrealized holder or an ambiguous one: the approval broker owner (gunbc.live_deploy.spec instance_owns_the_host_approval_broker; the srv1-live literal is deleted), fabric storage placement (gunbc.fabric_storage_placement fabric_storage_placement, FabricStoragePlacementRefused; operator_host_srv1 no longer named), the approval client origin (gunbc.auth.approval_request_client approval_loopback_origin), the identity trust (gunbc.auth.approval_decision_store approval_identity_trust_under), and the apply's protected siblings (gunbc.roadmap_dashboard_instance_apply dashboard_protected_siblings, derived by host and class). test.claim.deployment_risk_witness_test moving_the_prod_role_moves_the_broker_owner_and_the_fabric_storage_placement is the RED: a fixture selection moves the owner and the placement, and a mutant that pins the owner back to srv1-live fails it.", + "PINS REPOINTED BY DELIVERABLE 2: the role-following singletons follow gunbc.roadmap_dashboard_instance prod_role_realization -- one lookup, the instance roster filtered by its own deployment field -- which REFUSES with a typed ProdRoleRealizationRefusal under NoProdRole, an unrealized holder or an ambiguous one, and mints the only holder token (ProdRoleHolder, sole_constructor). A spec carries that token as gunbc.live_deploy.spec RoleSingletonHolding and every singleton member -- the approval broker unit and front door, the fabric store unit, route, socket, root and directories -- is built from it, never from a caller-spelled instance_id, so they move between targets as one subject. The apply first assesses the desired realization against an observation of every roster host (gunbc.live_deploy.role_singleton_convergence): Noop, Install only when nothing is observed anywhere, and a typed refusal for a move, a removal, an unobservable host, two holders or an ownerless broker. The approval client origin, identity trust, the GCP IAM converge job's runner (gunbc.fleet_converge_workflow fleet_converge_gcp_iam_converge_job_under, refused under NoProdRole) and the apply's protected siblings (dashboard_protected_siblings) follow the same realization. The REDs are in test.claim.deployment_risk_witness_test at the spec, convergence and emitted-job boundaries.", - "PINS REMAINING, each with its reason. (a) The nullary serve, belt, dispatch and launch entry points that call srv1_live_dashboard_instance (gunbc.roadmap_serve roadmap_serve_handle and its wrappers, roadmap_belt_actuate, roadmap_dispatch_actuator, dispatch_preflight, roadmap_launch_deployment_cli) are srv1-live's OWN entry points -- HostDashboardInstance.serve_function names roadmap_serve_handle for srv1-live and a sibling roadmap_serve_handle_ for every other instance -- so they are self-identification, not a prod pin, and repointing them to the holder would make srv1-live act on the new holder's state once prod moves, which is this class. Their honest fix is deliverable 4 (one spec per deployment, no nullary default instance). (b) roadmap_belt_actuate belt_dispatch_instance_cli defaults an absent instance env to srv1-live: a default, not a role, left for the belt owner's lane. (c) gunbc.auth.approval_broker_endpoint approval_broker_host_identity still names srv1-live's host for the GCP IAM converge job's runner label: workflow emission has no typed refusal arm to carry NoProdRole. (d) gunbc.fleet_reach_endpoint fleet_probe_remote_user_for's srv1/srv2 test is which hosts carry the gunbc-fleet account -- a host-shared fact, not a prod pin.", + "HONEST NON-PINS, not remaining debt: (a) the nullary serve, belt, dispatch and launch entry points that call srv1_live_dashboard_instance are srv1-live's OWN entry points (HostDashboardInstance.serve_function names roadmap_serve_handle for srv1-live and roadmap_serve_handle_ for every other instance, a binding test.claim.serve_function_binding_witness_test now checks roster-wide), so they must NOT follow the role -- following it would make srv1-live act on the new holder's state; (b) gunbc.fleet_reach_endpoint fleet_probe_remote_user_for's srv1/srv2 test is which hosts carry the gunbc-fleet account, a host-shared fact. NOT CLAIMED: that an unbound test deployment refuses everywhere -- roadmap_belt_actuate belt_dispatch_instance_cli still defaults an absent instance env to srv1-live, which stays with the belt owner's lane.", - "NEXT-RUNG TRIGGER: every pin that names the prod deployment directly (the approval broker owner, fabric storage placement, the approval client/store/trust, the serve and dispatch entry points that call srv1_live_dashboard_instance, the apply's prod peer, fleet_reach_endpoint) resolves through prod_role_selection, AND every attachment the srv1 lab shares with prod today (ntfy, Codex account and home, admitted ref, publication identity, R2/GCP/workload identity, OIDC) resolves through DeploymentExternalBindingSet or refuses. One pin or one binding is not sufficient: the class is the route, and any remaining named route is the next receipt.", + "NEXT-RUNG TRIGGER: (1) the role-singleton convergence gains its Move and Remove actuations, so a role change converges the old singleton away instead of refusing; (2) every attachment the srv1 lab shares with prod today (ntfy, Codex account and home, admitted ref, publication identity, R2/GCP/workload identity, OIDC) resolves through DeploymentExternalBindingTable or refuses (deliverable 3). One binding is not sufficient: the class is the route, and any remaining named route is the next receipt.", ], evidence: [ DeclarationRef { module_path: "gunbc.deployment_risk", decl_name: "resolve_deployment_bindings", field: WholeDeclaration }, DeclarationRef { module_path: "test.claim.deployment_risk_witness_test", decl_name: "a_test_deployment_refuses_rather_than_resolving_the_prod_bindings", field: WholeDeclaration }, DeclarationRef { module_path: "gunbc.roadmap_dashboard_instance", decl_name: "prod_role_realization_in", field: WholeDeclaration }, - DeclarationRef { module_path: "test.claim.deployment_risk_witness_test", decl_name: "moving_the_prod_role_moves_the_broker_owner_and_the_fabric_storage_placement", field: WholeDeclaration }, + DeclarationRef { module_path: "test.claim.deployment_risk_witness_test", decl_name: "moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec", field: WholeDeclaration }, + DeclarationRef { module_path: "gunbc.live_deploy.role_singleton_convergence", decl_name: "role_singleton_convergence", field: WholeDeclaration }, ], } diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index b40a9bc120d..7d1b6524022 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -775,8 +775,15 @@ type ProdRoleRealizationRefusal | ProdRoleHolderUnrealized { deployment: DeploymentId } | ProdRoleHolderAmbiguous { deployment: DeploymentId, count: Int } +// THE HOLDER TOKEN. sole_constructor, so only this module -- and in it only prod_role_realization_in +// -- can mint one: a role-following singleton is built FROM a holder, and no caller can claim +// ownership by spelling an instance_id or by constructing a holder for an instance of its choosing. +type ProdRoleHolder sole_constructor { + instance: HostDashboardInstance +} + type ProdRoleRealization - = ProdRoleRealizedBy { instance: HostDashboardInstance } + = ProdRoleRealizedBy { holder: ProdRoleHolder } | ProdRoleRealizationRefused { cause: ProdRoleRealizationRefusal } fn prod_role_realization_in( @@ -791,7 +798,7 @@ fn prod_role_realization_in( Absent => ProdRoleRealizationRefused { cause: ProdRoleHolderUnrealized { deployment: holder } } Present { value: only } => if count(realizing) == 1 { - ProdRoleRealizedBy { instance: only } + ProdRoleRealizedBy { holder: ProdRoleHolder { instance: only } } } else { ProdRoleRealizationRefused { cause: ProdRoleHolderAmbiguous { deployment: holder, count: count(realizing) } } } @@ -815,15 +822,6 @@ fn prod_role_realization_refusal_detail(cause: ProdRoleRealizationRefusal) -> St } } -// IS THIS instance_id THE PROD-ROLE HOLDER'S REALIZATION, under a supplied realization: the ONE -// holder-identity comparison every role-following singleton (broker owner, fabric placement) asks. -// False under every refusal: a refused role has no holder, so no instance may act as one. -fn prod_role_realized_by(realization: ProdRoleRealization, instance_id: NonEmptyStr) -> Bool { - match realization { - ProdRoleRealizedBy { instance: holder } => (holder.instance_id as String) == (instance_id as String) - ProdRoleRealizationRefused { cause: _ } => false - } -} data macbook_local_dashboard_id: NonEmptyStr = "macbook-local" as NonEmptyStr @@ -908,7 +906,7 @@ fn macbook_local_dashboard_instance() -> HostDashboardInstance { ), }, serve_binary: macbook_local_serve_binary, - serve_function: "roadmap_serve_handle" as NonEmptyStr, + serve_function: "roadmap_serve_handle_macbook_local" as NonEmptyStr, listen_host: "127.0.0.1" as NonEmptyStr, listen_port: 8081, tmux_server: dashboard_named_tmux_server(instance_id: macbook_local_dashboard_id), diff --git a/dag/test/claim/deployed_tree_remote_witness_test.dag b/dag/test/claim/deployed_tree_remote_witness_test.dag index ec932e85486..0d43ebb773b 100644 --- a/dag/test/claim/deployed_tree_remote_witness_test.dag +++ b/dag/test/claim/deployed_tree_remote_witness_test.dag @@ -15,6 +15,7 @@ import extdeps.git { } import extdeps.github.github { github_https_clone_url } import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } +import gunbc.live_deploy.desired { deployment_spec_srv1 } import gunbc.live_deploy.spec { OwnedArtifactKind, PublicationHelperTimerUnit, @@ -254,6 +255,7 @@ fn ordered_tags() -> List { deployment_owned_steps( instance: srv1_live_dashboard_instance(), names: srv1_live_deployment_names(instance: srv1_live_dashboard_instance()), + holding: deployment_spec_srv1().role_singletons, ), init: [], f: (acc, step) => { diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index b13d6b53e0f..50b16e9ab6d 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -1,7 +1,7 @@ module test.claim.deployment_risk_witness_test import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } -import std.types { NonEmptyStr, List, Bool } +import std.types { NonEmptyStr, List, Bool, String, Int } import v2.std.collection { Present, Absent } import extdeps.ntfy.ntfy { NtfyTopic } import gunbc.auth.approval_notification { approval_ntfy_topic } @@ -47,11 +47,42 @@ import gunbc.roadmap_dashboard_instance { prod_role_realization_in, public_builtin_dashboard_instances, } -import gunbc.live_deploy.spec { instance_owns_the_host_approval_broker_under } +import gunbc.live_deploy.spec { + DeploymentSpec, + DeploymentStep, + ArtifactStep, + Dependency, + FabricStorageServeUnit, + FabricStorageServeMapping, + FabricStorageStoreDirectory, + deployment_spec_for_under, + deployment_owns_the_host_approval_broker, + deployment_fabric_storage_door_socket, +} +import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster } +import gunbc.live_deploy.role_singleton_convergence { + RoleSingletonConvergence, + RoleSingletonNoop, + RoleSingletonInstall, + RoleSingletonRefused, + RoleSingletonMoveUnimplemented, + RoleSingletonRemovalUnimplemented, + RoleSingletonHostUnobservable, + RoleSingletonHoldersAmbiguous, + RoleSingletonOwnerUnmapped, + HostRoleSingletonReading, + HostRoleSingletonsRead, + HostRoleSingletonsUnobservable, + role_singleton_convergence, + role_singleton_observation_of, +} +import gunbc.fleet_converge_workflow { fleet_converge_gcp_iam_converge_job_under } +import extdeps.github.actions { SelfHosted, RunStep } import gunbc.fabric_storage_placement { FabricStoragePlacementRefused, - fabric_storage_placed_on_under, fabric_storage_placement_under, + fabric_storage_store_directories_for, + fabric_storage_door_socket, } import gunbc.auth.approval_request_client { ApprovalLoopbackOriginResolved, @@ -153,45 +184,75 @@ test fn the_wrong_instance_failure_mode_row_cites_this_red() -> Bool { any(a_test_deployment_acts_on_the_prod_deployments_state.evidence, r => r.decl_name == "a_test_deployment_refuses_rather_than_resolving_the_prod_bindings") } -// ── The role-following singletons: broker owner, fabric storage placement, approval origin ────── -// THE DISCRIMINATING RED (deployment-risk plan, deliverable 2). A FIXTURE selection moves the role -// to srv2-deploy over the REAL instance roster, and every singleton that follows the role moves with -// it: the broker owner is srv2-deploy and no longer srv1-live, the fabric store is placed on -// srv2-deploy and not on srv1-live, and approvals are reached at srv2-deploy's listen address. A pin -// back to srv1 by name -- an instance_id literal, srv1_live_dashboard_instance(), operator_host_srv1 -// -- keeps answering srv1-live and fails the srv1-negative conjuncts. -fn fx_moved_realization() -> ProdRoleRealization { - prod_role_realization_in( - selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }, - instances: public_builtin_dashboard_instances(), - ) -} - -test fn moving_the_prod_role_moves_the_broker_owner_and_the_fabric_storage_placement() -> Bool { - let moved = fx_moved_realization() - let srv2 = srv2_deploy_dashboard_instance() - let srv1 = srv1_live_dashboard_instance() - let origin_moved = match approval_loopback_origin_under(realization: moved) { - ApprovalLoopbackOriginResolved { origin } => - (origin as String) == (approval_loopback_origin_of(instance: srv2) as String) - && !((origin as String) == (approval_loopback_origin_of(instance: srv1) as String)) - ApprovalLoopbackOriginRefused { cause: _ } => false - } - instance_owns_the_host_approval_broker_under(realization: moved, instance_id: srv2.instance_id) - && !instance_owns_the_host_approval_broker_under(realization: moved, instance_id: srv1.instance_id) - && fabric_storage_placed_on_under(realization: moved, instance: srv2) - && !fabric_storage_placed_on_under(realization: moved, instance: srv1) - && origin_moved +// ── The role-following singletons, at the TRANSITION boundary ─────────────────────────────────── +// Every claim below reads what an apply would actually be handed: the roster's specs built under a +// supplied realization (deployment_spec_for_under), the convergence decision over supplied per-host +// readings, and the emitted GCP IAM job. The production entry points run the same functions over +// prod_role_selection and a real observation; the positive control at the end is over those. +fn fx_moved() -> ProdRoleRealization { + prod_role_realization_in(selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }, instances: public_builtin_dashboard_instances()) } -// UNDER NoProdRole EVERY ROLE-FOLLOWER REFUSES WITH THE TYPED CAUSE -- nobody owns the broker, the -// store is not placed anywhere, and approvals have no origin. A fallback to srv1 fails every conjunct. -test fn under_no_prod_role_the_broker_owner_and_the_fabric_storage_placement_refuse() -> Bool { - let unheld = prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances()) - let realization_refused = match unheld { - ProdRoleRealizationRefused { cause: ProdRoleUnheld } => true - _ => false - } +fn fx_unheld() -> ProdRoleRealization { + prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances()) +} + +fn roster_specs_under(realization: ProdRoleRealization) -> List { + map(desired_deployment_roster(), r => deployment_spec_for_under(realization: realization, instance: r.instance, target: r.target, names: r.names)) +} + +fn spec_store_directory_count(spec: DeploymentSpec) -> Int { + count(spec.steps |> filter(s => match s { + Dependency { step } => match step.kind { FabricStorageStoreDirectory => true _ => false } + ArtifactStep { step: _ } => false + })) +} + +fn spec_store_artifact_count(spec: DeploymentSpec) -> Int { + count(spec.steps |> filter(s => match s { + ArtifactStep { step } => match step.kind { FabricStorageServeUnit => true FabricStorageServeMapping => true _ => false } + Dependency { step: _ } => false + })) +} + +fn spec_holds_no_singleton(spec: DeploymentSpec) -> Bool { + spec_store_directory_count(spec: spec) == 0 + && spec_store_artifact_count(spec: spec) == 0 + && !deployment_owns_the_host_approval_broker(spec: spec) + && match deployment_fabric_storage_door_socket(spec: spec) { Absent => true Present { value: _ } => false } +} + +fn is_srv2_deploy(spec: DeploymentSpec) -> Bool { + (spec.instance_id as String) == (srv2_deploy_dashboard_instance().instance_id as String) +} + +// THE DISCRIMINATING RED. Move the role to srv2-deploy: the srv2 spec carries EVERY store directory, +// the unit and route, the broker, and its own door socket; both srv1 specs carry none of them. A pin +// back to srv1 by name, or directories left on the srv1 target, fails it. +test fn moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec() -> Bool { + let specs = roster_specs_under(realization: fx_moved()) + let srv2 = specs |> filter(s => is_srv2_deploy(spec: s)) + let others = specs |> filter(s => !is_srv2_deploy(spec: s)) + let want_dirs = count(fabric_storage_store_directories_for(instance: srv2_deploy_dashboard_instance())) + count(srv2) == 1 + && count(others) > 0 + && want_dirs > 0 + && all(srv2, s => + spec_store_directory_count(spec: s) == want_dirs + && spec_store_artifact_count(spec: s) == 2 + && deployment_owns_the_host_approval_broker(spec: s) + && match deployment_fabric_storage_door_socket(spec: s) { + Present { value: k } => (k as String) == (fabric_storage_door_socket(instance: srv2_deploy_dashboard_instance()) as String) + Absent => false + } + ) + && all(others, s => spec_holds_no_singleton(spec: s)) +} + +// UNDER NoProdRole NO TARGET GETS A DIRECTORY, A UNIT, A ROUTE OR THE BROKER, and every role-follower +// refuses with the realization's typed cause. +test fn under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses() -> Bool { + let unheld = fx_unheld() let placement_refused = match fabric_storage_placement_under(realization: unheld) { FabricStoragePlacementRefused { cause: ProdRoleUnheld } => true _ => false @@ -200,13 +261,9 @@ test fn under_no_prod_role_the_broker_owner_and_the_fabric_storage_placement_ref ApprovalLoopbackOriginRefused { cause: ProdRoleUnheld } => true _ => false } - realization_refused + all(roster_specs_under(realization: unheld), s => spec_holds_no_singleton(spec: s)) && placement_refused && origin_refused - && all(public_builtin_dashboard_instances(), i => - !instance_owns_the_host_approval_broker_under(realization: unheld, instance_id: i.instance_id) - && !fabric_storage_placed_on_under(realization: unheld, instance: i) - ) } // A HOLDER WITH NO REALIZATION REFUSES TOO, rather than resolving "the first instance". @@ -218,20 +275,102 @@ test fn a_prod_role_holder_with_no_instance_refuses_as_unrealized() -> Bool { } } -// THE POSITIVE CONTROL, over the REAL selection and the production entry points: today srv1-live -// realizes the holder, owns the broker and holds the placement, and srv1's lab does neither. Without -// it the REDs above would pass for resolvers that refuse everyone. -test fn the_real_selection_places_the_broker_and_the_store_on_its_holder() -> Bool { +// ── The convergence transitions, over supplied per-host readings ──────────────────────────────── +fn read_on(host: String, holders: List, broker: Bool) -> HostRoleSingletonReading { + HostRoleSingletonsRead { host: host as NonEmptyStr, holders: holders, broker_present: broker } +} + +fn decide(desired: ProdRoleRealization, readings: List) -> RoleSingletonConvergence { + role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings)) +} + +test fn a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move() -> Bool { + match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true), read_on(host: "srv2", holders: [], broker: false)]) { + RoleSingletonRefused { cause: RoleSingletonMoveUnimplemented { observed: o, desired: d } } => o == srv1_daily_workspace_deployment && d == srv2_deploy_deployment + _ => false + } +} + +test fn no_prod_role_with_a_holder_observed_refuses_as_a_removal() -> Bool { + match decide(desired: fx_unheld(), readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true)]) { + RoleSingletonRefused { cause: RoleSingletonRemovalUnimplemented { observed: o } } => o == srv1_daily_workspace_deployment + _ => false + } +} + +// UNOBSERVED IS NOT ABSENT: with srv1 unreadable and srv2 empty, the moved role must NOT install. +test fn an_unobservable_roster_host_refuses_rather_than_reading_as_absent() -> Bool { + match decide(desired: fx_moved(), readings: [HostRoleSingletonsUnobservable { host: "srv1" as NonEmptyStr, cause: "ssh refused" }, read_on(host: "srv2", holders: [], broker: false)]) { + RoleSingletonRefused { cause: RoleSingletonHostUnobservable { host: h, cause: _ } } => (h as String) == "srv1" + _ => false + } +} + +test fn nothing_observed_and_a_valid_holder_installs_on_that_holder() -> Bool { + match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [], broker: false), read_on(host: "srv2", holders: [], broker: false)]) { + RoleSingletonInstall { holder } => holder.instance.deployment == srv2_deploy_deployment + _ => false + } +} + +test fn the_observed_holder_equal_to_the_desired_one_is_a_noop() -> Bool { + match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [], broker: false), read_on(host: "srv2", holders: [srv2_deploy_deployment], broker: true)]) { + RoleSingletonNoop => true + _ => false + } +} + +test fn two_observed_holders_or_an_ownerless_broker_refuse() -> Bool { + let two = match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true), read_on(host: "srv2", holders: [srv2_deploy_deployment], broker: true)]) { + RoleSingletonRefused { cause: RoleSingletonHoldersAmbiguous { observed: os } } => count(os) == 2 + _ => false + } + let ownerless = match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [], broker: true), read_on(host: "srv2", holders: [], broker: false)]) { + RoleSingletonRefused { cause: RoleSingletonOwnerUnmapped { host: h } } => (h as String) == "srv1" + _ => false + } + two && ownerless +} + +// ── The emitted GCP IAM job follows the holder ────────────────────────────────────────────────── +test fn the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role() -> Bool { + let moved = fleet_converge_gcp_iam_converge_job_under(realization: fx_moved()) + let unheld = fleet_converge_gcp_iam_converge_job_under(realization: fx_unheld()) + let srv2_host = srv2_deploy_dashboard_instance().host_identity as String + let srv1_host = srv1_live_dashboard_instance().host_identity as String + let moved_labels = match moved.runner { SelfHosted { labels } => labels _ => [] } + let unheld_labels = match unheld.runner { SelfHosted { labels } => labels _ => [] } + let unheld_refuses = any(unheld.steps, st => match st { + RunStep { name: _, id: _, run, shell: _, env: _, working_directory: _, if_condition: _, continue_on_error: _, timeout_minutes: _ } => + string_contains(s: run, pattern: "REFUSED") && string_contains(s: run, pattern: "NoProdRole") + _ => false + }) + any(moved_labels, l => l == srv2_host) + && !any(moved_labels, l => l == srv1_host) + && !any(unheld_labels, l => l == srv1_host || l == srv2_host) + && count(unheld.steps) == 1 + && unheld_refuses +} + +// ── THE POSITIVE CONTROL, over the REAL selection and roster ──────────────────────────────────── +// Today the daily-workspace deployment (srv1-live) holds the role: its spec carries every singleton +// member, srv1's lab and srv2-deploy carry none, the GCP IAM job runs on srv1, and the real +// realization with the role's singletons observed on srv1-live is a Noop. Without it the REDs above +// would pass for constructions that place nothing anywhere. +test fn the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop() -> Bool { let real = prod_role_realization() - let live = srv1_live_dashboard_instance() - let lab = srv1_lab_dashboard_instance() - let holder_is_live = match real { - ProdRoleRealizedBy { instance } => (instance.instance_id as String) == (live.instance_id as String) - ProdRoleRealizationRefused { cause: _ } => false + let specs = roster_specs_under(realization: real) + let live_id = srv1_live_dashboard_instance().instance_id as String + let live = specs |> filter(s => (s.instance_id as String) == live_id) + let others = specs |> filter(s => !((s.instance_id as String) == live_id)) + let job_labels = match fleet_converge_gcp_iam_converge_job_under(realization: real).runner { SelfHosted { labels } => labels _ => [] } + let noop = match decide(desired: real, readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true), read_on(host: "srv2", holders: [], broker: false)]) { + RoleSingletonNoop => true + _ => false } - holder_is_live - && instance_owns_the_host_approval_broker_under(realization: real, instance_id: live.instance_id) - && !instance_owns_the_host_approval_broker_under(realization: real, instance_id: lab.instance_id) - && fabric_storage_placed_on_under(realization: real, instance: live) - && !fabric_storage_placed_on_under(realization: real, instance: lab) + count(live) == 1 + && all(live, s => spec_store_directory_count(spec: s) > 0 && spec_store_artifact_count(spec: s) == 2 && deployment_owns_the_host_approval_broker(spec: s)) + && all(others, s => spec_holds_no_singleton(spec: s)) + && any(job_labels, l => l == (srv1_live_dashboard_instance().host_identity as String)) + && noop } diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index ccd348d257e..f653e27f3de 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -826,8 +826,8 @@ fn spec_owned_paths(spec: DeploymentSpec) -> List { // #12747 added a second host-scoped member, though nothing about the twin was wrong. fn live_host_singleton_paths(live: DeploymentSpec) -> List { concat( - deployment_fabric_storage_steps(instance: srv1_live_dashboard_instance(), names: live.names), - deployment_approval_broker_front_door_steps(instance: srv1_live_dashboard_instance()), + deployment_fabric_storage_steps(holding: live.role_singletons, names: live.names), + deployment_approval_broker_front_door_steps(holding: live.role_singletons), ) |> map(s => s.path as String) } @@ -848,12 +848,12 @@ test fn a_twin_deployment_on_one_host_collides_with_nothing() -> Bool { && all(singletons, h => any(live_paths, l => l == h)) && all(live_instance_scoped, l => any(twin_steps, t => t.kind == l.kind)) && all(twin_steps, t => any(live_instance_scoped, l => l.kind == t.kind)) - && count(deployment_fabric_storage_steps(instance: srv1_lab_dashboard_instance(), names: twin.names)) == 0 - && count(deployment_approval_broker_front_door_steps(instance: srv1_lab_dashboard_instance())) == 0 + && count(deployment_fabric_storage_steps(holding: twin.role_singletons, names: twin.names)) == 0 + && count(deployment_approval_broker_front_door_steps(holding: twin.role_singletons)) == 0 && all(twin_paths, t => !any(live_paths, l => l == t)) } -data placement_scoped_members_note: String = "THE TWIN MIRRORS PRODUCTION ONE-FOR-ONE EXCEPT FOR THE PLACEMENT'S OWN MEMBERS. The fabric DB endpoint -- its serve unit and its tailscale route -- is a host singleton owned by the one instance that holds the fabric DB placement (gunbc.fabric_storage_placement fabric_storage_placed_on), so production carries exactly those members more than the twin, and the twin none. The relation stays between two live rosters: the difference is named by the function that decides it, not typed as a number." +data placement_scoped_members_note: String = "THE TWIN MIRRORS PRODUCTION ONE-FOR-ONE EXCEPT FOR THE PLACEMENT'S OWN MEMBERS. The fabric DB endpoint -- its serve unit and its tailscale route -- is a host singleton owned by the one instance that holds the fabric DB placement (gunbc.live_deploy.spec RoleSingletonHolding), so production carries exactly those members more than the twin, and the twin none. The relation stays between two live rosters: the difference is named by the function that decides it, not typed as a number." data non_degeneracy_without_transcription_note: String = "THE COUNTS ABOVE STOPPED BEING LITERALS (review, 2026-07-30 — the live-state count-transcription class ruled against on 2026-07-29 and swept in #7464). Five was read off the live owned roster, so authoring a correct sixth member reds a claim that has nothing to say about members, and the reviewer's habit becomes editing the number until the day the number is wrong for a real reason. #7478 adds exactly such a member.\\n\\nDeleting the conjunct outright would have been the wrong fix, which is why this is a replacement rather than a removal: `all` over an empty list is vacuously true, so without a non-degeneracy guard a twin whose owned set collapsed to nothing would satisfy the disjointness claim perfectly. The literal was doing real work in a way its shape disguised.\\n\\nWhat replaces it says the same thing without reading the roster's size into the test: the production set is non-empty, and the twin mirrors it one-for-one. Both survive a member being added — the relation is between two live rosters rather than between one roster and a number a human typed — and the second is strictly stronger than the literal pair, since it would also catch a twin that gained or lost a member production did not." @@ -874,6 +874,7 @@ test fn retract_is_the_reverse_of_what_this_spec_applies() -> Bool { instance: srv1_lab_dashboard_instance(), target: deployment_srv1_target(), names: derived_deployment_names(instance: srv1_lab_dashboard_instance()), + holding: twin.role_singletons, ) let applied_paths = applied |> flat_map(s => match s { ArtifactStep { step } => [step.path as String] @@ -1088,7 +1089,7 @@ test fn fleet_converge_timer_not_installed_by_deploy_apply() -> Bool { let spec = deployment_spec_srv1() let live = apply_emitted_steps_text(spec: spec, plan: deploy_witness_full_install_plan(spec: spec)) !any( - deployment_owned_steps(instance: srv1_live_dashboard_instance(), names: spec.names), + deployment_owned_steps(instance: srv1_live_dashboard_instance(), names: spec.names, holding: spec.role_singletons), s => match s.kind { FleetConvergeTimerUnit => true _ => false } ) && !string_contains(s: live, pattern: "OnCalendar=*:0/15") diff --git a/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag b/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag index ed328dce08b..6bb16e96c65 100644 --- a/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag +++ b/dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag @@ -778,14 +778,14 @@ test fn slice_unit_is_not_installable() -> Bool { fn spec_with_principal(wrong_user: NonEmptyStr) -> DeploymentSpec { let base = live_spec() match base { - DeploymentSpec { target: t, steps: s, names: n, service: svc, approval_broker: ab, instance_id: iid } => + DeploymentSpec { target: t, steps: s, names: n, service: svc, approval_broker: ab, instance_id: iid, role_singletons: rs } => match svc { DeploymentServiceConfig { unit_name: u, serve_binary: b, serve_entry: se, serve_function: f, provider_state_root: ps, instance_root: ir, repo_root: rr, service_uid: uid, listen: l, serve_cpu_limit: cpu, serve_wall_limit: wall } => DeploymentSpec { - target: t, steps: s, names: n, instance_id: iid, approval_broker: ab, + target: t, steps: s, names: n, instance_id: iid, approval_broker: ab, role_singletons: rs, service: DeploymentServiceConfig { unit_name: u, serve_binary: b, serve_entry: se, serve_function: f, provider_state_root: ps, @@ -817,14 +817,14 @@ fn divergent_repo_root() -> NonEmptyStr { fn spec_with_repo_root(wrong_root: NonEmptyStr) -> DeploymentSpec { match live_spec() { - DeploymentSpec { target: t, steps: s, names: n, service: svc, approval_broker: ab, instance_id: iid } => + DeploymentSpec { target: t, steps: s, names: n, service: svc, approval_broker: ab, instance_id: iid, role_singletons: rs } => match svc { DeploymentServiceConfig { unit_name: u, serve_binary: b, serve_entry: se, serve_function: f, provider_state_root: ps, instance_root: ir, service_user: su, service_uid: uid, listen: l, serve_cpu_limit: cpu, serve_wall_limit: wall } => DeploymentSpec { - target: t, steps: s, names: n, instance_id: iid, approval_broker: ab, + target: t, steps: s, names: n, instance_id: iid, approval_broker: ab, role_singletons: rs, service: DeploymentServiceConfig { unit_name: u, serve_binary: b, serve_entry: se, serve_function: f, provider_state_root: ps, @@ -865,14 +865,14 @@ test fn serve_unit_user_goes_red_under_different_user() -> Bool { fn spec_with_port(wrong_port: Port) -> DeploymentSpec { match live_spec() { - DeploymentSpec { target: t, steps: s, names: n, service: svc, approval_broker: ab, instance_id: iid } => + DeploymentSpec { target: t, steps: s, names: n, service: svc, approval_broker: ab, instance_id: iid, role_singletons: rs } => match svc { DeploymentServiceConfig { unit_name: u, serve_binary: b, serve_entry: se, serve_function: f, provider_state_root: ps, instance_root: ir, repo_root: rr, service_user: su, service_uid: uid, listen: l, serve_cpu_limit: cpu, serve_wall_limit: wall } => DeploymentSpec { - target: t, steps: s, names: n, instance_id: iid, approval_broker: ab, + target: t, steps: s, names: n, instance_id: iid, approval_broker: ab, role_singletons: rs, service: DeploymentServiceConfig { unit_name: u, serve_binary: b, serve_entry: se, serve_function: f, provider_state_root: ps, diff --git a/dag/test/claim/roadmap/serve_function_binding_witness_test.dag b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag new file mode 100644 index 00000000000..33224969b9e --- /dev/null +++ b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag @@ -0,0 +1,58 @@ +module test.claim.serve_function_binding_witness_test + +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.types { Bool, String, NonEmptyStr, List, CommitSha } +import extdeps.http.server { ServeWireResponse } +import gunbc.roadmap_dashboard_instance { + HostDashboardInstance, + public_builtin_dashboard_instances, + dashboard_instance_health_identity_prefix, +} +import gunbc.roadmap_serve { + roadmap_serve_handle, + roadmap_serve_handle_macbook_local, + roadmap_serve_handle_srv1_lab, + roadmap_serve_handle_srv2_preview, + roadmap_serve_handle_srv2_deploy, + roadmap_serve_handle_srv2_lab, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// EVERY INSTANCE'S serve_function SERVES THAT SAME INSTANCE. HostDashboardInstance.serve_function is +// the name its unit passes to `gunbc serve --function`, and each declared handler binds exactly one +// instance; a row naming another instance's handler serves that other instance's state under its own +// unit -- the MacBook row named srv1-live's roadmap_serve_handle while roadmap_serve_handle_macbook_local +// existed. The control runs each handler on the health route and requires the answer to START with +// the identity prefix of the instance whose row names it, so a handler bound to any other instance +// fails, and a row naming a handler this table does not carry fails as unbound. +type ServeHandlerRow { + name: String + handle: fn(String, String, String, String, CommitSha) -> ServeWireResponse +} + +fn declared_serve_handlers() -> List { + [ + ServeHandlerRow { name: "roadmap_serve_handle", handle: (m, p, b, t, r) => roadmap_serve_handle(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_macbook_local", handle: (m, p, b, t, r) => roadmap_serve_handle_macbook_local(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv1_lab", handle: (m, p, b, t, r) => roadmap_serve_handle_srv1_lab(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv2_preview", handle: (m, p, b, t, r) => roadmap_serve_handle_srv2_preview(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv2_deploy", handle: (m, p, b, t, r) => roadmap_serve_handle_srv2_deploy(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv2_lab", handle: (m, p, b, t, r) => roadmap_serve_handle_srv2_lab(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, + ] +} + +fn instance_serves_itself(instance: HostDashboardInstance) -> Bool { + let bound = filter(declared_serve_handlers(), h => h.name == (instance.serve_function as String)) + count(bound) == 1 + && all(bound, h => { + let serve = h.handle + let answer = serve("GET", "/healthz", "", "", "0000000000000000000000000000000000000000" as CommitSha) + answer.status == 200 + && starts_with(s: answer.body, prefix: dashboard_instance_health_identity_prefix(instance_id: instance.instance_id as String)) + }) +} + +test fn every_instances_serve_function_serves_that_instance() -> Bool { + all(public_builtin_dashboard_instances(), i => instance_serves_itself(instance: i)) +} diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index fa96d370382..62d259fcea6 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -23,8 +23,11 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - Declared frontier (review 74937; merry-tern-58 ruling 2026-10-04): `DeploymentRiskInterface`, `DeploymentHarmMagnitudeBet` and the operator-disposition gate are **deferred until an operator policy differs by class**. Today's policy (gunbc#13124: automatic repair is "no" for every deployment) is class-independent, so a gate consulting the class would decide nothing (§3c decoration), and a class-dependent policy is the operator's call. No carrier is minted before then. Design note for when the bet lands: its magnitude is an order of magnitude over `Duration` (today `std.types` `Duration`; std has no order-of-magnitude carrier yet), e.g. a decade exponent beside its unit, typed with `extdeps.external_authority` `CitedFigureStanding`, and it never decides the class. 1b. **Machine qualification migration**: one motion over machine_intake (re-census on main first; coordinate warm-crane-577's mtcollins1 untangle lane). eager-gull-22 handed it to this lane. 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. - - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous`). Consumers: broker owner (`instance_owns_the_host_approval_broker`, the `"srv1-live"` literal deleted), fabric storage placement (`FabricStoragePlacementRefused`; `operator_host_srv1` no longer named), approval client origin, identity trust, and `DashboardProtectedSibling` (same-host siblings whose class is `ProdRisk`). RED in `test.claim.deployment_risk_witness_test`. - - Not repointed, with reasons (failure-mode row carries them): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`), so they move to per-deployment names in deliverable 4, not to the role; `belt_dispatch_instance_cli`'s absent-env default; `approval_broker_host_identity` (workflow runner label, no refusal arm in emission); `fleet_reach_endpoint` (host-shared account fact, not a prod pin). + - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). A spec carries it as `gunbc.live_deploy.spec` `RoleSingletonHolding`; the broker unit and front door and the fabric store's unit, route, socket, root and directories are built from it and move between targets as one subject (no instance-id ownership; the `"srv1-live"` literal and `operator_host_srv1` are gone). Also following the realization: approval client origin, identity trust, the GCP IAM converge job's runner (refused job under NoProdRole), and `DashboardProtectedSibling` (same-host siblings whose class is `ProdRisk`). + - Role-singleton convergence (`gunbc.live_deploy.role_singleton_convergence`, gating every apply): desired realization × an observation of every roster host, reusing `member_observe` legs. `RoleSingletonNoop` | `RoleSingletonInstall` (only when nothing is observed anywhere) | `RoleSingletonRefused` with `RoleSingletonMoveUnimplemented`, `RoleSingletonRemovalUnimplemented`, `RoleSingletonHostUnobservable` (unobserved is never absent), `RoleSingletonHoldersAmbiguous`, `RoleSingletonOwnerUnmapped`. **Next trigger:** Move and Remove actuations (persistent-state move, singleton removal) become arms beside the existing ones. + - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. + - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. + - Honest non-pins (not debt): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`, now checked roster-wide by `test.claim.serve_function_binding_witness_test`), so they must not follow the role; `fleet_reach_endpoint` is a host-shared account fact. Not claimed: that an unbound test deployment refuses everywhere — `belt_dispatch_instance_cli`'s absent-env default stays with the belt owner's lane. 3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. 4. **One spec per deployment**; retire the `live_deploy.desired` rung drop by its own trigger. Renames: `srv1_production_desired_deployment → srv1_primary_desired_deployment`, `roadmap_belt_production_spawn_mode → roadmap_belt_operational_spawn_mode`. 5. **Conformance row** `deployment-risk conformance` in `gunbc.design_argument conformance_domains`, once the home is consumed. @@ -40,7 +43,7 @@ Widened scope (side chat): classify every convergence subject as deployment-boun | Codex account and home | still shared | 3.2 | | admitted ref | still shared (labs on `refs/fleet/desired`) | 3.3 | | R2 / GCP / WIF | still shared | 3.5; coordinate royal-moth-86 (#13035) | -| approval broker unit (singleton) | follows the role via `prod_role_realization`; NoProdRole → no owner | deliverable 2 | -| fabric storage placement (singleton) | follows the role; NoProdRole → `FabricStoragePlacementRefused` | deliverable 2 | +| approval broker unit and front door, fabric store unit/route/socket/root/directories (role singletons) | one subject on the holder's spec (`RoleSingletonHolding`); a role change refuses until Move/Remove land; NoProdRole → no target holds any | deliverable 2 | +| GCP IAM converge job runner | holder's host; NoProdRole → refused job | deliverable 2 | | approval loopback origin / identity trust | follows the role; NoProdRole → origin refused, trust not established | deliverable 2 | | OIDC client | still shared, unmodeled | 3.6 | From c4e830f705a5e829f8a2cb6e435ec2706def41fb Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 09:14:49 +0000 Subject: [PATCH 13/48] Merge origin/main; pass deployment at the four isolated_dashboard_instance callers main added (queue dequeue on failed floor) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/test/claim/auth/login_callback_hold_wet_witness_test.dag | 5 +++-- dag/test/claim/auth/request_cookie_evidence_witness_test.dag | 5 +++-- .../roadmap_attempt_history_evidence_witness_test.dag | 2 ++ .../claim/roadmap/roadmap_dogfood_route_wet_witness_test.dag | 2 ++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dag/test/claim/auth/login_callback_hold_wet_witness_test.dag b/dag/test/claim/auth/login_callback_hold_wet_witness_test.dag index 36eb63cc9b2..b958da957e3 100644 --- a/dag/test/claim/auth/login_callback_hold_wet_witness_test.dag +++ b/dag/test/claim/auth/login_callback_hold_wet_witness_test.dag @@ -1,9 +1,10 @@ module test.claim.auth.login_callback_hold_wet_witness -import std.types { Bool, FilePath, String } +import std.types { NonEmptyStr, Bool, FilePath, String } import extdeps.auth.oidc { OidcLoginTransaction, OidcPkceCodeVerifier, RelativeReturnPath } import extdeps.shell import gunbc.host_layout { srv1_host_layout } +import gunbc.deployment_risk { deployment_id } import gunbc.roadmap_dashboard_instance { isolated_dashboard_instance, DashboardObserveOnly, DashboardLocalProvision } import gunbc.auth.login_transaction_store { login_transaction_store_layout_for_instance, login_transaction_store_save, login_transaction_store_lookup, LoginTransactionSaved, LoginTransactionStoreFound, LoginTransactionStoreUnknown } import gunbc.auth.login_browser_binding { auth_login_browser_binding } @@ -14,7 +15,7 @@ import gunbc.roadmap_auth_routes { auth_google_callback_response, auth_callback_ test fn foreign_browser_cannot_consume_and_cached_replay_cannot_exchange() -> Bool { let directory = shell.Mktemp.Dir() if !directory.success { false } else { - let instance = isolated_dashboard_instance(instance_id: "login-hold-fixture", layout: srv1_host_layout(), root: directory.path as FilePath, + let instance = isolated_dashboard_instance(deployment: deployment_id(name: "login-hold-fixture" as NonEmptyStr), instance_id: "login-hold-fixture", layout: srv1_host_layout(), root: directory.path as FilePath, serve_function: "unused", listen_port: 18744, source_anchor_ref: "refs/heads/fixture", actuation: DashboardObserveOnly, provision_transport: DashboardLocalProvision) let layout = login_transaction_store_layout_for_instance(instance: instance) let made = shell.Mkdir.Parents(path: layout.root as String) diff --git a/dag/test/claim/auth/request_cookie_evidence_witness_test.dag b/dag/test/claim/auth/request_cookie_evidence_witness_test.dag index 80814119e54..251f6204610 100644 --- a/dag/test/claim/auth/request_cookie_evidence_witness_test.dag +++ b/dag/test/claim/auth/request_cookie_evidence_witness_test.dag @@ -1,7 +1,8 @@ module test.claim.auth.request_cookie_evidence_witness_test -import std.types { Bool, FilePath } +import std.types { NonEmptyStr, Bool, FilePath } import gunbc.host_layout { srv1_host_layout } +import gunbc.deployment_risk { deployment_id } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, isolated_dashboard_instance, DashboardObserveOnly, DashboardLocalProvision } import gunbc.roadmap_serve { roadmap_serve_request_security } import gunbc.auth.request_authentication { RequestAuthenticationEvidence, AuthAbsent, GoogleOidcSession, FabricServiceEvidence, SessionStandingInvalid } @@ -13,7 +14,7 @@ import gunbc.auth.session { SessionCookieMalformed, SessionCookieValid, SessionC // and never reach the session store, because no session id parses. RED if an absent header, or a // header carrying only other cookies, ever reads as anything but AuthAbsent. fn fixture_instance() -> HostDashboardInstance { - isolated_dashboard_instance(instance_id: "cookie-seam-fixture", layout: srv1_host_layout(), root: "/fixture-never-read" as FilePath, + isolated_dashboard_instance(deployment: deployment_id(name: "cookie-seam-fixture" as NonEmptyStr), instance_id: "cookie-seam-fixture", layout: srv1_host_layout(), root: "/fixture-never-read" as FilePath, serve_function: "unused", listen_port: 18745, source_anchor_ref: "refs/heads/fixture", actuation: DashboardObserveOnly, provision_transport: DashboardLocalProvision) } diff --git a/dag/test/claim/roadmap/roadmap_attempt_history_evidence_witness_test.dag b/dag/test/claim/roadmap/roadmap_attempt_history_evidence_witness_test.dag index dd05814bc5e..46ebc40c0fd 100644 --- a/dag/test/claim/roadmap/roadmap_attempt_history_evidence_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_attempt_history_evidence_witness_test.dag @@ -3,6 +3,7 @@ module test.claim.roadmap_attempt_history_evidence_witness_test import std.types { Bool, String, NonEmptyStr, FilePath } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import gunbc.host_layout { srv1_host_layout } +import gunbc.deployment_risk { deployment_id } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, isolated_dashboard_instance, DashboardActuating, DashboardLocalProvision, } @@ -24,6 +25,7 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // production by the belt's history scan (gunbc.roadmap_dogfood_route) over srv2's attempt state. fn instance() -> HostDashboardInstance { isolated_dashboard_instance( + deployment: deployment_id(name: "attempt-history-witness" as NonEmptyStr), instance_id: "attempt-history-witness" as NonEmptyStr, layout: srv1_host_layout(), root: "/instance" as FilePath, diff --git a/dag/test/claim/roadmap/roadmap_dogfood_route_wet_witness_test.dag b/dag/test/claim/roadmap/roadmap_dogfood_route_wet_witness_test.dag index bd5406d2a50..f29148a9641 100644 --- a/dag/test/claim/roadmap/roadmap_dogfood_route_wet_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_dogfood_route_wet_witness_test.dag @@ -9,6 +9,7 @@ import gunbc.durable_cas_file_store { DerivedPayloadAdmitted, DerivedPayloadKeyNotSlotAddressable, } import gunbc.host_layout { srv1_host_layout } +import gunbc.deployment_risk { deployment_id } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, isolated_dashboard_instance, dashboard_instance_dispatch_attempt_state_root, DashboardActuating, DashboardLocalProvision, @@ -32,6 +33,7 @@ import gunbc.roadmap_belt_tick_cli { belt_start_record_exit } fn fresh_instance(tag: String) -> HostDashboardInstance { let dir = shell.Mktemp.DirWithTemplate(template: join(["/tmp/gunbc_dogfood_", tag, ".XXXXXX"], "")).path isolated_dashboard_instance( + deployment: deployment_id(name: "dogfood-wet" as NonEmptyStr), instance_id: "dogfood-wet" as NonEmptyStr, layout: srv1_host_layout(), root: dir as FilePath, From 74c62bfa63b6bb90f24bac0cb61c649017c29ee4 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 09:34:51 +0000 Subject: [PATCH 14/48] serve_function control probes /instance.json, the per-instance identity route (/healthz is release-only) Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2run.sh | 25 ------------------- .../serve_function_binding_witness_test.dag | 5 ++-- 2 files changed, 3 insertions(+), 27 deletions(-) delete mode 100644 .d2run.sh diff --git a/.d2run.sh b/.d2run.sh deleted file mode 100644 index 622a6dfa906..00000000000 --- a/.d2run.sh +++ /dev/null @@ -1,25 +0,0 @@ -set -u -cargo build --release -p v1-compiler --bin claim_batch 2>&1 | tail -1 -mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 21474836480 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs -CB=$PWD/target/release/claim_batch -echo "=== emit_test whole file" -$CB --source-root dag --source-root src/v2 --entry dag/test/claim/live_deploy/emit_test.dag --functions witness_spec_targets_srv1,witness_spec_uses_apt_package_manager,witness_spec_listen_port_is_8080,witness_owned_vs_ensured_typed,witness_apply_provisions_attempt_state_root_owned,witness_attempt_state_mode_derives_to_owner_only,witness_retract_attempt_state_root_refuses_when_nonempty,witness_tailnet_domain_propagates_optional,witness_tailnet_url_absent_not_fabricated,witness_dependency_ensure_is_idempotent,witness_apply_emits_no_embedded_preflight_and_only_sudoers,witness_retract_preamble_acknowledges_shared_sudoers_install,the_convergence_slice_is_installed_before_the_convergence_restart,witness_apply_script_contains_systemd_and_tailscale,witness_apply_script_wires_memory_cap_deploy_consumer,retract_script_stays_within_ci_runner_grants,apply_script_stays_within_ci_runner_grants,git_metadata_file_transport_is_present_in_the_legacy_specimen_and_absent_from_emission,repository_convergence_is_ordered_after_binary_and_before_readback,witness_tree_sync_restart_diagnoses_on_failure,witness_retract_script_owned_only,witness_apply_script_has_no_node_server,witness_apply_script_has_no_frozen_page,witness_apply_script_excludes_readback_curls,witness_retract_owned_process_removes_serve_service,witness_execstart_carries_exactly_the_admitted_revision,witness_execstart_omitting_the_revision_is_not_the_emitted_line,witness_execstart_at_another_candidate_differs,witness_systemd_unit_emitted,witness_remote_mutation_service_op_realization_binds_emit_shell_phase_b_axis,witness_emit_shell_dissolution_trigger_is_scaffold,witness_dependency_graph_emit_frontier_binds_orch_emit_pipeline,witness_teardown_dependency_arm_refuses_golden_independent,witness_wholesale_refused_poison_carries_marker,witness_deploy_access_job_principal_refused_poison_golden_independent,witness_apply_script_access_ready_not_deploy_access_poison,a_twin_deployment_on_one_host_collides_with_nothing,twin_teardown_removes_nothing_production_owns,retract_is_the_reverse_of_what_this_spec_applies,production_apply_carries_its_own_handler_and_tree_sync_env,emitted_twin_apply_touches_none_of_productions_effects,twin_belt_unit_carries_the_twins_spawn_root_and_not_productions,production_belt_unit_carries_productions_spawn_root,emitted_twin_retract_removes_nothing_of_productions,shell_quote_escapes_an_embedded_apostrophe,production_apply_creates_productions_binary_parent_directory,twin_apply_creates_its_own_binary_parent_directory_and_not_productions,teardown_disposition_separates_owned_from_ensured,production_apply_serves_its_own_endpoint_and_scopes_no_path,twin_apply_serves_its_own_endpoint_and_not_productions,production_retract_offs_its_own_endpoint,twin_retract_offs_its_own_endpoint_and_not_productions,retract_is_not_emitted_without_an_observed_scoped_removal,fleet_converge_timer_not_installed_by_deploy_apply,emit_,emit_staged_unit_body_is_quoted_by_the_posix_authority,the_preamble_ensures_the_publication_principal_as_a_nologin_system_account,the_publication_member_emits_convergence_and_no_rsync,a_zero_mutation_plan_installs_publishes_and_restarts_nothing_of_the_release,the_full_plan_lands_its_members_in_the_deployments_order,the_fabric_storage_store_root_reaches_the_srv1_apply_path,the_fabric_storage_store_areas_reach_the_srv1_apply_path,the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_principal,the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone,the_placed_fabric_store_derives_entry_mode_0400,no_retract_can_remove_the_fabric_storage_store,a_binary_only_plan_installs_and_restarts_without_publishing_the_tree,a_matching_authorization_applies_the_staged_caps,a_withheld_decision_applies_no_caps_at_the_production_root,an_authorization_for_another_host_applies_no_caps,the_writer_reverts_only_the_authorized_target_units,the_dark_install_never_names_the_roadmap_unit,the_dark_install_execstart_carries_the_admitted_revision,a_twin_apply_neither_writes_nor_restarts_the_host_broker,the_owning_apply_does_write_and_restart_the_host_broker,both_install_paths_bind_the_brokers_one_port,a_twin_dark_install_refuses_rather_than_emitting_an_empty_stage,the_broker_unit_binds_loopback_and_runs_from_its_own_tree,the_dark_install_populates_the_broker_tree,the_dark_install_and_the_unit_name_the_same_candidate_binary,the_receipt_command_substitutes_the_digest_rather_than_splicing_it,the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it > /tmp/et.out 2>&1; echo rc=$? -grep -E "^(PASS|FAIL)" /tmp/et.out | grep -v "^PASS" | head; grep -c "^PASS" /tmp/et.out; grep -v "^\[" /tmp/et.out | grep -iE "error|unknown|not a test|refus|panic|killed|oom" | head -10; tail -5 /tmp/et.out -echo "=== serve binding per instance" -F=dag/test/claim/roadmap/serve_function_binding_witness_test.dag -cat >> $F <<'T' - -fn d_one(id: String) -> Bool { all(public_builtin_dashboard_instances() |> filter(i => (i.instance_id as String) == id), i => instance_serves_itself(instance: i)) } -fn d_status(id: String) -> Bool { all(public_builtin_dashboard_instances() |> filter(i => (i.instance_id as String) == id), i => all(declared_serve_handlers() |> filter(h => h.name == (i.serve_function as String)), h => { let serve = h.handle serve("GET", "/healthz", "", "", "0000000000000000000000000000000000000000" as CommitSha).status == 200 })) } -test fn d_count_each() -> Bool { all(public_builtin_dashboard_instances(), i => count(filter(declared_serve_handlers(), h => h.name == (i.serve_function as String))) == 1) } -test fn d_live() -> Bool { d_one(id: "srv1-live") } -test fn d_live_status() -> Bool { d_status(id: "srv1-live") } -test fn d_mac() -> Bool { d_one(id: "macbook-local") } -test fn d_mac_status() -> Bool { d_status(id: "macbook-local") } -test fn d_srv2() -> Bool { d_one(id: "srv2-deploy") } -test fn d_lab() -> Bool { d_one(id: "srv1-lab-codex-feedback-v0") } -T -$CB --source-root dag --source-root src/v2 --entry $F --functions d_count_each,d_live,d_live_status,d_mac,d_mac_status,d_srv2,d_lab 2>&1 | grep -E "^(PASS|FAIL)|error" | head -20 -git checkout $F -echo "=== broker oracle on base 1e649489991cf8695f3ba7b022de985909a9687e" -git checkout -q 1e649489991cf8695f3ba7b022de985909a9687e -- dag src/v2 && $CB --source-root dag --source-root src/v2 --entry dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag --functions the_approval_broker_unit_would_install_these_bytes 2>&1 | grep -E "^(PASS|FAIL)|error" | head diff --git a/dag/test/claim/roadmap/serve_function_binding_witness_test.dag b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag index 33224969b9e..7764d45c6b4 100644 --- a/dag/test/claim/roadmap/serve_function_binding_witness_test.dag +++ b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag @@ -23,7 +23,8 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // the name its unit passes to `gunbc serve --function`, and each declared handler binds exactly one // instance; a row naming another instance's handler serves that other instance's state under its own // unit -- the MacBook row named srv1-live's roadmap_serve_handle while roadmap_serve_handle_macbook_local -// existed. The control runs each handler on the health route and requires the answer to START with +// existed. The control runs each handler on /instance.json (its per-instance identity route; /healthz +// is release-only and identical for every instance) and requires the answer to START with // the identity prefix of the instance whose row names it, so a handler bound to any other instance // fails, and a row naming a handler this table does not carry fails as unbound. type ServeHandlerRow { @@ -47,7 +48,7 @@ fn instance_serves_itself(instance: HostDashboardInstance) -> Bool { count(bound) == 1 && all(bound, h => { let serve = h.handle - let answer = serve("GET", "/healthz", "", "", "0000000000000000000000000000000000000000" as CommitSha) + let answer = serve("GET", "/instance.json", "", "", "0000000000000000000000000000000000000000" as CommitSha) answer.status == 200 && starts_with(s: answer.body, prefix: dashboard_instance_health_identity_prefix(instance_id: instance.instance_id as String)) }) From f617d9dcca78fb2489520b059537fcef9e416c19 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 09:37:28 +0000 Subject: [PATCH 15/48] D2: the role decides where the broker is installed; broker-side modules receive their host and bind Broker-side modules (endpoint, decision store, submission, device enrolment) no longer import the instance roster, keeping #13132's narrowed native-broker closure: the serving broker reads its own host (approval_broker_serving_base_url), and identity trust derives from approval_broker_listen_host, the one row the broker unit's --host is emitted from. Role-dependent placement for non-broker readers (GCP IAM job, approvals app) moves to gunbc.auth.approval_broker_placement. srv1_dashboard_bind is deleted. Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2main.sh | 6 ++ dag/gunbc/approve_ios_project.dag | 7 +- dag/gunbc/auth/approval_broker_endpoint.dag | 66 +++++++------------ dag/gunbc/auth/approval_broker_placement.dag | 51 ++++++++++++++ dag/gunbc/auth/approval_decision_store.dag | 30 ++++----- .../auth/approval_device_enrolment_code.dag | 6 +- .../auth/approval_request_submission.dag | 8 +-- dag/gunbc/fleet/fleet_converge_workflow.dag | 2 +- dag/gunbc/fleet/fleet_tailnet.dag | 4 +- ...ent_acts_on_the_prod_deployments_state.dag | 2 +- .../roadmap/roadmap_dashboard_instance.dag | 5 +- dag/gunbc/roadmap/srv1_dashboard_bind.dag | 10 --- .../approval_decision_store_witness_test.dag | 15 ++--- .../approve_ios_project_witness_test.dag | 17 +++-- docs/plans/deployment-risk-conformance.md | 2 +- 15 files changed, 127 insertions(+), 104 deletions(-) create mode 100644 .d2main.sh create mode 100644 dag/gunbc/auth/approval_broker_placement.dag delete mode 100644 dag/gunbc/roadmap/srv1_dashboard_bind.dag diff --git a/.d2main.sh b/.d2main.sh new file mode 100644 index 00000000000..8ee4eaf4b24 --- /dev/null +++ b/.d2main.sh @@ -0,0 +1,6 @@ +set -u +git checkout -q f680b955a1f20e53c883a723929d2352f075dcc6 -- . 2>&1 | tail -1 +cargo build --release -p v1-compiler --bin claim_batch 2>&1 | tail -1 +mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 21474836480 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs +git rev-parse HEAD; git diff --stat f680b955a1f20e53c883a723929d2352f075dcc6 -- dag src/v2 | tail -1 +./target/release/claim_batch --source-root dag --source-root src/v2 --entry dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag --functions the_approval_broker_unit_would_install_these_bytes 2>&1 | grep -E "^(PASS|FAIL)|error" diff --git a/dag/gunbc/approve_ios_project.dag b/dag/gunbc/approve_ios_project.dag index 53be24848cb..5a29b8bdb13 100644 --- a/dag/gunbc/approve_ios_project.dag +++ b/dag/gunbc/approve_ios_project.dag @@ -14,7 +14,8 @@ import extdeps.apple.build_settings { MarketingVersion, CurrentProjectVersion } import gunbc.auth.approval_app_attest_config { approval_app_bundle_id, approval_app_bundle_version } -import gunbc.auth.approval_broker_endpoint { approval_broker_public_host_from, approval_broker_public_port, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused, approval_broker_address_refusal_detail } +import gunbc.auth.approval_broker_endpoint { approval_broker_public_port, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } +import gunbc.auth.approval_broker_placement { approval_broker_public_host_from } import gunbc.fleet_intent_network { fleet_intent_network } import extdeps.apple.xcode { xcode_26_6 } import extdeps.xcodegen.project_spec { @@ -45,7 +46,7 @@ import extdeps.languages.yaml.emit { emit_yaml, EmittedYaml, YamlEmitRefused, ya // server_host ApproveServerHost -- read by ServerConfig at launch. // apns_environment ApproveApnsEnvironment -- read by ServerConfig at launch. // apns_environment is xcconfig-substituted from Config/Team.xcconfig. server_host is NOT: it is the -// broker's public host (gunbc.auth.approval_broker_endpoint approval_broker_public_host_from), the +// broker's public host (gunbc.auth.approval_broker_placement approval_broker_public_host_from), the // same composition the approval links are issued against, written into the spec as a value. It was // an operator-typed APPROVE_SERVER_HOST, a second authority for where the broker answers. type ApproveAppInfo { @@ -195,7 +196,7 @@ fn approve_ios_project_yml_from(domain: NonEmptyStr?, port: Port?) -> ApproveIos " and the app's ServerConfig carries no port; project it (an ApproveServerPort Info.plist key read by gunbc.approve_ios_swift_wire ServerConfig) before publishing the broker off the default TLS listener" ], "") } Absent => match approval_broker_public_host_from(domain: domain) { - ApprovalBrokerAddressRefused { cause } => ApproveIosProjectRefused { reason: join(["gunbc.approve_ios_project: ", approval_broker_address_refusal_detail(cause: cause) as String], "") } + ApprovalBrokerAddressRefused { detail } => ApproveIosProjectRefused { reason: join(["gunbc.approve_ios_project: ", detail as String], "") } ApprovalBrokerAddressResolved { value: host } => match emit_yaml(v: project_spec_yaml(p: approve_ios_project_spec(server_host: host))) { EmittedYaml { text: t } => ApproveIosProjectGenerated { content: concat(approve_ios_project_header, t) } YamlEmitRefused { path: p, reason: r } => ApproveIosProjectRefused { reason: yaml_emit_refusal_text(module_path: "gunbc.approve_ios_project", path: p, reason: r) } diff --git a/dag/gunbc/auth/approval_broker_endpoint.dag b/dag/gunbc/auth/approval_broker_endpoint.dag index 7c8d0f062dc..65ef7a91e11 100644 --- a/dag/gunbc/auth/approval_broker_endpoint.dag +++ b/dag/gunbc/auth/approval_broker_endpoint.dag @@ -7,10 +7,8 @@ import extdeps.tailscale.serve { import extdeps.external_authority { ExternalAuthority, CitedFigureStanding, CitedToAuthority, TranscribedUncited } import gunbc.fleet_tailnet { fleet_tailnet_domain, tailnet_base_url_from, tailnet_host_from } import gunbc.serve_liveness { serve_liveness_path } -import gunbc.roadmap_dashboard_instance { - ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, - prod_role_realization_refusal_detail, -} +import gunbc.hostname_read { hostname_short_read, HostnameShortCaptured, HostnameShortCaptureRefused } +import gunbc.host_effect { LocalShell } import gunbc.auth.approval_device_wire { approval_device_request_path_prefix, approval_device_enrollment_path_prefix } // ── WHERE THE APPROVAL BROKER ANSWERS ──────────────────────────────────────────────────────── @@ -98,20 +96,6 @@ data approval_broker_listen_port: Port = 8085 // door is the only public path" true rather than merely intended. data approval_broker_listen_host: NonEmptyStr = "127.0.0.1" -// THE HOST THE BROKER RUNS ON, derived from the prod-role holder that owns its unit (the same -// resolution the loopback client origin follows): a run that files and polls over that client (the -// GCP IAM converge job) has to run there. Under a refused realization there is no such host, and the -// refusal is carried so the job that needs it is emitted refused rather than pinned to srv1. -type ApprovalBrokerHost - = ApprovalBrokerHostResolved { host: NonEmptyStr } - | ApprovalBrokerHostRefused { cause: ProdRoleRealizationRefusal } - -fn approval_broker_host_under(realization: ProdRoleRealization) -> ApprovalBrokerHost { - match realization { - ProdRoleRealizedBy { holder } => ApprovalBrokerHostResolved { host: holder.instance.host_identity } - ProdRoleRealizationRefused { cause } => ApprovalBrokerHostRefused { cause: cause } - } -} fn approval_broker_loopback_base_url(port: Port) -> NonEmptyStr { join(["http://", approval_broker_listen_host as String, ":", to_string(port)], "") as NonEmptyStr @@ -246,37 +230,33 @@ data approval_broker_front_door_path_preservation: CitedFigureStanding = Transcr // (gunbc.approve_ios_project) -- read one port rather than each assuming 443. data approval_broker_public_port: Port? = none -// THE ADDRESS FOLLOWS THE BROKER'S HOST, which follows the prod role (approval_broker_host_under): -// the links, the device enrolment notification and the approvals app all name the host the broker -// actually runs on, and under a refused realization there is no address -- each reader carries the -// refusal into its own refusal arm rather than naming srv1. +// THE BROKER'S PUBLIC ADDRESS ON A GIVEN HOST. Which host is a placement fact, not this module's: +// the prod role decides WHERE the broker is installed (gunbc.auth.approval_broker_placement, over +// gunbc.live_deploy.spec RoleSingletonHolding), and a broker-side reader receives the host it was +// installed on rather than resolving the role -- so the broker's closure never reaches the instance +// roster (#13132). type ApprovalBrokerAddress = ApprovalBrokerAddressResolved { value: NonEmptyStr } - | ApprovalBrokerAddressRefused { cause: ProdRoleRealizationRefusal } + | ApprovalBrokerAddressRefused { detail: NonEmptyStr } -fn approval_broker_address_refusal_detail(cause: ProdRoleRealizationRefusal) -> NonEmptyStr { - join(["approval broker address unresolved: ", prod_role_realization_refusal_detail(cause: cause)], "") as NonEmptyStr -} - -fn approval_broker_base_url_under(realization: ProdRoleRealization, domain: NonEmptyStr?) -> ApprovalBrokerAddress { - match approval_broker_host_under(realization: realization) { - ApprovalBrokerHostResolved { host } => ApprovalBrokerAddressResolved { value: tailnet_base_url_from(host: host, domain: domain, port: approval_broker_public_port) } - ApprovalBrokerHostRefused { cause } => ApprovalBrokerAddressRefused { cause: cause } - } -} - -fn approval_broker_base_url() -> ApprovalBrokerAddress { - approval_broker_base_url_under(realization: prod_role_realization(), domain: fleet_tailnet_domain) +fn approval_broker_base_url_on(host: NonEmptyStr, domain: NonEmptyStr?) -> NonEmptyStr { + tailnet_base_url_from(host: host, domain: domain, port: approval_broker_public_port) } // THE BARE HOST OF THAT SAME ADDRESS, for a client that composes scheme, host and path itself. -fn approval_broker_public_host_under(realization: ProdRoleRealization, domain: NonEmptyStr?) -> ApprovalBrokerAddress { - match approval_broker_host_under(realization: realization) { - ApprovalBrokerHostResolved { host } => ApprovalBrokerAddressResolved { value: tailnet_host_from(host: host, domain: domain) } - ApprovalBrokerHostRefused { cause } => ApprovalBrokerAddressRefused { cause: cause } - } +fn approval_broker_public_host_on(host: NonEmptyStr, domain: NonEmptyStr?) -> NonEmptyStr { + tailnet_host_from(host: host, domain: domain) } -fn approval_broker_public_host_from(domain: NonEmptyStr?) -> ApprovalBrokerAddress { - approval_broker_public_host_under(realization: prod_role_realization(), domain: domain) +// THE SERVING BROKER'S OWN ADDRESS, received at serve time: the short hostname of the machine the +// process runs on, which is the host its unit was installed on. An unreadable hostname refuses; it +// is never replaced by a spelled host. +fn approval_broker_serving_base_url() -> ApprovalBrokerAddress { + match hostname_short_read(transport: LocalShell) { + HostnameShortCaptured { value: v } => + if v == "" { ApprovalBrokerAddressRefused { detail: "approval broker address unresolved: the serving host's short hostname read back empty" } } + else { ApprovalBrokerAddressResolved { value: approval_broker_base_url_on(host: v as NonEmptyStr, domain: fleet_tailnet_domain) } } + HostnameShortCaptureRefused { reason } => + ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: the serving host's hostname could not be read: ", reason], "") as NonEmptyStr } + } } diff --git a/dag/gunbc/auth/approval_broker_placement.dag b/dag/gunbc/auth/approval_broker_placement.dag new file mode 100644 index 00000000000..788e4331e77 --- /dev/null +++ b/dag/gunbc/auth/approval_broker_placement.dag @@ -0,0 +1,51 @@ +module gunbc.auth.approval_broker_placement + +import std.types { NonEmptyStr } +import gunbc.roadmap_dashboard_instance { + ProdRoleRealization, + ProdRoleRealizedBy, + ProdRoleRealizationRefused, + ProdRoleRealizationRefusal, + prod_role_realization, + prod_role_realization_refusal_detail, +} +import gunbc.auth.approval_broker_endpoint { + ApprovalBrokerAddress, + ApprovalBrokerAddressResolved, + ApprovalBrokerAddressRefused, + approval_broker_public_host_on, +} + +// WHERE THE APPROVAL BROKER IS INSTALLED, for readers OUTSIDE the broker process: the GCP IAM +// converge job that must run beside it (gunbc.fleet_converge_workflow) and the approvals app's build +// config (gunbc.approve_ios_project). It is the host of the prod-role holder -- the same token +// (gunbc.roadmap_dashboard_instance ProdRoleHolder) from which gunbc.live_deploy.spec builds the +// broker's unit, so the job and the app name the host the unit is actually on. Under a refused +// realization there is no such host and each reader refuses through its own arm. +// +// This module is deliberately NOT imported by any broker-side module (gunbc.auth.approval_broker_endpoint, +// approval_decision_store, approval_request_submission, approval_device_enrolment_code): the broker +// receives its own host at serve time (approval_broker_serving_base_url) and never reaches the +// instance roster, which keeps the native broker's closure as #13132 narrowed it. +type ApprovalBrokerHost + = ApprovalBrokerHostResolved { host: NonEmptyStr } + | ApprovalBrokerHostRefused { cause: ProdRoleRealizationRefusal } + +fn approval_broker_host_under(realization: ProdRoleRealization) -> ApprovalBrokerHost { + match realization { + ProdRoleRealizedBy { holder } => ApprovalBrokerHostResolved { host: holder.instance.host_identity } + ProdRoleRealizationRefused { cause } => ApprovalBrokerHostRefused { cause: cause } + } +} + +fn approval_broker_public_host_under(realization: ProdRoleRealization, domain: NonEmptyStr?) -> ApprovalBrokerAddress { + match approval_broker_host_under(realization: realization) { + ApprovalBrokerHostResolved { host } => ApprovalBrokerAddressResolved { value: approval_broker_public_host_on(host: host, domain: domain) } + ApprovalBrokerHostRefused { cause } => + ApprovalBrokerAddressRefused { detail: ("approval broker address unresolved: " + prod_role_realization_refusal_detail(cause: cause)) as NonEmptyStr } + } +} + +fn approval_broker_public_host_from(domain: NonEmptyStr?) -> ApprovalBrokerAddress { + approval_broker_public_host_under(realization: prod_role_realization(), domain: domain) +} diff --git a/dag/gunbc/auth/approval_decision_store.dag b/dag/gunbc/auth/approval_decision_store.dag index 2888dca7d32..c258eb0f071 100644 --- a/dag/gunbc/auth/approval_decision_store.dag +++ b/dag/gunbc/auth/approval_decision_store.dag @@ -20,7 +20,7 @@ import extdeps.filesystem.filesystem_io { Filesystem, FilesystemExactRead, Files import extdeps.tailscale.identity { TailnetIdentity } import extdeps.network.address { IpV4, ip_address_is_loopback } import extdeps.network.ipv4 { parse_ipv4_address, Ipv4Parsed, Ipv4ParseFailed } -import gunbc.roadmap_dashboard_instance { ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, prod_role_realization, prod_role_realization_refusal_detail } +import gunbc.auth.approval_broker_endpoint { approval_broker_listen_host } import extdeps.crypto.mac { MacKey, MacKeyId, MacSuite, HmacSha256, MacVerification, mac_verify, mac_key_material_hex_length } import extdeps.languages.json.emit { JsonValue, JsonString, JsonBool, serialize_json, json_object, json_kv, json_string, json_bool } import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, JsonMemberFound, JsonMemberAbsent, JsonMemberDuplicated, JsonMemberNotAnObject, parse_json_document, json_object_unique_member } @@ -161,7 +161,7 @@ type IdentityTrustStanding // THE TRUST STANDING IS DERIVED FROM THE ROW THE UNIT IS EMITTED FROM, never asserted beside it // (review 67264), AND A LOOPBACK BIND ATTRIBUTES NOTHING (reviews 67306, 67348): -// gunbc.roadmap_dashboard_instance prod_role_realization (the holder's listen_host) is the one fact +// gunbc.auth.approval_broker_endpoint approval_broker_listen_host (the broker unit's --host) is the one fact // live_deploy turns into the unit's --host, so the standing reads that field. Any bind other than // loopback keeps the gate closed: the header is self-assertable by whoever reaches the port. A // loopback bind rules out off-host callers only; an on-host process under another POSIX user can @@ -177,34 +177,26 @@ fn approval_identity_trust_for(listen_host: NonEmptyStr) -> IdentityTrustStandin Ipv4Parsed { address: a } => if ip_address_is_loopback(addr: IpV4 { v4: a }) { IdentityHeaderUnattributed { - residual: join(["gunbc-roadmap.service binds --host ", listen_host as String, " (the prod-role holder's listen_host) behind tailscale serve https://srv1.tailecbe08.ts.net/, so off-host callers cannot reach it; an on-host process under another POSIX user still can, so the header is not attributed and the record names the capability's bearer"], "") as NonEmptyStr, + residual: join(["gunbc-approval-broker.service binds --host ", listen_host as String, " (gunbc.auth.approval_broker_endpoint approval_broker_listen_host) behind its tailscale serve front door, so off-host callers cannot reach it; an on-host process under another POSIX user still can, so the header is not attributed and the record names the capability's bearer"], "") as NonEmptyStr, } } else { IdentityHeaderTrustNotEstablished { - blocker: join(["gunbc-roadmap.service binds --host ", listen_host as String, " (the prod-role holder's listen_host), so a Tailscale-User-Login header can be set by anyone who reaches the port; the gate opens when the row binds loopback"], "") as NonEmptyStr, + blocker: join(["gunbc-approval-broker.service binds --host ", listen_host as String, " (gunbc.auth.approval_broker_endpoint approval_broker_listen_host), so a Tailscale-User-Login header can be set by anyone who reaches the port; the gate opens when the row binds loopback"], "") as NonEmptyStr, } } Ipv4ParseFailed { error: _ } => IdentityHeaderTrustNotEstablished { - blocker: join(["the prod-role holder's listen_host '", listen_host as String, "' is not an IPv4 address, so the bind cannot be classified as loopback"], "") as NonEmptyStr, + blocker: join(["approval_broker_listen_host '", listen_host as String, "' is not an IPv4 address, so the bind cannot be classified as loopback"], "") as NonEmptyStr, } } } -// THE TRUST OF THE INSTANCE THAT SERVES APPROVALS -- the prod-role holder's realization -// (gunbc.roadmap_dashboard_instance prod_role_realization). Under a refused realization no instance -// serves approvals, so trust is NOT established and names the refusal; it is never srv1's by default. -fn approval_identity_trust_under(realization: ProdRoleRealization) -> IdentityTrustStanding { - match realization { - ProdRoleRealizedBy { holder } => approval_identity_trust_for(listen_host: holder.instance.listen_host) - ProdRoleRealizationRefused { cause } => - IdentityHeaderTrustNotEstablished { - blocker: join(["no instance serves approvals: ", prod_role_realization_refusal_detail(cause: cause)], "") as NonEmptyStr, - } - } -} - -data approval_identity_trust: IdentityTrustStanding = approval_identity_trust_under(realization: prod_role_realization()) +// THE TRUST OF THE PROCESS THAT SERVES APPROVALS, derived from THE ONE ROW its unit's --host is +// emitted from: gunbc.auth.approval_broker_endpoint approval_broker_listen_host, which +// gunbc.live_deploy.spec approval_broker_service_config binds (reviews 67264/67306). Editing that bind +// to 0.0.0.0 closes this gate in the same commit, and the broker never reaches the instance roster +// to compute it (#13132). Which host the broker runs on is placement, not trust. +data approval_identity_trust: IdentityTrustStanding = approval_identity_trust_for(listen_host: approval_broker_listen_host) // ── the store ────────────────────────────────────────────────────────────────────────────────── diff --git a/dag/gunbc/auth/approval_device_enrolment_code.dag b/dag/gunbc/auth/approval_device_enrolment_code.dag index 72c369df0eb..361da9c87a3 100644 --- a/dag/gunbc/auth/approval_device_enrolment_code.dag +++ b/dag/gunbc/auth/approval_device_enrolment_code.dag @@ -20,7 +20,7 @@ import gunbc.auth.approval_ntfy_access_readback { AclEstablished, AclRefused, approval_ntfy_runtime_reading, approval_ntfy_channel_verdict, approval_ntfy_acl_deviation_sentence, } -import gunbc.auth.approval_broker_endpoint { approval_broker_base_url, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused, approval_broker_address_refusal_detail } +import gunbc.auth.approval_broker_endpoint { approval_broker_serving_base_url, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } import gunbc.auth.approval_request_submission { PublisherTokenRead, read_publisher_token, publish_notification, NotificationPublish, NotificationAttempted, NotificationPublisherTokenUnavailable } import gunbc.auth.approval_device_redemption { approval_device_store_root, issue_enrolment_code, @@ -161,8 +161,8 @@ fn deliver_enrolment_code(code: NonEmptyStr, expires_at: Timestamp, publisher: P decl_ref(module_path: "gunbc.auth.approval_device_enrolment_code", decl_name: "issue_and_deliver_enrolment_code_until"), decl_ref(module_path: "test.claim.approval_device_enrolment_code_witness_test", decl_name: "the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code"), ] { - match approval_broker_base_url() { - ApprovalBrokerAddressRefused { cause } => EnrolmentCodeIssuanceRefused { cause: approval_broker_address_refusal_detail(cause: cause) as String } + match approval_broker_serving_base_url() { + ApprovalBrokerAddressRefused { detail } => EnrolmentCodeIssuanceRefused { cause: detail as String } ApprovalBrokerAddressResolved { value: url } => { let message = enrolment_code_notification(code: code, expires_at: expires_at, approval_url: url) EnrolmentCodeIssued { topic: message.topic as NonEmptyStr, expires_at: expires_at, delivery: enrolment_code_delivery_of(publish: publish_notification(message: message, publisher: publisher)) } diff --git a/dag/gunbc/auth/approval_request_submission.dag b/dag/gunbc/auth/approval_request_submission.dag index 6d1e9e32e5a..08de7f744a7 100644 --- a/dag/gunbc/auth/approval_request_submission.dag +++ b/dag/gunbc/auth/approval_request_submission.dag @@ -13,7 +13,7 @@ import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, Propos import gunbc.auth.approval_broker { SubmissionAdmission, SubmissionAdmitted, SubmissionRequesterNotEnrolled, SubmissionAuthorityNotPermittedForRequester, admit_submission, BrokerApproved, BrokerDenied } import gunbc.auth.approval_notification { approval_notification } import gunbc.auth.approval_ntfy_publish -import gunbc.auth.approval_broker_endpoint { approval_broker_base_url, approval_broker_confirm_path_prefix, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused, approval_broker_address_refusal_detail } +import gunbc.auth.approval_broker_endpoint { approval_broker_serving_base_url, approval_broker_confirm_path_prefix, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } import std.algebra { trim } import gunbc.auth.approval_status_wire { FiledApprovalStatus, StatusNotFiled, StatusPending, StatusApproved, StatusDenied, StatusUnreadable, @@ -199,7 +199,7 @@ fn admit_parsed_submission(tag_hex: NonEmptyStr, request: StoredApprovalRequest, // that sentence false while leaving the STRING it produced byte-identical, because the front door // keeps the public host and port. A grounding that has gone false while its output stayed right is // exactly the fact nothing reads back, so the derivation moved to the authority that now owns the -// endpoint (gunbc.auth.approval_broker_endpoint approval_broker_base_url) rather than being left +// endpoint (gunbc.auth.approval_broker_endpoint approval_broker_serving_base_url) rather than being left // here with its reason rewritten. The undeclared-domain `.invalid` arm still lives with // gunbc.fleet_tailnet tailnet_base_url_from and is not restated at either site. @@ -311,8 +311,8 @@ fn notify_filed( ) -> SubmissionOutcome { match capability_keyring { ApprovalKeyringRefused { cause: c } => SubmissionFiledCapabilityKeyUnavailable { escalation_id: request.escalation_id, cause: c } - ApprovalKeyLoaded { key: key } => match approval_broker_base_url() { - ApprovalBrokerAddressRefused { cause } => SubmissionFiledBrokerAddressRefused { escalation_id: request.escalation_id, detail: approval_broker_address_refusal_detail(cause: cause) } + ApprovalKeyLoaded { key: key } => match approval_broker_serving_base_url() { + ApprovalBrokerAddressRefused { detail } => SubmissionFiledBrokerAddressRefused { escalation_id: request.escalation_id, detail: detail } ApprovalBrokerAddressResolved { value: base } => match issue_links(capability_key: key, base: base, request: request) { LinksNotIssued { key_id: k } => SubmissionFiledLinksNotIssued { escalation_id: request.escalation_id, key_id: k } links => match approval_message(request: request, links: links) { diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index 9495d98efcc..f08ae7a68c5 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -118,7 +118,7 @@ import gunbc.fleet_intent_network { operator_host_mtcollins1 } import gunbc.auth.gcp_iam_converge_federation { gcp_iam_converge_environment, gcp_iam_observe_wif_provider_resource } import gunbc.auth.gcp_iam_converge { iam_observe_service_account, iam_converge_service_account, iam_converge_token_lifetime } import gunbc.auth.gcp_iam_converge_run { gcp_iam_converge_step_timeout_minutes, iam_converge_token_env, gcp_iam_converge_approved_output } -import gunbc.auth.approval_broker_endpoint { approval_broker_host_under, ApprovalBrokerHostResolved, ApprovalBrokerHostRefused } +import gunbc.auth.approval_broker_placement { approval_broker_host_under, ApprovalBrokerHostResolved, ApprovalBrokerHostRefused } import gunbc.roadmap_dashboard_instance { ProdRoleRealization, ProdRoleRealizationRefusal, prod_role_realization, prod_role_realization_refusal_detail } import extdeps.exec.command { shell_quote } import gunbc.auth.managed_host_boot_federation { diff --git a/dag/gunbc/fleet/fleet_tailnet.dag b/dag/gunbc/fleet/fleet_tailnet.dag index 7543f23f63e..9702c258756 100644 --- a/dag/gunbc/fleet/fleet_tailnet.dag +++ b/dag/gunbc/fleet/fleet_tailnet.dag @@ -4,7 +4,7 @@ import std.types { NonEmptyStr, String, Port } // THE FLEET'S TAILNET DOMAIN, as a leaf. gunbc.fleet_intent_network fleet_intent_network carries it // as its topology's tailnet_domain, and the approval broker composes its public address from it -// (gunbc.auth.approval_broker_endpoint approval_broker_base_url); a reader of the domain does not +// (gunbc.auth.approval_broker_endpoint approval_broker_base_url_on); a reader of the domain does not // import the topology's assets, segments and endpoints. data fleet_tailnet_domain: NonEmptyStr? = Present { value: "tailecbe08.ts.net" } @@ -13,7 +13,7 @@ data fleet_tailnet_domain: NonEmptyStr? = Present { value: "tailecbe08.ts.net" } // "https://srv1.tailecbe08.ts.net:8443", which re-minted two facts the corpus owns elsewhere -- the // tailnet domain (fleet_tailnet_domain above) and the host's identity, which each caller passes as // `host` from its own authority (gunbc.fleet_intent_network operator_host_srv1 for ntfy, -// gunbc.auth.approval_broker_endpoint approval_broker_host_identity for the broker). Renaming the +// the serving host for the broker, gunbc.auth.approval_broker_endpoint approval_broker_serving_base_url). Renaming the // tailnet or the host would have left that string stale while every modeled consumer moved. // // The address a CLIENT dials is a different fact from the address the SERVER listens on diff --git a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag index 445605436de..d556c5944c2 100644 --- a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag +++ b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag @@ -22,7 +22,7 @@ data a_test_deployment_acts_on_the_prod_deployments_state: RecurringFailureMode "CLIMB STARTED BY THE MODEL PR: the class is derived, the bindings resolve per exact class, and test.claim.deployment_risk_witness_test carries the discriminating RED (a TestRisk deployment refuses rather than resolving the prod topic and app) beside its positive control. That covers the two attachments the binding set carries today and none of the four receipts above, so the row's rung is unchanged by it.", - "PINS REPOINTED BY DELIVERABLE 2: the role-following singletons follow gunbc.roadmap_dashboard_instance prod_role_realization -- one lookup, the instance roster filtered by its own deployment field -- which REFUSES with a typed ProdRoleRealizationRefusal under NoProdRole, an unrealized holder or an ambiguous one, and mints the only holder token (ProdRoleHolder, sole_constructor). A spec carries that token as gunbc.live_deploy.spec RoleSingletonHolding and every singleton member -- the approval broker unit and front door, the fabric store unit, route, socket, root and directories -- is built from it, never from a caller-spelled instance_id, so they move between targets as one subject. The apply first assesses the desired realization against an observation of every roster host (gunbc.live_deploy.role_singleton_convergence): Noop, Install only when nothing is observed anywhere, and a typed refusal for a move, a removal, an unobservable host, two holders or an ownerless broker. The approval client origin, identity trust, the GCP IAM converge job's runner (gunbc.fleet_converge_workflow fleet_converge_gcp_iam_converge_job_under, refused under NoProdRole) and the apply's protected siblings (dashboard_protected_siblings) follow the same realization. The REDs are in test.claim.deployment_risk_witness_test at the spec, convergence and emitted-job boundaries.", + "PINS REPOINTED BY DELIVERABLE 2: the role-following singletons follow gunbc.roadmap_dashboard_instance prod_role_realization -- one lookup, the instance roster filtered by its own deployment field -- which REFUSES with a typed ProdRoleRealizationRefusal under NoProdRole, an unrealized holder or an ambiguous one, and mints the only holder token (ProdRoleHolder, sole_constructor). A spec carries that token as gunbc.live_deploy.spec RoleSingletonHolding and every singleton member -- the approval broker unit and front door, the fabric store unit, route, socket, root and directories -- is built from it, never from a caller-spelled instance_id, so they move between targets as one subject. The apply first assesses the desired realization against an observation of every roster host (gunbc.live_deploy.role_singleton_convergence): Noop, Install only when nothing is observed anywhere, and a typed refusal for a move, a removal, an unobservable host, two holders or an ownerless broker. The approval client origin, the GCP IAM converge job's runner (gunbc.fleet_converge_workflow fleet_converge_gcp_iam_converge_job_under, refused under NoProdRole), the approvals app's host (gunbc.auth.approval_broker_placement) and the apply's protected siblings (dashboard_protected_siblings) follow the same realization; broker-side modules receive their host at serve time rather than resolving the role. The REDs are in test.claim.deployment_risk_witness_test at the spec, convergence and emitted-job boundaries.", "HONEST NON-PINS, not remaining debt: (a) the nullary serve, belt, dispatch and launch entry points that call srv1_live_dashboard_instance are srv1-live's OWN entry points (HostDashboardInstance.serve_function names roadmap_serve_handle for srv1-live and roadmap_serve_handle_ for every other instance, a binding test.claim.serve_function_binding_witness_test now checks roster-wide), so they must NOT follow the role -- following it would make srv1-live act on the new holder's state; (b) gunbc.fleet_reach_endpoint fleet_probe_remote_user_for's srv1/srv2 test is which hosts carry the gunbc-fleet account, a host-shared fact. NOT CLAIMED: that an unbound test deployment refuses everywhere -- roadmap_belt_actuate belt_dispatch_instance_cli still defaults an absent instance env to srv1-live, which stays with the belt owner's lane.", diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index 3589324e7f6..7d1b6524022 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -2,7 +2,6 @@ module gunbc.roadmap_dashboard_instance import extdeps.tools.env { env_absolute_program } import std.types { String, NonEmptyStr, Int, Bool, List, FilePath, Port, GitRef } -import gunbc.srv1_dashboard_bind { srv1_dashboard_host_identity, srv1_dashboard_listen_host } import std.measure { ByteSize } import v2.std.integer { integer_int_to_decimal_string } import gunbc.runner_slot_desired { gunbc_runner_slot_desired } @@ -967,7 +966,7 @@ fn srv1_live_dashboard_instance() -> HostDashboardInstance { HostDashboardInstance { deployment: srv1_daily_workspace_deployment, instance_id: srv1_dashboard_live_id, - host_identity: srv1_dashboard_host_identity, + host_identity: "srv1" as NonEmptyStr, provision_parent_root: "/opt/gunbc" as FilePath, instance_root: "/opt/gunbc" as FilePath, local_origin_root: srv1_gunbc_repo_root, @@ -991,7 +990,7 @@ fn srv1_live_dashboard_instance() -> HostDashboardInstance { }, serve_binary: srv1_gunbc_serve_binary, serve_function: "roadmap_serve_handle" as NonEmptyStr, - listen_host: srv1_dashboard_listen_host, + listen_host: "127.0.0.1" as NonEmptyStr, listen_port: 8080, tmux_server: DefaultTmuxServer, actuation: DashboardActuating, diff --git a/dag/gunbc/roadmap/srv1_dashboard_bind.dag b/dag/gunbc/roadmap/srv1_dashboard_bind.dag deleted file mode 100644 index 3ba5f47313d..00000000000 --- a/dag/gunbc/roadmap/srv1_dashboard_bind.dag +++ /dev/null @@ -1,10 +0,0 @@ -module gunbc.srv1_dashboard_bind - -import std.types { NonEmptyStr } - -// THE SRV1 DASHBOARD INSTANCE'S HOST AND BIND, as a leaf read by gunbc.roadmap_dashboard_instance -// srv1_live_dashboard_instance. The approval modules no longer read it: the broker's host, the client -// origin and the identity trust follow the prod-role holder (gunbc.roadmap_dashboard_instance -// prod_role_realization), so they name whichever instance holds the role, not srv1. -data srv1_dashboard_host_identity: NonEmptyStr = "srv1" -data srv1_dashboard_listen_host: NonEmptyStr = "127.0.0.1" diff --git a/dag/test/claim/approval_decision_store_witness_test.dag b/dag/test/claim/approval_decision_store_witness_test.dag index a731a43912c..40c3157d545 100644 --- a/dag/test/claim/approval_decision_store_witness_test.dag +++ b/dag/test/claim/approval_decision_store_witness_test.dag @@ -5,8 +5,8 @@ import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } import std.scoped_authorization { AttemptIdentity } import extdeps.crypto.mac { MacKey, HmacSha256, mac_verify, MacVerified, MacTagMismatch, mac_key_material_hex_length, mac_suite_key_size } import std.measure { byte_size_count } -import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance, prod_role_realization, prod_role_realization_in, public_builtin_dashboard_instances } -import gunbc.deployment_risk { NoProdRole } +import gunbc.auth.approval_broker_endpoint { approval_broker_listen_host } +import gunbc.live_deploy.desired { deployment_spec_srv1 } import extdeps.tailscale.identity { tailnet_identity_of } import extdeps.filesystem.filesystem_io { filesystem_exact_read } import gunbc.auth.approval_capability { ProposeApprove, ProposeDeny, approval_capability_signing_input } @@ -16,7 +16,7 @@ import gunbc.auth.approval_decision_store { StoredApprovalRequest, stored_request_json, parse_stored_record, StoredRequestParsed, StoredDecisionParsed, StoredRecordUnreadable, claims_for, capability_path_segment, parse_capability_segment, CapabilitySegmentParsed, CapabilitySegmentMalformed, - IdentityHeaderTrustEstablished, IdentityHeaderUnattributed, IdentityHeaderTrustNotEstablished, approval_identity_trust, approval_identity_trust_for, approval_identity_trust_under, + IdentityHeaderTrustEstablished, IdentityHeaderUnattributed, IdentityHeaderTrustNotEstablished, approval_identity_trust, approval_identity_trust_for, redeem_over_store, RedemptionIdentityUntrusted, } import extdeps.ietf.http_semantics { POST, GET } @@ -136,17 +136,16 @@ test fn fixture_tags_verify_over_the_stored_claims_and_not_across_decisions() -> // THE TRUST STANDING FOLLOWS THE INSTANCE ROW AND NOTHING ELSE: loopback opens the gate ONLY as // Unattributed (never as Established -- a loopback bind does not attribute the on-host peer, so // the record names the bearer), any other bind or an unparseable host keeps it closed, and the production row is -// exactly the derivation over the prod-role holder's listen_host (srv1-live under today's -// selection) -- so the gate cannot be +// exactly the derivation over the broker's bind row, the same value the emitted broker unit's --host +// carries -- so the gate cannot be // opened by editing a row beside the one the unit is emitted from. test fn identity_trust_is_derived_from_the_instance_bind() -> Bool { match approval_identity_trust_for(listen_host: "127.0.0.1") { IdentityHeaderUnattributed { residual: _ } => true _ => false } && match approval_identity_trust_for(listen_host: "127.0.0.1") { IdentityHeaderTrustEstablished { evidence: _ } => false _ => true } && match approval_identity_trust_for(listen_host: "0.0.0.0") { IdentityHeaderTrustNotEstablished { blocker: _ } => true _ => false } && match approval_identity_trust_for(listen_host: "srv1.internal") { IdentityHeaderTrustNotEstablished { blocker: _ } => true _ => false } - && approval_identity_trust == approval_identity_trust_for(listen_host: srv1_live_dashboard_instance().listen_host) - && approval_identity_trust == approval_identity_trust_under(realization: prod_role_realization()) - && match approval_identity_trust_under(realization: prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances())) { IdentityHeaderTrustNotEstablished { blocker: _ } => true _ => false } + && approval_identity_trust == approval_identity_trust_for(listen_host: approval_broker_listen_host) + && approval_identity_trust == approval_identity_trust_for(listen_host: deployment_spec_srv1().approval_broker.listen.host as NonEmptyStr) } // AN UNTRUSTED IDENTITY HEADER REFUSES BEFORE THE SEGMENT IS EVEN PARSED -- with a well-formed diff --git a/dag/test/claim/approve_ios_project_witness_test.dag b/dag/test/claim/approve_ios_project_witness_test.dag index dccee3e01f6..61697cc62df 100644 --- a/dag/test/claim/approve_ios_project_witness_test.dag +++ b/dag/test/claim/approve_ios_project_witness_test.dag @@ -11,7 +11,10 @@ import extdeps.apple.xcode { XcodeRelease, xcode_26_6, xcode_release_text } import extdeps.xcodegen.project_spec { xcodegen_last_upgrade_check } import gunbc.approve_ios_app { approve_ios_build_standing, NotExecutedByAnyRequiredLane, approve_ios_build_unexecuted_frontier } import gunbc.approve_ios_project { expected_approve_ios_project_yml, approve_ios_project_yml_from, ApproveIosProjectGeneration, ApproveIosProjectGenerated, ApproveIosProjectRefused } -import gunbc.auth.approval_broker_endpoint { approval_broker_base_url, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } +import gunbc.auth.approval_broker_endpoint { approval_broker_base_url_on } +import gunbc.auth.approval_broker_placement { approval_broker_host_under, ApprovalBrokerHostResolved, ApprovalBrokerHostRefused } +import gunbc.roadmap_dashboard_instance { prod_role_realization } +import gunbc.fleet_tailnet { fleet_tailnet_domain } import gunbc.fleet_intent_network { operator_host_srv1 } import gunbc.auth.approval_ntfy_publish { approval_ntfy_serve_port } @@ -238,7 +241,8 @@ fn generated_server_host(g: ApproveIosProjectGeneration) -> String? { } // The host the app dials and the base the approval links are issued against are one address: -// https:// plus the projected host IS gunbc.auth.approval_broker_endpoint approval_broker_base_url. +// https:// plus the projected host IS the broker's base URL on the host it is placed on +// (gunbc.auth.approval_broker_placement), i.e. what the serving broker issues its links against. test fn the_committed_projection_names_the_brokers_public_host() -> Bool { match generated_server_host(g: expected_approve_ios_project_yml()) { Present { value: h } => concat("https://", h) == approval_broker_base_url_text() @@ -273,10 +277,11 @@ test fn red_a_non_default_public_port_refuses_the_projection() -> Bool { } } -// The resolved link base under today's selection; a refused one is "" and fails every comparison. +// The link base on the placed broker's host under today's selection; a refused placement is "" and +// fails every equality. fn approval_broker_base_url_text() -> String { - match approval_broker_base_url() { - ApprovalBrokerAddressResolved { value } => value as String - ApprovalBrokerAddressRefused { cause: _ } => "" + match approval_broker_host_under(realization: prod_role_realization()) { + ApprovalBrokerHostResolved { host } => approval_broker_base_url_on(host: host, domain: fleet_tailnet_domain) as String + ApprovalBrokerHostRefused { cause: _ } => "" } } diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index 62d259fcea6..c7c4e8b1b23 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -23,7 +23,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - Declared frontier (review 74937; merry-tern-58 ruling 2026-10-04): `DeploymentRiskInterface`, `DeploymentHarmMagnitudeBet` and the operator-disposition gate are **deferred until an operator policy differs by class**. Today's policy (gunbc#13124: automatic repair is "no" for every deployment) is class-independent, so a gate consulting the class would decide nothing (§3c decoration), and a class-dependent policy is the operator's call. No carrier is minted before then. Design note for when the bet lands: its magnitude is an order of magnitude over `Duration` (today `std.types` `Duration`; std has no order-of-magnitude carrier yet), e.g. a decade exponent beside its unit, typed with `extdeps.external_authority` `CitedFigureStanding`, and it never decides the class. 1b. **Machine qualification migration**: one motion over machine_intake (re-census on main first; coordinate warm-crane-577's mtcollins1 untangle lane). eager-gull-22 handed it to this lane. 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. - - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). A spec carries it as `gunbc.live_deploy.spec` `RoleSingletonHolding`; the broker unit and front door and the fabric store's unit, route, socket, root and directories are built from it and move between targets as one subject (no instance-id ownership; the `"srv1-live"` literal and `operator_host_srv1` are gone). Also following the realization: approval client origin, identity trust, the GCP IAM converge job's runner (refused job under NoProdRole), and `DashboardProtectedSibling` (same-host siblings whose class is `ProdRisk`). + - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). A spec carries it as `gunbc.live_deploy.spec` `RoleSingletonHolding`; the broker unit and front door and the fabric store's unit, route, socket, root and directories are built from it and move between targets as one subject (no instance-id ownership; the `"srv1-live"` literal and `operator_host_srv1` are gone). Also following the realization: the approval client origin, the GCP IAM converge job's runner (refused job under NoProdRole) and the approvals app's host (`gunbc.auth.approval_broker_placement`), and `DashboardProtectedSibling` (same-host siblings whose class is `ProdRisk`). Broker-side modules do NOT resolve the role (it would undo #13132's narrowed native-broker closure): the role decides where the broker is installed, the serving broker reads its own host at serve time (`approval_broker_serving_base_url`), and identity trust is derived from the broker unit's own bind row (`approval_broker_listen_host`). - Role-singleton convergence (`gunbc.live_deploy.role_singleton_convergence`, gating every apply): desired realization × an observation of every roster host, reusing `member_observe` legs. `RoleSingletonNoop` | `RoleSingletonInstall` (only when nothing is observed anywhere) | `RoleSingletonRefused` with `RoleSingletonMoveUnimplemented`, `RoleSingletonRemovalUnimplemented`, `RoleSingletonHostUnobservable` (unobserved is never absent), `RoleSingletonHoldersAmbiguous`, `RoleSingletonOwnerUnmapped`. **Next trigger:** Move and Remove actuations (persistent-state move, singleton removal) become arms beside the existing ones. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. From ee1d6023acdca34bb37ce0d2741a461665e978fa Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 09:38:34 +0000 Subject: [PATCH 16/48] plan: D4 naming residue for srv1_gunbc_approval_broker_root Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/plans/deployment-risk-conformance.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index c7c4e8b1b23..9dfe63d3f27 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -30,6 +30,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - Honest non-pins (not debt): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`, now checked roster-wide by `test.claim.serve_function_binding_witness_test`), so they must not follow the role; `fleet_reach_endpoint` is a host-shared account fact. Not claimed: that an unbound test deployment refuses everywhere — `belt_dispatch_instance_cli`'s absent-env default stays with the belt owner's lane. 3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. 4. **One spec per deployment**; retire the `live_deploy.desired` rung drop by its own trigger. Renames: `srv1_production_desired_deployment → srv1_primary_desired_deployment`, `roadmap_belt_production_spawn_mode → roadmap_belt_operational_spawn_mode`. + - Naming residue: `gunbc.host_layout` `srv1_gunbc_approval_broker_root` ("/opt/gunbc/approval-broker") is host-agnostic in value but srv1 in name; rename it with the per-deployment names (not a prod pin; D2 review, 2026-10-04). 5. **Conformance row** `deployment-risk conformance` in `gunbc.design_argument conformance_domains`, once the home is consumed. Widened scope (side chat): classify every convergence subject as deployment-bound (Spark serving/experiments/seat grants, fabric storage, approval broker, R2 keyed by class + `BucketPurpose`, IAM targets, DNS/routes/certs, runner workloads, mutable caches), host-shared (Spark Wi-Fi/SSH/admin grants, KVM, base networking, toolchains), or fleet-control (root IAM writer, account admin). Pure computation identity and content-addressed outputs gain no deployment key. Manual convergence requests take `DeploymentConvergence { deployment } | HostResourceConvergence { host, resource } | FleetControlConvergence { operation }`. From 87c68a88a159fffd109112bad72ba052bb04c6b9 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 10:09:53 +0000 Subject: [PATCH 17/48] approval broker: serve-time host from /proc/sys/kernel/hostname (leaf read; hostname_read's closure cycled back through live_deploy.spec) Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2main.sh | 6 --- dag/gunbc/auth/approval_broker_endpoint.dag | 41 +++++++++++++++------ 2 files changed, 29 insertions(+), 18 deletions(-) delete mode 100644 .d2main.sh diff --git a/.d2main.sh b/.d2main.sh deleted file mode 100644 index 8ee4eaf4b24..00000000000 --- a/.d2main.sh +++ /dev/null @@ -1,6 +0,0 @@ -set -u -git checkout -q f680b955a1f20e53c883a723929d2352f075dcc6 -- . 2>&1 | tail -1 -cargo build --release -p v1-compiler --bin claim_batch 2>&1 | tail -1 -mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 21474836480 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs -git rev-parse HEAD; git diff --stat f680b955a1f20e53c883a723929d2352f075dcc6 -- dag src/v2 | tail -1 -./target/release/claim_batch --source-root dag --source-root src/v2 --entry dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag --functions the_approval_broker_unit_would_install_these_bytes 2>&1 | grep -E "^(PASS|FAIL)|error" diff --git a/dag/gunbc/auth/approval_broker_endpoint.dag b/dag/gunbc/auth/approval_broker_endpoint.dag index 65ef7a91e11..75f780a16ef 100644 --- a/dag/gunbc/auth/approval_broker_endpoint.dag +++ b/dag/gunbc/auth/approval_broker_endpoint.dag @@ -7,8 +7,10 @@ import extdeps.tailscale.serve { import extdeps.external_authority { ExternalAuthority, CitedFigureStanding, CitedToAuthority, TranscribedUncited } import gunbc.fleet_tailnet { fleet_tailnet_domain, tailnet_base_url_from, tailnet_host_from } import gunbc.serve_liveness { serve_liveness_path } -import gunbc.hostname_read { hostname_short_read, HostnameShortCaptured, HostnameShortCaptureRefused } -import gunbc.host_effect { LocalShell } +import extdeps.filesystem.filesystem_io { Filesystem, FilesystemExactRead, FilesystemExactPathRead, FilesystemExactPathAbsent, FilesystemExactPathUnreadable, FilesystemExactPathKindUnrecognized, filesystem_exact_read } +import extdeps.linux.procfs { procfs_path_literal, ProcSysKernelHostname } +import std.algebra { trim } +import v2.std.collection { Present, Absent } import gunbc.auth.approval_device_wire { approval_device_request_path_prefix, approval_device_enrollment_path_prefix } // ── WHERE THE APPROVAL BROKER ANSWERS ──────────────────────────────────────────────────────── @@ -248,15 +250,30 @@ fn approval_broker_public_host_on(host: NonEmptyStr, domain: NonEmptyStr?) -> No tailnet_host_from(host: host, domain: domain) } -// THE SERVING BROKER'S OWN ADDRESS, received at serve time: the short hostname of the machine the -// process runs on, which is the host its unit was installed on. An unreadable hostname refuses; it -// is never replaced by a spelled host. -fn approval_broker_serving_base_url() -> ApprovalBrokerAddress { - match hostname_short_read(transport: LocalShell) { - HostnameShortCaptured { value: v } => - if v == "" { ApprovalBrokerAddressRefused { detail: "approval broker address unresolved: the serving host's short hostname read back empty" } } - else { ApprovalBrokerAddressResolved { value: approval_broker_base_url_on(host: v as NonEmptyStr, domain: fleet_tailnet_domain) } } - HostnameShortCaptureRefused { reason } => - ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: the serving host's hostname could not be read: ", reason], "") as NonEmptyStr } +// THE SERVING BROKER'S OWN ADDRESS, received at serve time: the kernel hostname of the machine the +// process runs on (extdeps.linux.procfs ProcSysKernelHostname, its first label), which is the host +// its unit was installed on. A leaf filesystem read rather than gunbc.hostname_read, whose host-effect +// closure reaches gunbc.live_deploy.spec and would make the broker import its own placement. An +// unreadable or empty hostname refuses; it is never replaced by a spelled host. +fn serving_host_of(read: FilesystemExactRead) -> ApprovalBrokerAddress { + match read { + FilesystemExactPathRead { path: _, content } => { + let first = split(s: trim(s: content), delimiter: ".") |> first + match first { + Present { value: label } => + if label == "" { ApprovalBrokerAddressRefused { detail: "approval broker address unresolved: the serving host's kernel hostname is empty" } } + else { ApprovalBrokerAddressResolved { value: approval_broker_base_url_on(host: label as NonEmptyStr, domain: fleet_tailnet_domain) } } + Absent => ApprovalBrokerAddressRefused { detail: "approval broker address unresolved: the serving host's kernel hostname is empty" } + } + } + FilesystemExactPathAbsent { path } => ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", path, " is absent"], "") as NonEmptyStr } + FilesystemExactPathUnreadable { path, kind: _, error } => ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", path, " is unreadable: ", error], "") as NonEmptyStr } + FilesystemExactPathKindUnrecognized { path, observed: _, error } => ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", path, ": ", error], "") as NonEmptyStr } } } + +fn approval_broker_serving_base_url() -> ApprovalBrokerAddress { + let path = procfs_path_literal(p: ProcSysKernelHostname) as String + let read = Filesystem.Read(path: path) + serving_host_of(read: filesystem_exact_read(path: path, content: read.content, success: read.success, error: read.error, error_kind: read.error_kind)) +} From 5a4dc75858fdaf1afeefe79db6f0cc079040476b Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 10:14:13 +0000 Subject: [PATCH 18/48] D2: the broker is GIVEN its base URL (root fix for the serve-time host, per review) Reverts the /proc/sys/kernel/hostname leaf read (a second route beside gunbc.hostname_read, Linux-only). Placement binds the base URL from the holder token into the broker unit's Environment= line and the enrolment verb's env prefix (GUNBC_APPROVAL_BROKER_BASE_URL); the broker reads the value it was given and refuses an unbound or non-https value. The submission and enrolment folds take the address as a supplied value; the enrolment step's ssh target, dispatch host check and binding follow placement instead of srv1. Moved-role control at the emitted-unit boundary. Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2run.sh | 20 ++++++ dag/gunbc/auth/approval_broker_endpoint.dag | 43 +++++------- dag/gunbc/auth/approval_broker_placement.dag | 18 +++++ dag/gunbc/auth/approval_broker_serve.dag | 4 +- .../auth/approval_device_enrolment_code.dag | 12 ++-- .../approval_device_enrolment_code_issue.dag | 67 +++++++++++++------ .../auth/approval_request_submission.dag | 13 ++-- .../instruments/approval_store_live_probe.dag | 2 + dag/gunbc/live_deploy/emit.dag | 19 ++++++ ...device_enrolment_code_wet_witness_test.dag | 5 +- ...val_device_enrolment_code_witness_test.dag | 39 +++++++---- ...oval_ntfy_access_readback_witness_test.dag | 2 +- ...proval_request_submission_witness_test.dag | 7 +- .../claim/deployment_risk_witness_test.dag | 32 +++++++++ 14 files changed, 204 insertions(+), 79 deletions(-) create mode 100644 .d2run.sh diff --git a/.d2run.sh b/.d2run.sh new file mode 100644 index 00000000000..f67732f2b24 --- /dev/null +++ b/.d2run.sh @@ -0,0 +1,20 @@ +set -u +cargo build --release -p v1-compiler --bin claim_batch --bin v1_src_dag_parse 2>&1 | tail -1 +mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 21474836480 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs +./target/release/v1_src_dag_parse 2>&1 | grep -v "parse-clean" | head -20 +run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^(PASS|FAIL)" /tmp/cb.out | grep -v "^PASS"; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)" | tail -15; fi; echo rc=$rc; } +run dag/test/claim/deployment_risk_witness_test.dag a_test_deployment_refuses_rather_than_resolving_the_prod_bindings,the_prod_role_holder_resolves_the_prod_topic_and_app,each_class_resolves_only_its_own_set,no_prod_role_makes_the_former_holder_test_risk_and_it_refuses,moving_the_role_moves_the_class_and_nothing_else_decides_it,exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace,the_wrong_instance_failure_mode_row_cites_this_red,moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses,a_prod_role_holder_with_no_instance_refuses_as_unrealized,a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move,no_prod_role_with_a_holder_observed_refuses_as_a_removal,an_unobservable_roster_host_refuses_rather_than_reading_as_absent,nothing_observed_and_a_valid_holder_installs_on_that_holder,the_observed_holder_equal_to_the_desired_one_is_a_noop,two_observed_holders_or_an_ownerless_broker_refuse,the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop +run dag/test/claim/roadmap/serve_function_binding_witness_test.dag every_instances_serve_function_serves_that_instance +run dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag dashboard_source_transport_argv_is_exact_and_atomic,dashboard_local_origin_ref_translation_is_exact,dashboard_provider_runtime_and_auth_share_one_state_binding,dashboard_target_preflight_derives_dispatch_platform_dependencies,dashboard_health_requires_intended_instance_identity,dashboard_plan_is_end_to_end_and_production_disjoint,dashboard_refusal_receipt_is_typed_and_located,dashboard_dirty_controller_source_refuses_before_apply,dashboard_bootstrap_parent_writability_is_a_preflight_fact,dashboard_unreachable_root_refuses_before_choosing_a_parent,protected_sibling_readings_never_equate_absent_with_observed,a_host_without_a_protected_sibling_refuses_nothing_and_reads_nothing,protected_siblings_follow_the_prod_role_selection_within_the_host,sibling_isolation_still_requires_the_instance_to_own_its_paths +run dag/test/claim/approval_request_client_witness_test.dag submit_and_status_urls_follow_the_prod_role_holders_listen_bind,submit_body_nests_the_stored_request_as_a_json_string,status_words_are_closed,injected_kv_lines_in_expires_at_are_unreadable_not_approved,poll_expires_after_expires_at_unless_already_decided,a_pending_filing_expires_by_its_filed_window_not_the_callers,the_exact_expiry_instant_is_expired_and_one_second_before_is_live,post_filed_token_admits_poll_including_notify_failure,status_text_round_trips_through_the_same_grammar +run dag/test/claim/approval_decision_store_witness_test.dag keyring_loads_sixty_four_lower_hex_and_refuses_the_rest,keyring_absent_and_unreadable_are_two_refusals,stored_request_round_trips_through_json,a_record_missing_a_field_or_of_unknown_kind_is_unreadable_not_defaulted,a_newline_in_expires_at_is_unreadable_not_a_filed_request,capability_segment_round_trips_and_refuses_a_third_decision_word,fixture_tags_verify_over_the_stored_claims_and_not_across_decisions,identity_trust_is_derived_from_the_instance_bind,redemption_refuses_first_on_an_untrusted_identity_header +run dag/test/claim/approval_request_submission_witness_test.dag a_signed_envelope_parses_back_to_the_same_request_and_tag,a_non_canonical_record_is_refused_before_it_is_verified,a_correctly_signed_submission_is_admitted_and_a_wrong_key_is_not_enrolled,a_slot_with_no_minted_capability_does_not_admit_poll,the_capability_key_cannot_file_a_request,issued_links_carry_tags_that_verify_over_the_stored_claims_for_their_own_decision_only,the_message_carries_purpose_both_links_and_the_destructive_mark,a_non_hex_capability_key_issues_no_link,status_text_reports_not_filed_for_an_absent_slot_and_unreadable_off_the_success_channel,a_filed_request_with_a_publisher_token_is_published_and_without_one_is_reported,an_occupied_slot_is_renotifiable_only_while_pending_with_the_same_bytes,a_capability_key_refusal_after_filing_names_the_filed_escalation +run dag/test/claim/approval_device_enrolment_code_witness_test.dag the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code,the_remote_argv_runs_the_release_verb_as_the_operator_user,the_mode_is_rostered_and_pinned_to_srv1,an_accepted_publish_with_an_unreadable_receipt_is_accepted_and_never_rendered_as_not_delivered,only_an_explicit_http_refusal_yields_delivery_refused,a_transport_refusal_or_server_error_is_indeterminate_and_says_the_code_may_have_arrived,the_enrolment_code_is_sixteen_supplied_octets_as_lowercase_hex +run dag/test/claim/approve_ios_project_witness_test.dag the_declared_release_stamps_the_building_xcodes_upgrade_check,the_committed_projection_carries_every_required_info_plist_key_and_setting,witness_a_complete_supplied_document_is_admitted,red_a_document_without_a_launch_screen_is_refused,red_a_document_without_a_face_id_purpose_is_refused,red_a_document_without_a_display_name_is_refused,red_a_dead_infoplist_key_setting_is_refused,red_a_stale_xcode_version_is_refused,red_a_test_target_without_an_info_plist_is_refused,red_a_document_without_supported_orientations_is_refused,the_ios_build_standing_names_its_missing_lane,red_an_omitted_or_substituted_apns_environment_is_refused,red_an_omitted_or_substituted_bundle_identifier_is_refused,red_an_omitted_or_substituted_swift_version_is_refused,red_an_omitted_or_xcconfig_substituted_server_host_is_refused,the_committed_projection_names_the_brokers_public_host,changing_the_tailnet_domain_changes_the_generated_host,an_undeclared_tailnet_domain_projects_an_unroutable_host,red_a_non_default_public_port_refuses_the_projection +run dag/test/claim/fabric/fabric_writer_roster_witness_test.dag the_served_roster_holds_exactly_the_observed_logins_once,the_admission_admits_a_rostered_login_and_refuses_an_outsider_and_an_absent_login,the_admission_admits_a_rostered_local_peer_and_refuses_an_outsider_and_an_unattested_peer,the_door_reads_the_login_header_only_from_the_proxy_peer,the_production_served_door_refuses_a_login_no_reading_observed,the_door_admits_the_operator_and_the_ci_runner_locally_and_no_one_else,the_placed_store_areas_admit_no_writer_but_the_service +run dag/test/claim/deployed_tree_remote_witness_test.dag an_unconfigured_remote_is_absent_not_unusable,a_path_that_is_not_a_repository_is_unusable_not_absent,absent_and_unusable_are_distinct_observations,a_zero_exit_with_no_output_is_refused_not_an_empty_url,an_observed_url_is_trimmed,an_unknown_exit_code_is_refused,the_config_key_is_projected_from_the_remote_name,the_desired_url_is_the_derived_public_clone_url,identity_separates_two_instances_on_one_host,only_an_exact_read_back_reports_success,the_remote_converges_after_its_inputs_and_before_the_belt +run dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag the_belt_service_unit_matches_the_deployed_bytes,the_belt_service_unit_carries_the_per_tick_watchdog_bound,the_belt_timer_unit_matches_the_deployed_bytes,the_serve_unit_matches_the_deployed_bytes,the_slice_unit_matches_the_deployed_bytes,the_approval_broker_slice_unit_matches_the_deployed_bytes,the_approval_broker_unit_would_install_these_bytes,the_broker_inherits_the_roadmap_evaluation_caps,the_broker_and_the_roadmap_join_different_slices,the_broker_and_the_roadmap_bind_different_ports,the_broker_slice_bound_is_not_the_roadmap_deployment_bound,the_convergence_slice_carries_its_own_bounds,belt_service_runs_as_service_principal,belt_service_joins_deployment_slice,belt_service_works_in_repo_tree,belt_service_carries_user_manager_environment,belt_service_carries_spawn_workdir,belt_service_execstart_is_belt_tick_cli,belt_service_type_is_oneshot,belt_service_has_unit_directives,belt_service_is_not_installable,belt_timer_targets_belt_service,belt_timer_has_declared_cadence,belt_timer_has_description,belt_timer_is_installable_as_timers_target,serve_unit_runs_as_service_principal,serve_unit_joins_deployment_slice,serve_unit_works_in_repo_tree,serve_unit_carries_user_manager_environment,serve_unit_type_is_simple,serve_unit_restarts_on_failure,serve_unit_execstart_carries_admitted_entrypoint,serve_unit_has_unit_directives,serve_unit_is_installable_as_multi_user_target,slice_description_names_the_unit,slice_forbids_swap,slice_memory_bounds_are_the_deployments_own,slice_unit_is_not_installable,belt_service_user_goes_red_under_different_user,serve_unit_user_goes_red_under_different_user,serve_unit_execstart_goes_red_under_different_port,belt_service_working_directory_goes_red_under_different_root,the_convergence_oneshot_does_not_join_the_broker_slice,only_the_broker_unit_joins_the_broker_slice,both_served_slices_deny_swap_from_the_same_row +run dag/test/claim/live_deploy/emit_test.dag witness_spec_targets_srv1,witness_spec_uses_apt_package_manager,witness_spec_listen_port_is_8080,witness_owned_vs_ensured_typed,witness_apply_provisions_attempt_state_root_owned,witness_attempt_state_mode_derives_to_owner_only,witness_retract_attempt_state_root_refuses_when_nonempty,witness_tailnet_domain_propagates_optional,witness_tailnet_url_absent_not_fabricated,witness_dependency_ensure_is_idempotent,witness_apply_emits_no_embedded_preflight_and_only_sudoers,witness_retract_preamble_acknowledges_shared_sudoers_install,the_convergence_slice_is_installed_before_the_convergence_restart,witness_apply_script_contains_systemd_and_tailscale,witness_apply_script_wires_memory_cap_deploy_consumer,retract_script_stays_within_ci_runner_grants,apply_script_stays_within_ci_runner_grants,git_metadata_file_transport_is_present_in_the_legacy_specimen_and_absent_from_emission,repository_convergence_is_ordered_after_binary_and_before_readback,witness_tree_sync_restart_diagnoses_on_failure,witness_retract_script_owned_only,witness_apply_script_has_no_node_server,witness_apply_script_has_no_frozen_page,witness_apply_script_excludes_readback_curls,witness_retract_owned_process_removes_serve_service,witness_execstart_carries_exactly_the_admitted_revision,witness_execstart_omitting_the_revision_is_not_the_emitted_line,witness_execstart_at_another_candidate_differs,witness_systemd_unit_emitted,witness_remote_mutation_service_op_realization_binds_emit_shell_phase_b_axis,witness_emit_shell_dissolution_trigger_is_scaffold,witness_dependency_graph_emit_frontier_binds_orch_emit_pipeline,witness_teardown_dependency_arm_refuses_golden_independent,witness_wholesale_refused_poison_carries_marker,witness_deploy_access_job_principal_refused_poison_golden_independent,witness_apply_script_access_ready_not_deploy_access_poison,a_twin_deployment_on_one_host_collides_with_nothing,twin_teardown_removes_nothing_production_owns,retract_is_the_reverse_of_what_this_spec_applies,production_apply_carries_its_own_handler_and_tree_sync_env,emitted_twin_apply_touches_none_of_productions_effects,twin_belt_unit_carries_the_twins_spawn_root_and_not_productions,production_belt_unit_carries_productions_spawn_root,emitted_twin_retract_removes_nothing_of_productions,shell_quote_escapes_an_embedded_apostrophe,production_apply_creates_productions_binary_parent_directory,twin_apply_creates_its_own_binary_parent_directory_and_not_productions,teardown_disposition_separates_owned_from_ensured,production_apply_serves_its_own_endpoint_and_scopes_no_path,twin_apply_serves_its_own_endpoint_and_not_productions,production_retract_offs_its_own_endpoint,twin_retract_offs_its_own_endpoint_and_not_productions,retract_is_not_emitted_without_an_observed_scoped_removal,fleet_converge_timer_not_installed_by_deploy_apply,emit_RED_unit_files_are_not_written_through_here_documents,emit_staged_unit_body_is_quoted_by_the_posix_authority,the_preamble_ensures_the_publication_principal_as_a_nologin_system_account,the_publication_member_emits_convergence_and_no_rsync,a_zero_mutation_plan_installs_publishes_and_restarts_nothing_of_the_release,the_full_plan_lands_its_members_in_the_deployments_order,the_fabric_storage_store_root_reaches_the_srv1_apply_path,the_fabric_storage_store_areas_reach_the_srv1_apply_path,the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_principal,the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone,the_placed_fabric_store_derives_entry_mode_0400,no_retract_can_remove_the_fabric_storage_store,a_binary_only_plan_installs_and_restarts_without_publishing_the_tree,a_matching_authorization_applies_the_staged_caps,a_withheld_decision_applies_no_caps_at_the_production_root,an_authorization_for_another_host_applies_no_caps,the_writer_reverts_only_the_authorized_target_units,the_dark_install_never_names_the_roadmap_unit,the_dark_install_execstart_carries_the_admitted_revision,a_twin_apply_neither_writes_nor_restarts_the_host_broker,the_owning_apply_does_write_and_restart_the_host_broker,both_install_paths_bind_the_brokers_one_port,a_twin_dark_install_refuses_rather_than_emitting_an_empty_stage,the_broker_unit_binds_loopback_and_runs_from_its_own_tree,the_dark_install_populates_the_broker_tree,the_dark_install_and_the_unit_name_the_same_candidate_binary,the_receipt_command_substitutes_the_digest_rather_than_splicing_it,the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it +echo "=== MUTANT: MacBook bound back to srv1-live handler" +sed -i 's/serve_function: "roadmap_serve_handle_macbook_local" as NonEmptyStr/serve_function: "roadmap_serve_handle" as NonEmptyStr/' dag/gunbc/roadmap/roadmap_dashboard_instance.dag +run dag/test/claim/roadmap/serve_function_binding_witness_test.dag every_instances_serve_function_serves_that_instance diff --git a/dag/gunbc/auth/approval_broker_endpoint.dag b/dag/gunbc/auth/approval_broker_endpoint.dag index 75f780a16ef..2834ced244d 100644 --- a/dag/gunbc/auth/approval_broker_endpoint.dag +++ b/dag/gunbc/auth/approval_broker_endpoint.dag @@ -7,9 +7,7 @@ import extdeps.tailscale.serve { import extdeps.external_authority { ExternalAuthority, CitedFigureStanding, CitedToAuthority, TranscribedUncited } import gunbc.fleet_tailnet { fleet_tailnet_domain, tailnet_base_url_from, tailnet_host_from } import gunbc.serve_liveness { serve_liveness_path } -import extdeps.filesystem.filesystem_io { Filesystem, FilesystemExactRead, FilesystemExactPathRead, FilesystemExactPathAbsent, FilesystemExactPathUnreadable, FilesystemExactPathKindUnrecognized, filesystem_exact_read } -import extdeps.linux.procfs { procfs_path_literal, ProcSysKernelHostname } -import std.algebra { trim } +import extdeps.shell import v2.std.collection { Present, Absent } import gunbc.auth.approval_device_wire { approval_device_request_path_prefix, approval_device_enrollment_path_prefix } @@ -250,30 +248,25 @@ fn approval_broker_public_host_on(host: NonEmptyStr, domain: NonEmptyStr?) -> No tailnet_host_from(host: host, domain: domain) } -// THE SERVING BROKER'S OWN ADDRESS, received at serve time: the kernel hostname of the machine the -// process runs on (extdeps.linux.procfs ProcSysKernelHostname, its first label), which is the host -// its unit was installed on. A leaf filesystem read rather than gunbc.hostname_read, whose host-effect -// closure reaches gunbc.live_deploy.spec and would make the broker import its own placement. An -// unreadable or empty hostname refuses; it is never replaced by a spelled host. -fn serving_host_of(read: FilesystemExactRead) -> ApprovalBrokerAddress { - match read { - FilesystemExactPathRead { path: _, content } => { - let first = split(s: trim(s: content), delimiter: ".") |> first - match first { - Present { value: label } => - if label == "" { ApprovalBrokerAddressRefused { detail: "approval broker address unresolved: the serving host's kernel hostname is empty" } } - else { ApprovalBrokerAddressResolved { value: approval_broker_base_url_on(host: label as NonEmptyStr, domain: fleet_tailnet_domain) } } - Absent => ApprovalBrokerAddressRefused { detail: "approval broker address unresolved: the serving host's kernel hostname is empty" } - } - } - FilesystemExactPathAbsent { path } => ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", path, " is absent"], "") as NonEmptyStr } - FilesystemExactPathUnreadable { path, kind: _, error } => ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", path, " is unreadable: ", error], "") as NonEmptyStr } - FilesystemExactPathKindUnrecognized { path, observed: _, error } => ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", path, ": ", error], "") as NonEmptyStr } +// THE SERVING BROKER'S OWN ADDRESS IS GIVEN, NOT DISCOVERED. Placement already knows which host the +// broker is installed on (gunbc.auth.approval_broker_placement, from the prod-role holder token the +// spec carries), so the emitter binds the base URL into the process's environment: the broker unit's +// Environment= line (gunbc.live_deploy.emit) and the enrolment verb's env prefix +// (gunbc.auth.approval_device_enrolment_code_issue). The broker reads the value it was given -- no +// host-effect read, so its closure never reaches the instance roster (#13132) and there is one +// authority for where it runs. An unset binding, or one that is not an https URL (the emitter's +// refusal marker), refuses; it is never replaced by a host. +data approval_broker_base_url_env_name: NonEmptyStr = "GUNBC_APPROVAL_BROKER_BASE_URL" + +fn approval_broker_serving_base_url_of(bound: String?) -> ApprovalBrokerAddress { + match bound { + Present { value: v } => + if starts_with(s: v, prefix: "https://") { ApprovalBrokerAddressResolved { value: v as NonEmptyStr } } + else { ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", approval_broker_base_url_env_name as String, " is bound to '", v, "', not an https URL"], "") as NonEmptyStr } } + Absent => ApprovalBrokerAddressRefused { detail: join(["approval broker address unresolved: ", approval_broker_base_url_env_name as String, " is not bound in this process"], "") as NonEmptyStr } } } fn approval_broker_serving_base_url() -> ApprovalBrokerAddress { - let path = procfs_path_literal(p: ProcSysKernelHostname) as String - let read = Filesystem.Read(path: path) - serving_host_of(read: filesystem_exact_read(path: path, content: read.content, success: read.success, error: read.error, error_kind: read.error_kind)) + approval_broker_serving_base_url_of(bound: shell.Env.Get(name: approval_broker_base_url_env_name).value) } diff --git a/dag/gunbc/auth/approval_broker_placement.dag b/dag/gunbc/auth/approval_broker_placement.dag index 788e4331e77..71fd358f6da 100644 --- a/dag/gunbc/auth/approval_broker_placement.dag +++ b/dag/gunbc/auth/approval_broker_placement.dag @@ -2,6 +2,7 @@ module gunbc.auth.approval_broker_placement import std.types { NonEmptyStr } import gunbc.roadmap_dashboard_instance { + ProdRoleHolder, ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, @@ -14,7 +15,9 @@ import gunbc.auth.approval_broker_endpoint { ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused, approval_broker_public_host_on, + approval_broker_base_url_on, } +import gunbc.fleet_tailnet { fleet_tailnet_domain } // WHERE THE APPROVAL BROKER IS INSTALLED, for readers OUTSIDE the broker process: the GCP IAM // converge job that must run beside it (gunbc.fleet_converge_workflow) and the approvals app's build @@ -46,6 +49,21 @@ fn approval_broker_public_host_under(realization: ProdRoleRealization, domain: N } } +// THE BASE URL THE BROKER IS GIVEN, from the holder token itself: the emitter binds it into the unit +// and the enrolment verb's environment (gunbc.auth.approval_broker_endpoint +// approval_broker_base_url_env_name), so the serving broker never resolves its own host. +fn approval_broker_base_url_of_holder(holder: ProdRoleHolder) -> NonEmptyStr { + approval_broker_base_url_on(host: holder.instance.host_identity, domain: fleet_tailnet_domain) +} + +fn approval_broker_base_url_under(realization: ProdRoleRealization) -> ApprovalBrokerAddress { + match realization { + ProdRoleRealizedBy { holder } => ApprovalBrokerAddressResolved { value: approval_broker_base_url_of_holder(holder: holder) } + ProdRoleRealizationRefused { cause } => + ApprovalBrokerAddressRefused { detail: ("approval broker address unresolved: " + prod_role_realization_refusal_detail(cause: cause)) as NonEmptyStr } + } +} + fn approval_broker_public_host_from(domain: NonEmptyStr?) -> ApprovalBrokerAddress { approval_broker_public_host_under(realization: prod_role_realization(), domain: domain) } diff --git a/dag/gunbc/auth/approval_broker_serve.dag b/dag/gunbc/auth/approval_broker_serve.dag index d207126e2dc..5d21ff5581e 100644 --- a/dag/gunbc/auth/approval_broker_serve.dag +++ b/dag/gunbc/auth/approval_broker_serve.dag @@ -47,8 +47,7 @@ import gunbc.auth.approval_broker_endpoint { approval_broker_confirm_path_prefix, approval_device_request_path_template, approval_device_enrollment_path_template, - approval_broker_listen_host, -} + approval_broker_listen_host,, approval_broker_serving_base_url } import gunbc.auth.approval_device_wire { approval_device_enrol_path, approval_device_redeem_path, approval_device_pending_path, approval_device_push_path } import gunbc.auth.approval_device_routes { DeviceRouteResponse, @@ -405,6 +404,7 @@ fn approval_submit_admitted_response(request: ServeHttpRequest CliWireResponse // executed by a claim on the route it has (review 69755 of gunbc#12013): the slot write runs on // the wet route against a fresh root, and the publish -- with the receipt minted from the // published message and never from the code -- runs on the hermetic ntfy arm. -fn issue_and_deliver_enrolment_code_until(root: NonEmptyStr, now: Timestamp, expires_at: Timestamp, code: NonEmptyStr, publisher: PublisherTokenRead) -> EnrolmentCodeIssuance admit_callers: [ +fn issue_and_deliver_enrolment_code_until(root: NonEmptyStr, now: Timestamp, expires_at: Timestamp, code: NonEmptyStr, publisher: PublisherTokenRead, broker: ApprovalBrokerAddress) -> EnrolmentCodeIssuance admit_callers: [ decl_ref(module_path: "gunbc.auth.approval_device_enrolment_code", decl_name: "issue_device_enrolment_code"), decl_ref(module_path: "test.claim.approval_device_enrolment_code_wet_witness_test", decl_name: "the_code_slot_is_written_before_delivery_by_real_execution"), ] { match issue_enrolment_code(root: root, code: code, issued_at: now, expires_at: expires_at) { DeviceStoreSlotOccupied => EnrolmentCodeIssuanceRefused { cause: "the minted code's slot is already occupied; run again" } DeviceStoreRefused { detail: d } => EnrolmentCodeIssuanceRefused { cause: d as String } - DeviceStoreWritten => deliver_enrolment_code(code: code, expires_at: expires_at, publisher: publisher) + DeviceStoreWritten => deliver_enrolment_code(code: code, expires_at: expires_at, publisher: publisher, broker: broker) } } -fn deliver_enrolment_code(code: NonEmptyStr, expires_at: Timestamp, publisher: PublisherTokenRead) -> EnrolmentCodeIssuance admit_callers: [ +fn deliver_enrolment_code(code: NonEmptyStr, expires_at: Timestamp, publisher: PublisherTokenRead, broker: ApprovalBrokerAddress) -> EnrolmentCodeIssuance admit_callers: [ decl_ref(module_path: "gunbc.auth.approval_device_enrolment_code", decl_name: "issue_and_deliver_enrolment_code_until"), decl_ref(module_path: "test.claim.approval_device_enrolment_code_witness_test", decl_name: "the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code"), ] { - match approval_broker_serving_base_url() { + match broker { ApprovalBrokerAddressRefused { detail } => EnrolmentCodeIssuanceRefused { cause: detail as String } ApprovalBrokerAddressResolved { value: url } => { let message = enrolment_code_notification(code: code, expires_at: expires_at, approval_url: url) @@ -208,7 +208,7 @@ fn issue_device_enrolment_code(revision: String) -> CliWireResponse match mint_enrolment_code() { Absent => CliWireUnprintable { cause: "the entropy read did not yield the requested octets" } Present { value: code } => - enrolment_code_issuance_response(i: issue_and_deliver_enrolment_code_until(root: approval_device_store_root, now: now, expires_at: device_instant_plus(t: now, s: approval_enrolment_code_window), code: code, publisher: read_publisher_token())) + enrolment_code_issuance_response(i: issue_and_deliver_enrolment_code_until(root: approval_device_store_root, now: now, expires_at: device_instant_plus(t: now, s: approval_enrolment_code_window), code: code, publisher: read_publisher_token(), broker: approval_broker_serving_base_url())) } } } diff --git a/dag/gunbc/auth/approval_device_enrolment_code_issue.dag b/dag/gunbc/auth/approval_device_enrolment_code_issue.dag index bcb93f824c7..87946ae0cd7 100644 --- a/dag/gunbc/auth/approval_device_enrolment_code_issue.dag +++ b/dag/gunbc/auth/approval_device_enrolment_code_issue.dag @@ -14,7 +14,9 @@ import gunbc.live_deploy.slice_bounds { approval_broker_slice_memory_max, approv import extdeps.tools.env { env_prefixed_argv } import v2.std.orchestration { EnvSet } import gunbc.cli_run_workspace_root_scaffold { gunbc_workspace_root_env_name } -import gunbc.fleet_intent_network { operator_host_srv1 } +import gunbc.auth.approval_broker_placement { approval_broker_host_under, approval_broker_base_url_under, ApprovalBrokerHostResolved, ApprovalBrokerHostRefused } +import gunbc.auth.approval_broker_endpoint { approval_broker_base_url_env_name, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } +import gunbc.roadmap_dashboard_instance { ProdRoleRealization, prod_role_realization, prod_role_realization_refusal_detail } import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, fleet_converge_expected_host_env_name, read_expected_revision, ExpectedRevisionPresent, ExpectedRevisionAbsent, @@ -37,7 +39,7 @@ import gunbc.live_deploy.release_locus { // ── THE OPERATOR'S CODE-ISSUING VERB, over the fleet SSH administrator edge ────────────────── // A FleetConvergeWorkflowMode step (ApprovalDeviceEnrolmentCodeIssue) run on the host-pinned // runner, exactly as gunbc.auth.approval_keyring_converge reaches srv1: the job prepares the fleet -// SSH context, connects to srv1 as the bootstrap principal, and elevates to the OPERATOR'S POSIX +// SSH context, connects to the broker's placed host as the bootstrap principal, and elevates to the OPERATOR'S POSIX // USER -- the owner of approval_device_store_root and a reader of the ntfy publisher token -- to // run gunbc.auth.approval_device_enrolment_code issue_device_enrolment_code from the broker's admitted // release tree. The remote verb writes the code's slot and delivers the code over the operator's @@ -53,9 +55,28 @@ import gunbc.live_deploy.release_locus { // missing binary and a loud remote failure, never a fallback to another tree. data approval_device_enrolment_code_issue_attempt_raw: String = "approval-device-enrolment-code-issue-ci" -fn enrolment_code_issue_ssh_target() -> SshTarget { +// THE BROKER'S HOST IS PLACEMENT'S, NOT A SPELLED srv1: the verb runs where the broker is installed, +// which follows the prod-role holder (gunbc.auth.approval_broker_placement). The ssh target, the +// dispatch host check and the base URL the verb is given all come from that one placement, and under +// a refused realization the step refuses before reaching any host. +type EnrolmentCodeIssuePlacement + = EnrolmentCodeIssuePlaced { host: NonEmptyStr, base_url: NonEmptyStr } + | EnrolmentCodeIssueUnplaced { detail: NonEmptyStr } + +fn enrolment_code_issue_placement_under(realization: ProdRoleRealization) -> EnrolmentCodeIssuePlacement { + match approval_broker_host_under(realization: realization) { + ApprovalBrokerHostRefused { cause } => + EnrolmentCodeIssueUnplaced { detail: ("approval device enrolment code: " + prod_role_realization_refusal_detail(cause: cause)) as NonEmptyStr } + ApprovalBrokerHostResolved { host } => match approval_broker_base_url_under(realization: realization) { + ApprovalBrokerAddressResolved { value: url } => EnrolmentCodeIssuePlaced { host: host, base_url: url } + ApprovalBrokerAddressRefused { detail } => EnrolmentCodeIssueUnplaced { detail: detail } + } + } +} + +fn enrolment_code_issue_ssh_target(host: NonEmptyStr) -> SshTarget { SshTarget { - endpoint: fleet_probe_endpoint_for(host: operator_host_srv1 as String) as NonEmptyStr, + endpoint: fleet_probe_endpoint_for(host: host as String) as NonEmptyStr, principal: executor_bootstrap_principal.login, } } @@ -97,12 +118,13 @@ fn enrolment_code_issue_scope_properties() -> List { // supplementary groups; systemd-run's own --uid/--gid did not, and every code was minted and then // refused delivery (fleet-converge run 37098981121). The drop sits inside the scope so the bound // still covers the whole verb. -fn enrolment_code_issue_remote_argv(revision: ReleaseRevisionBinding) -> List { +fn enrolment_code_issue_remote_argv(revision: ReleaseRevisionBinding, base_url: NonEmptyStr) -> List { let root = srv1_gunbc_approval_broker_root as String let release_dir = approval_broker_release_dir(root: root, revision: revision) let account = fleet_posix_operator_user.name let verb = env_prefixed_argv(bindings: [ EnvSet { name: gunbc_workspace_root_env_name, value: release_dir }, + EnvSet { name: approval_broker_base_url_env_name as String, value: base_url as String }, ], command_argv: [ approval_broker_release_binary_path(root: root, revision: revision), "run", @@ -117,18 +139,18 @@ fn enrolment_code_issue_remote_argv(revision: ReleaseRevisionBinding) -> List ProcessExit { - if host == (operator_host_srv1 as String) { ExitSuccess } - else { exit_failure(reason: "approval device enrolment code: refuses host '" + host + "': the store lives on the machine that serves /approve, which is " + (operator_host_srv1 as String)) } +fn enrolment_code_issue_refuses_off_broker_host(host: String, broker_host: NonEmptyStr) -> ProcessExit { + if host == (broker_host as String) { ExitSuccess } + else { exit_failure(reason: "approval device enrolment code: refuses host '" + host + "': the store lives on the machine that serves /approve, which is " + (broker_host as String)) } } -fn enrolment_code_issue_remote(revision: ReleaseRevisionBinding) -> CliWireResponse +fn enrolment_code_issue_remote(revision: ReleaseRevisionBinding, host: NonEmptyStr, base_url: NonEmptyStr) -> CliWireResponse uses net: Network { match prepare_fleet_ssh_agent_context(attempt_raw: approval_device_enrolment_code_issue_attempt_raw) { FleetSshContextRefused { cause: c } => CliWireUnprintable { cause: ("approval device enrolment code: " + c) as NonEmptyStr } FleetSshContextReady { context: context, receipt: _ } => - match typed_argv_exec_over_fleet_ssh(target: enrolment_code_issue_ssh_target(), context: context, argv: enrolment_code_issue_remote_argv(revision: revision)) { + match typed_argv_exec_over_fleet_ssh(target: enrolment_code_issue_ssh_target(host: host), context: context, argv: enrolment_code_issue_remote_argv(revision: revision, base_url: base_url)) { TypedArgvExecRefused { reason: why } => CliWireUnprintable { cause: ("approval device enrolment code: remote invocation refused: " + why) as NonEmptyStr } TypedArgvExecConverged { result: r } => if r.success { CliWirePrintable { bytes: r.stdout, exit: ExitSuccess } } @@ -137,21 +159,24 @@ fn enrolment_code_issue_remote(revision: ReleaseRevisionBinding) -> CliWireRespo } } -// THE STEP ENTRY: one host per run, named by the dispatch and required to be srv1; one release, -// named by the dispatch. +// THE STEP ENTRY: one host per run, named by the dispatch and required to be the broker's placed host; +// one release, named by the dispatch. fn approval_device_enrolment_code_issue_wet() -> CliWireResponse uses net: Network { match actions_variable_read(name: fleet_converge_expected_host_env_name) { ActionsVariableAbsent { variable: v, detail: d } => CliWireUnprintable { cause: ("approval device enrolment code: " + v + " " + d + "; one host per run, named by the dispatch") as NonEmptyStr } - ActionsVariablePresent { value: host } => - match enrolment_code_issue_refuses_off_srv1(host: host) { - ExitSuccess => - match read_expected_revision() { - ExpectedRevisionAbsent { variable: v, detail: d } => CliWireUnprintable { cause: ("approval device enrolment code: " + v + " " + d + "; the release to run is named by the dispatch") as NonEmptyStr } - ExpectedRevisionPresent { revision: rev } => enrolment_code_issue_remote(revision: RevisionBoundAtEmission { revision: rev as CommitSha }) - } - ExitFailure { code: _, reason: why } => CliWireUnprintable { cause: why as NonEmptyStr } - } + ActionsVariablePresent { value: host } => match enrolment_code_issue_placement_under(realization: prod_role_realization()) { + EnrolmentCodeIssueUnplaced { detail } => CliWireUnprintable { cause: detail } + EnrolmentCodeIssuePlaced { host: broker_host, base_url } => + match enrolment_code_issue_refuses_off_broker_host(host: host, broker_host: broker_host) { + ExitSuccess => + match read_expected_revision() { + ExpectedRevisionAbsent { variable: v, detail: d } => CliWireUnprintable { cause: ("approval device enrolment code: " + v + " " + d + "; the release to run is named by the dispatch") as NonEmptyStr } + ExpectedRevisionPresent { revision: rev } => enrolment_code_issue_remote(revision: RevisionBoundAtEmission { revision: rev as CommitSha }, host: broker_host, base_url: base_url) + } + ExitFailure { code: _, reason: why } => CliWireUnprintable { cause: why as NonEmptyStr } + } + } } } diff --git a/dag/gunbc/auth/approval_request_submission.dag b/dag/gunbc/auth/approval_request_submission.dag index 08de7f744a7..a3d66054263 100644 --- a/dag/gunbc/auth/approval_request_submission.dag +++ b/dag/gunbc/auth/approval_request_submission.dag @@ -13,7 +13,7 @@ import gunbc.auth.approval_capability { ProposedDecision, ProposeApprove, Propos import gunbc.auth.approval_broker { SubmissionAdmission, SubmissionAdmitted, SubmissionRequesterNotEnrolled, SubmissionAuthorityNotPermittedForRequester, admit_submission, BrokerApproved, BrokerDenied } import gunbc.auth.approval_notification { approval_notification } import gunbc.auth.approval_ntfy_publish -import gunbc.auth.approval_broker_endpoint { approval_broker_serving_base_url, approval_broker_confirm_path_prefix, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } +import gunbc.auth.approval_broker_endpoint { approval_broker_confirm_path_prefix, ApprovalBrokerAddress, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } import std.algebra { trim } import gunbc.auth.approval_status_wire { FiledApprovalStatus, StatusNotFiled, StatusPending, StatusApproved, StatusDenied, StatusUnreadable, @@ -308,10 +308,11 @@ fn notify_filed( capability_keyring: ApprovalKeyringRead, publisher: PublisherTokenRead, request: StoredApprovalRequest, + broker: ApprovalBrokerAddress, ) -> SubmissionOutcome { match capability_keyring { ApprovalKeyringRefused { cause: c } => SubmissionFiledCapabilityKeyUnavailable { escalation_id: request.escalation_id, cause: c } - ApprovalKeyLoaded { key: key } => match approval_broker_serving_base_url() { + ApprovalKeyLoaded { key: key } => match broker { ApprovalBrokerAddressRefused { detail } => SubmissionFiledBrokerAddressRefused { escalation_id: request.escalation_id, detail: detail } ApprovalBrokerAddressResolved { value: base } => match issue_links(capability_key: key, base: base, request: request) { LinksNotIssued { key_id: k } => SubmissionFiledLinksNotIssued { escalation_id: request.escalation_id, key_id: k } @@ -392,16 +393,17 @@ fn file_and_notify( capability_keyring: ApprovalKeyringRead, publisher: PublisherTokenRead, request: StoredApprovalRequest, + broker: ApprovalBrokerAddress, ) -> SubmissionOutcome { match file_approval_request(root: root, request: request) { StoreSlotOccupied => match occupied_slot_standing(slot: observe_escalation(root: root, escalation_id: request.escalation_id), request: request) { - OccupiedRenotifiable => notify_filed(capability_keyring: capability_keyring, publisher: publisher, request: request) + OccupiedRenotifiable => notify_filed(capability_keyring: capability_keyring, publisher: publisher, request: request, broker: broker) OccupiedConflict => SubmissionAlreadyFiled { escalation_id: request.escalation_id } OccupiedUnreadable { detail: d } => SubmissionStoreRefused { detail: d } } StoreRefused { detail: d } => SubmissionStoreRefused { detail: d } - StoreWritten => notify_filed(capability_keyring: capability_keyring, publisher: publisher, request: request) + StoreWritten => notify_filed(capability_keyring: capability_keyring, publisher: publisher, request: request, broker: broker) } } @@ -419,9 +421,10 @@ fn submit_parsed_submission( publisher: PublisherTokenRead, tag_hex: NonEmptyStr, request: StoredApprovalRequest, + broker: ApprovalBrokerAddress, ) -> SubmissionOutcome { match admit_parsed_submission(tag_hex: tag_hex, request: request, keyring: submission_keyring) { - SubmissionVerified { request: r } => file_and_notify(root: root, capability_keyring: capability_keyring, publisher: publisher, request: r) + SubmissionVerified { request: r } => file_and_notify(root: root, capability_keyring: capability_keyring, publisher: publisher, request: r, broker: broker) SubmissionNotAdmitted { refusal: refused } => SubmissionRefused { refusal: refused } } } diff --git a/dag/gunbc/instruments/approval_store_live_probe.dag b/dag/gunbc/instruments/approval_store_live_probe.dag index a2c57d369f4..a3fb6b09678 100644 --- a/dag/gunbc/instruments/approval_store_live_probe.dag +++ b/dag/gunbc/instruments/approval_store_live_probe.dag @@ -1,5 +1,6 @@ module tools.approval_store_live_probe +import gunbc.auth.approval_broker_endpoint { approval_broker_serving_base_url } import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } import std.process { ProcessExit, ExitSuccess, exit_failure } import std.content_hash { ContentHash, Sha256Hash, Sha256Digest } @@ -193,6 +194,7 @@ fn probe_submit(root: NonEmptyStr, signer: MacKey, request: StoredApprovalReques publisher: PublisherTokenUnavailable { path: "/etc/gunbc-ntfy/publisher-token", detail: "probe fixture: no message leaves this probe" }, tag_hex: t.hex, request: request, + broker: approval_broker_serving_base_url(), ) match outcome { SubmissionRefused { refusal: SubmissionNotEnrolled { detail: _ } } => ProbeNotEnrolled diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index 9d80b9ae446..98f2b52b794 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -121,7 +121,10 @@ import gunbc.live_deploy.slice_bounds { repository_convergence_slice_memory_max, repository_convergence_slice_memory_high, repository_convergence_slice_memory_swap_max, } +import gunbc.auth.approval_broker_placement { approval_broker_base_url_of_holder } +import gunbc.auth.approval_broker_endpoint { approval_broker_base_url_env_name } import gunbc.live_deploy.spec { + HoldsProdRoleSingletons, HoldsNoRoleSingleton, deployment_slice_unit_name, deployment_slice_unit_path, deployment_fabric_storage_unit_name, deployment_fabric_storage_serve_endpoint, deployment_fabric_storage_door_socket, deployment_approval_broker_front_door_endpoints, gunbc_approval_broker_unit_name, gunbc_approval_broker_unit_path, @@ -663,6 +666,7 @@ fn live_deploy_approval_broker_unit_file_on( ServiceSlice { name: gunbc_approval_broker_slice_unit_name }, WorkingDirectory { path: release_dir as NonEmptyStr }, release_locus_workspace_root_environment(release_dir: release_dir), + approval_broker_base_url_environment(spec: spec), ExecStart { command: join([ approval_broker_release_binary_path(root: svc.repo_root as String, revision: revision), " serve --source-root dag --source-root src/v2", @@ -1815,6 +1819,21 @@ data approval_broker_release_retention_note: String = "The broker's release locu // from a directory that is not a git checkout, and a locus is not one -- measured on srv1 // 2026-09-21, where both the broker's serve unit and the microVM slot unit exited 101 in // workspace_root before any stage ran. One row feeds both units. +// THE BROKER IS GIVEN ITS PUBLIC BASE URL, built from the holder token the spec carries +// (gunbc.auth.approval_broker_placement approval_broker_base_url_of_holder), so the serving broker +// reads where it runs instead of resolving it. A spec holding no role never reaches a broker unit +// write (apply_approval_broker_unit_write_steps is empty for it); if one is rendered anyway the +// binding is a refusal marker the broker's address read rejects as a non-URL, never a guessed host. +data approval_broker_base_url_unbound_marker: String = "__GUNBC_DEPLOY_REFUSED__no-prod-role-holder" + +fn approval_broker_base_url_environment(spec: DeploymentSpec) -> SystemdServiceDirective { + let value = match spec.role_singletons { + HoldsProdRoleSingletons { holder } => approval_broker_base_url_of_holder(holder: holder) as String + HoldsNoRoleSingleton => approval_broker_base_url_unbound_marker + } + Environment { assignment: join([approval_broker_base_url_env_name as String, "=", value], "") as NonEmptyStr } +} + fn release_locus_workspace_root_environment(release_dir: String) -> SystemdServiceDirective { Environment { assignment: join([gunbc_workspace_root_env_name, "=", release_dir], "") as NonEmptyStr } } diff --git a/dag/test/claim/approval_device_enrolment_code_wet_witness_test.dag b/dag/test/claim/approval_device_enrolment_code_wet_witness_test.dag index 7141ce711b9..33989bbb324 100644 --- a/dag/test/claim/approval_device_enrolment_code_wet_witness_test.dag +++ b/dag/test/claim/approval_device_enrolment_code_wet_witness_test.dag @@ -1,5 +1,6 @@ module test.claim.approval_device_enrolment_code_wet_witness_test +import gunbc.auth.approval_broker_endpoint { ApprovalBrokerAddressResolved } import std.logic { Bool } import std.types { String, NonEmptyStr, Timestamp, Int } import gunbc.auth.approval_device_enrolment_code { @@ -29,9 +30,9 @@ test fn the_code_slot_is_written_before_delivery_by_real_execution() -> Bool { let root_path = shell.Mktemp.DirWithTemplate(template: "/tmp/gunbc_enrol.XXXXXX").path let root = root_path as NonEmptyStr let publisher = PublisherTokenUnavailable { path: "/etc/gunbc-ntfy/publisher-token", detail: "absent" } - let first = issue_and_deliver_enrolment_code_until(root: root, now: "2026-09-21T20:00:00Z", expires_at: fx_expires, code: fx_code, publisher: publisher) + let first = issue_and_deliver_enrolment_code_until(root: root, now: "2026-09-21T20:00:00Z", expires_at: fx_expires, code: fx_code, publisher: publisher, broker: ApprovalBrokerAddressResolved { value: "https://srv1.example.ts.net" as NonEmptyStr }) let slot = observe_enrolment_slot(root: root, code: fx_code) - let second = issue_and_deliver_enrolment_code_until(root: root, now: "2026-09-21T20:00:00Z", expires_at: fx_expires, code: fx_code, publisher: publisher) + let second = issue_and_deliver_enrolment_code_until(root: root, now: "2026-09-21T20:00:00Z", expires_at: fx_expires, code: fx_code, publisher: publisher, broker: ApprovalBrokerAddressResolved { value: "https://srv1.example.ts.net" as NonEmptyStr }) let removed = shell.Remove.RecursiveForce(path: root_path) (match first { EnrolmentCodeIssued { topic: _, expires_at: x, delivery: DeliveryRefused { detail: _ } } => x == fx_expires diff --git a/dag/test/claim/approval_device_enrolment_code_witness_test.dag b/dag/test/claim/approval_device_enrolment_code_witness_test.dag index d408572f0cb..827b1ffd1e0 100644 --- a/dag/test/claim/approval_device_enrolment_code_witness_test.dag +++ b/dag/test/claim/approval_device_enrolment_code_witness_test.dag @@ -1,5 +1,8 @@ module test.claim.approval_device_enrolment_code_witness_test +import gunbc.auth.approval_broker_endpoint { approval_broker_base_url_env_name, ApprovalBrokerAddressResolved } +import gunbc.roadmap_dashboard_instance { prod_role_realization, prod_role_realization_in, public_builtin_dashboard_instances } +import gunbc.deployment_risk { ProdRoleHeldBy, NoProdRole, srv2_deploy_deployment } import std.logic { Bool } import std.types { String, NonEmptyStr, Timestamp, List, Secret, Int } import std.process { ExitSuccess } @@ -17,7 +20,7 @@ import gunbc.auth.approval_ntfy_publish { hermetic_ntfy_publish_message_id } import gunbc.auth.approval_device_redemption { observe_enrolment_slot, EnrolmentCodeUnspent } import extdeps.shell import gunbc.auth.approval_notification { approval_ntfy_topic } -import gunbc.auth.approval_device_enrolment_code_issue { enrolment_code_issue_remote_argv, enrolment_code_issue_refuses_off_srv1 } +import gunbc.auth.approval_device_enrolment_code_issue { enrolment_code_issue_remote_argv, enrolment_code_issue_refuses_off_broker_host, enrolment_code_issue_placement_under, EnrolmentCodeIssuePlaced, EnrolmentCodeIssueUnplaced } import gunbc.live_deploy.release_locus { RevisionBoundAtEmission, } @@ -41,7 +44,7 @@ data fx_expires: Timestamp = "2026-09-21T20:10:00Z" test fn the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code() -> Bool { let message = enrolment_code_notification(code: fx_code, expires_at: fx_expires, approval_url: "https://srv1.example.ts.net" as NonEmptyStr) - (match deliver_enrolment_code(code: fx_code, expires_at: fx_expires, publisher: PublisherTokenLoaded { token: "hermetic-publisher-token" as Secret }) { + (match deliver_enrolment_code(code: fx_code, expires_at: fx_expires, publisher: PublisherTokenLoaded { token: "hermetic-publisher-token" as Secret }, broker: ApprovalBrokerAddressResolved { value: "https://srv1.example.ts.net" as NonEmptyStr }) { EnrolmentCodeIssued { topic: t, expires_at: x, delivery: DeliveryAccepted { message_id: Present { value: m } } } => { let line = enrolment_code_delivery_line(r: EnrolmentCodeDeliveryReceipt { topic: t, message_id: Present { value: m }, expires_at: x }) (m as String) == hermetic_ntfy_publish_message_id @@ -53,7 +56,7 @@ test fn the_delivered_receipt_comes_from_the_published_message_and_its_line_neve } _ => false }) - && (match deliver_enrolment_code(code: fx_code, expires_at: fx_expires, publisher: PublisherTokenUnavailable { path: "/etc/gunbc-ntfy/publisher-token", detail: "absent" }) { + && (match deliver_enrolment_code(code: fx_code, expires_at: fx_expires, publisher: PublisherTokenUnavailable { path: "/etc/gunbc-ntfy/publisher-token", detail: "absent" }, broker: ApprovalBrokerAddressResolved { value: "https://srv1.example.ts.net" as NonEmptyStr }) { EnrolmentCodeIssued { topic: _, expires_at: x, delivery: DeliveryRefused { detail: c } } => x == fx_expires && string_contains(s: c, pattern: "absent") _ => false }) @@ -75,28 +78,36 @@ test fn the_delivered_receipt_comes_from_the_published_message_and_its_line_neve // verb could not read the group-gated ntfy publisher token (run 37098981121); restoring them turns // this claim red. test fn the_remote_argv_runs_the_release_verb_as_the_operator_user() -> Bool { - let argv = enrolment_code_issue_remote_argv(revision: RevisionBoundAtEmission { revision: "0123456789abcdef0123456789abcdef01234567" }) + let argv = enrolment_code_issue_remote_argv(revision: RevisionBoundAtEmission { revision: "0123456789abcdef0123456789abcdef01234567" }, base_url: "https://srv1.example.ts.net" as NonEmptyStr) let joined = join(argv, " ") let op = fleet_posix_operator_user.name as String string_contains(s: joined, pattern: "/usr/bin/sudo -n systemd-run --scope --property=MemoryMax=" + to_string(byte_size_count(b: approval_broker_slice_memory_max)) + " --property=MemoryHigh=" + to_string(byte_size_count(b: approval_broker_slice_memory_high)) - + " -- setpriv --reuid=" + op + " --regid=" + op + " --init-groups -- /usr/bin/env " + gunbc_workspace_root_env_name + "=/opt/gunbc/approval-broker/releases/0123456789abcdef0123456789abcdef01234567 /opt/gunbc/approval-broker/releases/0123456789abcdef0123456789abcdef01234567/gunbc run ") + + " -- setpriv --reuid=" + op + " --regid=" + op + " --init-groups -- /usr/bin/env " + gunbc_workspace_root_env_name + "=/opt/gunbc/approval-broker/releases/0123456789abcdef0123456789abcdef01234567 " + (approval_broker_base_url_env_name as String) + "=https://srv1.example.ts.net /opt/gunbc/approval-broker/releases/0123456789abcdef0123456789abcdef01234567/gunbc run ") && !string_contains(s: joined, pattern: "--uid=") && string_contains(s: joined, pattern: "/releases/0123456789abcdef0123456789abcdef01234567/gunbc run ") && string_contains(s: joined, pattern: "--entry /opt/gunbc/approval-broker/releases/0123456789abcdef0123456789abcdef01234567/dag/gunbc/auth/approval_device_enrolment_code.dag --function issue_device_enrolment_code --arg revision=0123456789abcdef0123456789abcdef01234567") } -// The step runs on srv1 and nowhere else; the mode is rostered, wired, and consumes the fleet SSH -// key like its keyring sibling. -test fn the_mode_is_rostered_and_pinned_to_srv1() -> Bool { - (match enrolment_code_issue_refuses_off_srv1(host: operator_host_srv1 as String) { - ExitSuccess => true - _ => false +// The step runs on the broker's placed host and nowhere else -- srv1 under today's selection, srv2 +// when the role moves to srv2-deploy, and nowhere under NoProdRole; the mode is rostered, wired, and +// consumes the fleet SSH key like its keyring sibling. +test fn the_mode_is_rostered_and_pinned_to_the_brokers_placed_host() -> Bool { + let roster = public_builtin_dashboard_instances() + let today = enrolment_code_issue_placement_under(realization: prod_role_realization()) + let moved = enrolment_code_issue_placement_under(realization: prod_role_realization_in(selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }, instances: roster)) + let unheld = enrolment_code_issue_placement_under(realization: prod_role_realization_in(selection: NoProdRole, instances: roster)) + (match today { + EnrolmentCodeIssuePlaced { host, base_url: _ } => + (match enrolment_code_issue_refuses_off_broker_host(host: "srv1", broker_host: host) { ExitSuccess => true _ => false }) + && (match enrolment_code_issue_refuses_off_broker_host(host: "srv2", broker_host: host) { ExitSuccess => false _ => true }) + EnrolmentCodeIssueUnplaced { detail: _ } => false }) - && (match enrolment_code_issue_refuses_off_srv1(host: "srv2") { - ExitSuccess => false - _ => true + && (match moved { + EnrolmentCodeIssuePlaced { host, base_url } => (host as String) == "srv2" && string_contains(s: base_url as String, pattern: "srv2.") + EnrolmentCodeIssueUnplaced { detail: _ } => false }) + && (match unheld { EnrolmentCodeIssueUnplaced { detail: _ } => true _ => false }) && fleet_converge_workflow_mode_wire(mode: ApprovalDeviceEnrolmentCodeIssue) == "approval_device_enrolment_code_issue" && any(fleet_converge_workflow_modes, m => fleet_converge_workflow_mode_wire(mode: m) == "approval_device_enrolment_code_issue") && (match fleet_converge_mode_fleet_ssh_key_demand(mode: ApprovalDeviceEnrolmentCodeIssue) { diff --git a/dag/test/claim/approval_ntfy_access_readback_witness_test.dag b/dag/test/claim/approval_ntfy_access_readback_witness_test.dag index 768b1b8e13a..23d69f55ed2 100644 --- a/dag/test/claim/approval_ntfy_access_readback_witness_test.dag +++ b/dag/test/claim/approval_ntfy_access_readback_witness_test.dag @@ -1236,7 +1236,7 @@ test fn a_duplicated_principal_refuses() -> Bool { // ── THE SEALS: no second root compiles ───────────────────────────────────────────────────── // A fresh module reaching each effect leaf, and the runtime reader, from an unadmitted // declaration. Each call must be refused by admission, named by its subject. -data unadmitted_effect_caller_source: String = "module probe_enrolment_second_root\nimport gunbc.auth.approval_device_enrolment_code { mint_enrolment_code, issue_and_deliver_enrolment_code_until, deliver_enrolment_code, enrolment_code_notification }\nimport gunbc.auth.approval_device_redemption { issue_enrolment_code }\nimport gunbc.auth.approval_request_submission { read_publisher_token, publish_notification }\nimport gunbc.auth.approval_ntfy_access_readback { approval_ntfy_runtime_reading, approval_ntfy_run, ReadConfig }\nimport gunbc.live_deploy.emit { RevisionNotInstalled }\nfn second_root() -> Bool {\n let a = mint_enrolment_code()\n let t = read_publisher_token()\n let b = issue_enrolment_code(root: \"/tmp/x\", code: \"c\", issued_at: \"2026-09-21T20:00:00Z\", expires_at: \"2026-09-21T20:10:00Z\")\n let c = issue_and_deliver_enrolment_code_until(root: \"/tmp/x\", now: \"2026-09-21T20:00:00Z\", expires_at: \"2026-09-21T20:10:00Z\", code: \"c\", publisher: t)\n let d = deliver_enrolment_code(code: \"c\", expires_at: \"2026-09-21T20:10:00Z\", publisher: t)\n let e = publish_notification(message: enrolment_code_notification(code: \"c\", expires_at: \"2026-09-21T20:10:00Z\"), publisher: t)\n let f = approval_ntfy_runtime_reading(revision: RevisionNotInstalled)\n let g = approval_ntfy_run(read: ReadConfig, pid: \"1\", revision: RevisionNotInstalled)\n true\n}\n" +data unadmitted_effect_caller_source: String = "module probe_enrolment_second_root\nimport gunbc.auth.approval_device_enrolment_code { mint_enrolment_code, issue_and_deliver_enrolment_code_until, deliver_enrolment_code, enrolment_code_notification }\nimport gunbc.auth.approval_device_redemption { issue_enrolment_code }\nimport gunbc.auth.approval_request_submission { read_publisher_token, publish_notification }\nimport gunbc.auth.approval_ntfy_access_readback { approval_ntfy_runtime_reading, approval_ntfy_run, ReadConfig }\nimport gunbc.live_deploy.emit { RevisionNotInstalled }\nimport gunbc.auth.approval_broker_endpoint { ApprovalBrokerAddressRefused }\nfn second_root() -> Bool {\n let a = mint_enrolment_code()\n let t = read_publisher_token()\n let b = issue_enrolment_code(root: \"/tmp/x\", code: \"c\", issued_at: \"2026-09-21T20:00:00Z\", expires_at: \"2026-09-21T20:10:00Z\")\n let c = issue_and_deliver_enrolment_code_until(root: \"/tmp/x\", now: \"2026-09-21T20:00:00Z\", expires_at: \"2026-09-21T20:10:00Z\", code: \"c\", publisher: t, broker: ApprovalBrokerAddressRefused { detail: \"x\" })\n let d = deliver_enrolment_code(code: \"c\", expires_at: \"2026-09-21T20:10:00Z\", publisher: t, broker: ApprovalBrokerAddressRefused { detail: \"x\" })\n let e = publish_notification(message: enrolment_code_notification(code: \"c\", expires_at: \"2026-09-21T20:10:00Z\", approval_url: \"https://x\"), publisher: t)\n let f = approval_ntfy_runtime_reading(revision: RevisionNotInstalled)\n let g = approval_ntfy_run(read: ReadConfig, pid: \"1\", revision: RevisionNotInstalled)\n true\n}\n" data sealed_effect_leaves: List = ["mint_enrolment_code", "read_publisher_token", "issue_enrolment_code", "issue_and_deliver_enrolment_code_until", "deliver_enrolment_code", "publish_notification", "approval_ntfy_runtime_reading", "approval_ntfy_run"] diff --git a/dag/test/claim/approval_request_submission_witness_test.dag b/dag/test/claim/approval_request_submission_witness_test.dag index fbb5a59d7c3..c2b12e97647 100644 --- a/dag/test/claim/approval_request_submission_witness_test.dag +++ b/dag/test/claim/approval_request_submission_witness_test.dag @@ -1,5 +1,6 @@ module test.claim.approval_request_submission_witness_test +import gunbc.auth.approval_broker_endpoint { ApprovalBrokerAddressResolved } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.types { Bool, NonEmptyStr, String, Secret } import std.scoped_authorization { AttemptIdentity } @@ -181,11 +182,11 @@ test fn status_text_reports_not_filed_for_an_absent_slot_and_unreadable_off_the_ // the fixture message id; deleting the publish call, or the token arm, fails this control. The // token-unavailable arm is the same fold one input earlier, so both are read from one route. test fn a_filed_request_with_a_publisher_token_is_published_and_without_one_is_reported() -> Bool { - match notify_filed(capability_keyring: ApprovalKeyLoaded { key: capability_key() }, publisher: PublisherTokenLoaded { token: "hermetic-publisher-token" as Secret }, request: request()) { + match notify_filed(capability_keyring: ApprovalKeyLoaded { key: capability_key() }, publisher: PublisherTokenLoaded { token: "hermetic-publisher-token" as Secret }, request: request(), broker: ApprovalBrokerAddressResolved { value: "https://srv1.example.ts.net" as NonEmptyStr }) { SubmissionFiledAndNotified { escalation_id: e, message_id: m } => e == "esc-mtc1-boot-1" && (m as String) == hermetic_ntfy_publish_message_id _ => false } - && match notify_filed(capability_keyring: ApprovalKeyLoaded { key: capability_key() }, publisher: PublisherTokenUnavailable { path: "/etc/gunbc-ntfy/publisher-token", detail: "absent" }, request: request()) { + && match notify_filed(capability_keyring: ApprovalKeyLoaded { key: capability_key() }, publisher: PublisherTokenUnavailable { path: "/etc/gunbc-ntfy/publisher-token", detail: "absent" }, request: request(), broker: ApprovalBrokerAddressResolved { value: "https://srv1.example.ts.net" as NonEmptyStr }) { SubmissionFiledPublisherTokenUnavailable { escalation_id: _, detail: d } => d == "absent" _ => false } @@ -218,7 +219,7 @@ test fn an_occupied_slot_is_renotifiable_only_while_pending_with_the_same_bytes( // A CAPABILITY-KEY REFUSAL AFTER THE WRITE STILL SAYS WHICH ESCALATION WAS FILED, like every other // post-write arm, so the filer retries the same bytes instead of minting a new id (review 67295). test fn a_capability_key_refusal_after_filing_names_the_filed_escalation() -> Bool { - match notify_filed(capability_keyring: ApprovalKeyringRefused { cause: ApprovalKeyFileAbsent { path: "/etc/gunbc-roadmap/approval-mac-key" } }, publisher: PublisherTokenLoaded { token: "hermetic-publisher-token" as Secret }, request: request()) { + match notify_filed(capability_keyring: ApprovalKeyringRefused { cause: ApprovalKeyFileAbsent { path: "/etc/gunbc-roadmap/approval-mac-key" } }, publisher: PublisherTokenLoaded { token: "hermetic-publisher-token" as Secret }, request: request(), broker: ApprovalBrokerAddressResolved { value: "https://srv1.example.ts.net" as NonEmptyStr }) { SubmissionFiledCapabilityKeyUnavailable { escalation_id: e, cause: _ } => e == "esc-mtc1-boot-1" _ => false } diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 50b16e9ab6d..44465da10c7 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -77,6 +77,9 @@ import gunbc.live_deploy.role_singleton_convergence { role_singleton_observation_of, } import gunbc.fleet_converge_workflow { fleet_converge_gcp_iam_converge_job_under } +import gunbc.live_deploy.emit { approval_broker_base_url_environment, approval_broker_base_url_unbound_marker } +import gunbc.auth.approval_broker_endpoint { approval_broker_base_url_env_name, approval_broker_serving_base_url_of, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } +import extdeps.systemd.unit_file { SystemdServiceDirective, Environment } import extdeps.github.actions { SelfHosted, RunStep } import gunbc.fabric_storage_placement { FabricStoragePlacementRefused, @@ -374,3 +377,32 @@ test fn the_real_selection_places_every_singleton_on_its_holder_and_converges_as && any(job_labels, l => l == (srv1_live_dashboard_instance().host_identity as String)) && noop } + +// ── The broker is GIVEN its address, at the emitted-unit boundary ─────────────────────────────── +// The broker unit's Environment= line is built from the spec's holder token: srv2's base URL when the +// role moves to srv2-deploy, srv1's under today's selection, and the refusal marker -- which the +// broker's own read rejects as not an https URL -- for a spec holding no role. +fn broker_env_assignment(spec: DeploymentSpec) -> String { + match approval_broker_base_url_environment(spec: spec) { + Environment { assignment } => assignment as String + _ => "" + } +} + +fn bound(value: String) -> String { + join([approval_broker_base_url_env_name as String, "=", value], "") +} + +test fn the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_refusal() -> Bool { + let moved = roster_specs_under(realization: fx_moved()) + let real = roster_specs_under(realization: prod_role_realization()) + let srv2_moved = moved |> filter(s => is_srv2_deploy(spec: s)) + let srv1_moved = moved |> filter(s => (s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String)) + let live_real = real |> filter(s => (s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String)) + count(srv2_moved) == 1 && count(srv1_moved) == 1 && count(live_real) == 1 + && all(srv2_moved, s => starts_with(s: broker_env_assignment(spec: s), prefix: bound(value: "https://srv2."))) + && all(live_real, s => starts_with(s: broker_env_assignment(spec: s), prefix: bound(value: "https://srv1."))) + && all(srv1_moved, s => broker_env_assignment(spec: s) == bound(value: approval_broker_base_url_unbound_marker)) + && match approval_broker_serving_base_url_of(bound: Present { value: approval_broker_base_url_unbound_marker }) { ApprovalBrokerAddressRefused { detail: _ } => true _ => false } + && match approval_broker_serving_base_url_of(bound: none) { ApprovalBrokerAddressRefused { detail: _ } => true _ => false } +} From c2b57b5b7ea08c2d052ab085c84da0a83ca34ae9 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 10:22:15 +0000 Subject: [PATCH 19/48] fleet-converge: broker-host steps say 'on the approval broker host' (host-neutral); fix approval_broker_serve import Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2run.sh | 7 ++++--- dag/gunbc/auth/approval_broker_serve.dag | 4 +++- dag/gunbc/fleet/fleet_converge_workflow.dag | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.d2run.sh b/.d2run.sh index f67732f2b24..691bb3a87cf 100644 --- a/.d2run.sh +++ b/.d2run.sh @@ -2,19 +2,20 @@ set -u cargo build --release -p v1-compiler --bin claim_batch --bin v1_src_dag_parse 2>&1 | tail -1 mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 21474836480 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs ./target/release/v1_src_dag_parse 2>&1 | grep -v "parse-clean" | head -20 -run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^(PASS|FAIL)" /tmp/cb.out | grep -v "^PASS"; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)" | tail -15; fi; echo rc=$rc; } -run dag/test/claim/deployment_risk_witness_test.dag a_test_deployment_refuses_rather_than_resolving_the_prod_bindings,the_prod_role_holder_resolves_the_prod_topic_and_app,each_class_resolves_only_its_own_set,no_prod_role_makes_the_former_holder_test_risk_and_it_refuses,moving_the_role_moves_the_class_and_nothing_else_decides_it,exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace,the_wrong_instance_failure_mode_row_cites_this_red,moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses,a_prod_role_holder_with_no_instance_refuses_as_unrealized,a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move,no_prod_role_with_a_holder_observed_refuses_as_a_removal,an_unobservable_roster_host_refuses_rather_than_reading_as_absent,nothing_observed_and_a_valid_holder_installs_on_that_holder,the_observed_holder_equal_to_the_desired_one_is_a_noop,two_observed_holders_or_an_ownerless_broker_refuse,the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop +run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^FAIL" /tmp/cb.out; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)|^(PASS|FAIL)" | tail -12; fi; echo rc=$rc; } +run dag/test/claim/deployment_risk_witness_test.dag a_test_deployment_refuses_rather_than_resolving_the_prod_bindings,the_prod_role_holder_resolves_the_prod_topic_and_app,each_class_resolves_only_its_own_set,no_prod_role_makes_the_former_holder_test_risk_and_it_refuses,moving_the_role_moves_the_class_and_nothing_else_decides_it,exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace,the_wrong_instance_failure_mode_row_cites_this_red,moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses,a_prod_role_holder_with_no_instance_refuses_as_unrealized,a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move,no_prod_role_with_a_holder_observed_refuses_as_a_removal,an_unobservable_roster_host_refuses_rather_than_reading_as_absent,nothing_observed_and_a_valid_holder_installs_on_that_holder,the_observed_holder_equal_to_the_desired_one_is_a_noop,two_observed_holders_or_an_ownerless_broker_refuse,the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop,the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_refusal run dag/test/claim/roadmap/serve_function_binding_witness_test.dag every_instances_serve_function_serves_that_instance run dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag dashboard_source_transport_argv_is_exact_and_atomic,dashboard_local_origin_ref_translation_is_exact,dashboard_provider_runtime_and_auth_share_one_state_binding,dashboard_target_preflight_derives_dispatch_platform_dependencies,dashboard_health_requires_intended_instance_identity,dashboard_plan_is_end_to_end_and_production_disjoint,dashboard_refusal_receipt_is_typed_and_located,dashboard_dirty_controller_source_refuses_before_apply,dashboard_bootstrap_parent_writability_is_a_preflight_fact,dashboard_unreachable_root_refuses_before_choosing_a_parent,protected_sibling_readings_never_equate_absent_with_observed,a_host_without_a_protected_sibling_refuses_nothing_and_reads_nothing,protected_siblings_follow_the_prod_role_selection_within_the_host,sibling_isolation_still_requires_the_instance_to_own_its_paths run dag/test/claim/approval_request_client_witness_test.dag submit_and_status_urls_follow_the_prod_role_holders_listen_bind,submit_body_nests_the_stored_request_as_a_json_string,status_words_are_closed,injected_kv_lines_in_expires_at_are_unreadable_not_approved,poll_expires_after_expires_at_unless_already_decided,a_pending_filing_expires_by_its_filed_window_not_the_callers,the_exact_expiry_instant_is_expired_and_one_second_before_is_live,post_filed_token_admits_poll_including_notify_failure,status_text_round_trips_through_the_same_grammar run dag/test/claim/approval_decision_store_witness_test.dag keyring_loads_sixty_four_lower_hex_and_refuses_the_rest,keyring_absent_and_unreadable_are_two_refusals,stored_request_round_trips_through_json,a_record_missing_a_field_or_of_unknown_kind_is_unreadable_not_defaulted,a_newline_in_expires_at_is_unreadable_not_a_filed_request,capability_segment_round_trips_and_refuses_a_third_decision_word,fixture_tags_verify_over_the_stored_claims_and_not_across_decisions,identity_trust_is_derived_from_the_instance_bind,redemption_refuses_first_on_an_untrusted_identity_header run dag/test/claim/approval_request_submission_witness_test.dag a_signed_envelope_parses_back_to_the_same_request_and_tag,a_non_canonical_record_is_refused_before_it_is_verified,a_correctly_signed_submission_is_admitted_and_a_wrong_key_is_not_enrolled,a_slot_with_no_minted_capability_does_not_admit_poll,the_capability_key_cannot_file_a_request,issued_links_carry_tags_that_verify_over_the_stored_claims_for_their_own_decision_only,the_message_carries_purpose_both_links_and_the_destructive_mark,a_non_hex_capability_key_issues_no_link,status_text_reports_not_filed_for_an_absent_slot_and_unreadable_off_the_success_channel,a_filed_request_with_a_publisher_token_is_published_and_without_one_is_reported,an_occupied_slot_is_renotifiable_only_while_pending_with_the_same_bytes,a_capability_key_refusal_after_filing_names_the_filed_escalation -run dag/test/claim/approval_device_enrolment_code_witness_test.dag the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code,the_remote_argv_runs_the_release_verb_as_the_operator_user,the_mode_is_rostered_and_pinned_to_srv1,an_accepted_publish_with_an_unreadable_receipt_is_accepted_and_never_rendered_as_not_delivered,only_an_explicit_http_refusal_yields_delivery_refused,a_transport_refusal_or_server_error_is_indeterminate_and_says_the_code_may_have_arrived,the_enrolment_code_is_sixteen_supplied_octets_as_lowercase_hex +run dag/test/claim/approval_device_enrolment_code_witness_test.dag the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code,the_remote_argv_runs_the_release_verb_as_the_operator_user,the_mode_is_rostered_and_pinned_to_the_brokers_placed_host,an_accepted_publish_with_an_unreadable_receipt_is_accepted_and_never_rendered_as_not_delivered,only_an_explicit_http_refusal_yields_delivery_refused,a_transport_refusal_or_server_error_is_indeterminate_and_says_the_code_may_have_arrived,the_enrolment_code_is_sixteen_supplied_octets_as_lowercase_hex run dag/test/claim/approve_ios_project_witness_test.dag the_declared_release_stamps_the_building_xcodes_upgrade_check,the_committed_projection_carries_every_required_info_plist_key_and_setting,witness_a_complete_supplied_document_is_admitted,red_a_document_without_a_launch_screen_is_refused,red_a_document_without_a_face_id_purpose_is_refused,red_a_document_without_a_display_name_is_refused,red_a_dead_infoplist_key_setting_is_refused,red_a_stale_xcode_version_is_refused,red_a_test_target_without_an_info_plist_is_refused,red_a_document_without_supported_orientations_is_refused,the_ios_build_standing_names_its_missing_lane,red_an_omitted_or_substituted_apns_environment_is_refused,red_an_omitted_or_substituted_bundle_identifier_is_refused,red_an_omitted_or_substituted_swift_version_is_refused,red_an_omitted_or_xcconfig_substituted_server_host_is_refused,the_committed_projection_names_the_brokers_public_host,changing_the_tailnet_domain_changes_the_generated_host,an_undeclared_tailnet_domain_projects_an_unroutable_host,red_a_non_default_public_port_refuses_the_projection run dag/test/claim/fabric/fabric_writer_roster_witness_test.dag the_served_roster_holds_exactly_the_observed_logins_once,the_admission_admits_a_rostered_login_and_refuses_an_outsider_and_an_absent_login,the_admission_admits_a_rostered_local_peer_and_refuses_an_outsider_and_an_unattested_peer,the_door_reads_the_login_header_only_from_the_proxy_peer,the_production_served_door_refuses_a_login_no_reading_observed,the_door_admits_the_operator_and_the_ci_runner_locally_and_no_one_else,the_placed_store_areas_admit_no_writer_but_the_service run dag/test/claim/deployed_tree_remote_witness_test.dag an_unconfigured_remote_is_absent_not_unusable,a_path_that_is_not_a_repository_is_unusable_not_absent,absent_and_unusable_are_distinct_observations,a_zero_exit_with_no_output_is_refused_not_an_empty_url,an_observed_url_is_trimmed,an_unknown_exit_code_is_refused,the_config_key_is_projected_from_the_remote_name,the_desired_url_is_the_derived_public_clone_url,identity_separates_two_instances_on_one_host,only_an_exact_read_back_reports_success,the_remote_converges_after_its_inputs_and_before_the_belt run dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag the_belt_service_unit_matches_the_deployed_bytes,the_belt_service_unit_carries_the_per_tick_watchdog_bound,the_belt_timer_unit_matches_the_deployed_bytes,the_serve_unit_matches_the_deployed_bytes,the_slice_unit_matches_the_deployed_bytes,the_approval_broker_slice_unit_matches_the_deployed_bytes,the_approval_broker_unit_would_install_these_bytes,the_broker_inherits_the_roadmap_evaluation_caps,the_broker_and_the_roadmap_join_different_slices,the_broker_and_the_roadmap_bind_different_ports,the_broker_slice_bound_is_not_the_roadmap_deployment_bound,the_convergence_slice_carries_its_own_bounds,belt_service_runs_as_service_principal,belt_service_joins_deployment_slice,belt_service_works_in_repo_tree,belt_service_carries_user_manager_environment,belt_service_carries_spawn_workdir,belt_service_execstart_is_belt_tick_cli,belt_service_type_is_oneshot,belt_service_has_unit_directives,belt_service_is_not_installable,belt_timer_targets_belt_service,belt_timer_has_declared_cadence,belt_timer_has_description,belt_timer_is_installable_as_timers_target,serve_unit_runs_as_service_principal,serve_unit_joins_deployment_slice,serve_unit_works_in_repo_tree,serve_unit_carries_user_manager_environment,serve_unit_type_is_simple,serve_unit_restarts_on_failure,serve_unit_execstart_carries_admitted_entrypoint,serve_unit_has_unit_directives,serve_unit_is_installable_as_multi_user_target,slice_description_names_the_unit,slice_forbids_swap,slice_memory_bounds_are_the_deployments_own,slice_unit_is_not_installable,belt_service_user_goes_red_under_different_user,serve_unit_user_goes_red_under_different_user,serve_unit_execstart_goes_red_under_different_port,belt_service_working_directory_goes_red_under_different_root,the_convergence_oneshot_does_not_join_the_broker_slice,only_the_broker_unit_joins_the_broker_slice,both_served_slices_deny_swap_from_the_same_row run dag/test/claim/live_deploy/emit_test.dag witness_spec_targets_srv1,witness_spec_uses_apt_package_manager,witness_spec_listen_port_is_8080,witness_owned_vs_ensured_typed,witness_apply_provisions_attempt_state_root_owned,witness_attempt_state_mode_derives_to_owner_only,witness_retract_attempt_state_root_refuses_when_nonempty,witness_tailnet_domain_propagates_optional,witness_tailnet_url_absent_not_fabricated,witness_dependency_ensure_is_idempotent,witness_apply_emits_no_embedded_preflight_and_only_sudoers,witness_retract_preamble_acknowledges_shared_sudoers_install,the_convergence_slice_is_installed_before_the_convergence_restart,witness_apply_script_contains_systemd_and_tailscale,witness_apply_script_wires_memory_cap_deploy_consumer,retract_script_stays_within_ci_runner_grants,apply_script_stays_within_ci_runner_grants,git_metadata_file_transport_is_present_in_the_legacy_specimen_and_absent_from_emission,repository_convergence_is_ordered_after_binary_and_before_readback,witness_tree_sync_restart_diagnoses_on_failure,witness_retract_script_owned_only,witness_apply_script_has_no_node_server,witness_apply_script_has_no_frozen_page,witness_apply_script_excludes_readback_curls,witness_retract_owned_process_removes_serve_service,witness_execstart_carries_exactly_the_admitted_revision,witness_execstart_omitting_the_revision_is_not_the_emitted_line,witness_execstart_at_another_candidate_differs,witness_systemd_unit_emitted,witness_remote_mutation_service_op_realization_binds_emit_shell_phase_b_axis,witness_emit_shell_dissolution_trigger_is_scaffold,witness_dependency_graph_emit_frontier_binds_orch_emit_pipeline,witness_teardown_dependency_arm_refuses_golden_independent,witness_wholesale_refused_poison_carries_marker,witness_deploy_access_job_principal_refused_poison_golden_independent,witness_apply_script_access_ready_not_deploy_access_poison,a_twin_deployment_on_one_host_collides_with_nothing,twin_teardown_removes_nothing_production_owns,retract_is_the_reverse_of_what_this_spec_applies,production_apply_carries_its_own_handler_and_tree_sync_env,emitted_twin_apply_touches_none_of_productions_effects,twin_belt_unit_carries_the_twins_spawn_root_and_not_productions,production_belt_unit_carries_productions_spawn_root,emitted_twin_retract_removes_nothing_of_productions,shell_quote_escapes_an_embedded_apostrophe,production_apply_creates_productions_binary_parent_directory,twin_apply_creates_its_own_binary_parent_directory_and_not_productions,teardown_disposition_separates_owned_from_ensured,production_apply_serves_its_own_endpoint_and_scopes_no_path,twin_apply_serves_its_own_endpoint_and_not_productions,production_retract_offs_its_own_endpoint,twin_retract_offs_its_own_endpoint_and_not_productions,retract_is_not_emitted_without_an_observed_scoped_removal,fleet_converge_timer_not_installed_by_deploy_apply,emit_RED_unit_files_are_not_written_through_here_documents,emit_staged_unit_body_is_quoted_by_the_posix_authority,the_preamble_ensures_the_publication_principal_as_a_nologin_system_account,the_publication_member_emits_convergence_and_no_rsync,a_zero_mutation_plan_installs_publishes_and_restarts_nothing_of_the_release,the_full_plan_lands_its_members_in_the_deployments_order,the_fabric_storage_store_root_reaches_the_srv1_apply_path,the_fabric_storage_store_areas_reach_the_srv1_apply_path,the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_principal,the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone,the_placed_fabric_store_derives_entry_mode_0400,no_retract_can_remove_the_fabric_storage_store,a_binary_only_plan_installs_and_restarts_without_publishing_the_tree,a_matching_authorization_applies_the_staged_caps,a_withheld_decision_applies_no_caps_at_the_production_root,an_authorization_for_another_host_applies_no_caps,the_writer_reverts_only_the_authorized_target_units,the_dark_install_never_names_the_roadmap_unit,the_dark_install_execstart_carries_the_admitted_revision,a_twin_apply_neither_writes_nor_restarts_the_host_broker,the_owning_apply_does_write_and_restart_the_host_broker,both_install_paths_bind_the_brokers_one_port,a_twin_dark_install_refuses_rather_than_emitting_an_empty_stage,the_broker_unit_binds_loopback_and_runs_from_its_own_tree,the_dark_install_populates_the_broker_tree,the_dark_install_and_the_unit_name_the_same_candidate_binary,the_receipt_command_substitutes_the_digest_rather_than_splicing_it,the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it +run dag/test/claim/approval_ntfy_access_readback_witness_test.dag a_second_root_is_refused_at_every_effect_leaf,the_one_root_compiles_without_an_admission_refusal echo "=== MUTANT: MacBook bound back to srv1-live handler" sed -i 's/serve_function: "roadmap_serve_handle_macbook_local" as NonEmptyStr/serve_function: "roadmap_serve_handle" as NonEmptyStr/' dag/gunbc/roadmap/roadmap_dashboard_instance.dag run dag/test/claim/roadmap/serve_function_binding_witness_test.dag every_instances_serve_function_serves_that_instance diff --git a/dag/gunbc/auth/approval_broker_serve.dag b/dag/gunbc/auth/approval_broker_serve.dag index 5d21ff5581e..4c845e77a35 100644 --- a/dag/gunbc/auth/approval_broker_serve.dag +++ b/dag/gunbc/auth/approval_broker_serve.dag @@ -47,7 +47,9 @@ import gunbc.auth.approval_broker_endpoint { approval_broker_confirm_path_prefix, approval_device_request_path_template, approval_device_enrollment_path_template, - approval_broker_listen_host,, approval_broker_serving_base_url } + approval_broker_listen_host, + approval_broker_serving_base_url, +} import gunbc.auth.approval_device_wire { approval_device_enrol_path, approval_device_redeem_path, approval_device_pending_path, approval_device_push_path } import gunbc.auth.approval_device_routes { DeviceRouteResponse, diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index f08ae7a68c5..e2d847bfea3 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -2744,7 +2744,7 @@ fn fleet_converge_microvm_runner_group_ensure_receipt_upload_step() -> Step { fn fleet_converge_approval_keyring_converge_step() -> Step { RunStep { - name: Present { value: "Materialize approval MAC keys on srv1 (SM pinned -> sha256 prefix -> /etc/gunbc-roadmap 0640)" }, + name: Present { value: "Materialize approval MAC keys on the approval broker host (SM pinned -> sha256 prefix -> /etc/gunbc-roadmap 0640)" }, id: Present { value: "approval_keyring_converge" }, run: gunbc_ci_approval_keyring_converge_invoke(), shell: none, @@ -2760,14 +2760,14 @@ fn fleet_converge_approval_keyring_converge_step() -> Step { } // THE ENROLMENT-CODE STEP: one remote invocation of gunbc.auth.approval_device_enrolment_code -// issue_device_enrolment_code on srv1 as the operator's user, from the release the dispatch names; +// issue_device_enrolment_code on the approval broker host as the operator's user, from the release the dispatch names; // the step prints the delivery receipt and NEVER the code (decision msg_a1d8d1b9 -- the code goes // over the operator's ntfy topic, whose deny-by-default policy is DECLARED and not yet read back: // gunbc.auth.approval_ntfy_deployment's access rows are NoDischargeSiteModeled). No WIF token: the verb needs no secret the host does not // already hold. fn fleet_converge_approval_device_enrolment_code_issue_step() -> Step { RunStep { - name: Present { value: "Issue a device enrolment code on srv1 and deliver it to the operator channel (receipt only in this log)" }, + name: Present { value: "Issue a device enrolment code on the approval broker host and deliver it to the operator channel (receipt only in this log)" }, id: Present { value: "approval_device_enrolment_code_issue" }, run: gunbc_ci_approval_device_enrolment_code_issue_invoke(), shell: none, @@ -3136,7 +3136,7 @@ data fleet_converge_approval_broker_dark_install_step_if: String = fleet_converg fn fleet_converge_approval_broker_dark_install_step() -> Step { RunStep { - name: Present { value: "Install the approval broker dark on srv1 (additive unit + slice; does not restart gunbc-roadmap.service)" }, + name: Present { value: "Install the approval broker dark on the approval broker host (additive unit + slice; does not restart gunbc-roadmap.service)" }, id: Present { value: "approval_broker_dark_install" }, run: gunbc_ci_approval_broker_dark_install_invoke(), shell: none, From a9eded98930d7a04add304a01aa93232c9e72eb8 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 12:18:46 +0000 Subject: [PATCH 20/48] fleet-converge.yml regenerated (main_wet_one; byte-identical to generator output faf23241); serve_function control passes request_headers Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2run.sh | 21 ------------------- .github/workflows/fleet-converge.yml | 6 +++--- .../serve_function_binding_witness_test.dag | 18 ++++++++-------- 3 files changed, 12 insertions(+), 33 deletions(-) delete mode 100644 .d2run.sh diff --git a/.d2run.sh b/.d2run.sh deleted file mode 100644 index 691bb3a87cf..00000000000 --- a/.d2run.sh +++ /dev/null @@ -1,21 +0,0 @@ -set -u -cargo build --release -p v1-compiler --bin claim_batch --bin v1_src_dag_parse 2>&1 | tail -1 -mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 21474836480 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs -./target/release/v1_src_dag_parse 2>&1 | grep -v "parse-clean" | head -20 -run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^FAIL" /tmp/cb.out; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)|^(PASS|FAIL)" | tail -12; fi; echo rc=$rc; } -run dag/test/claim/deployment_risk_witness_test.dag a_test_deployment_refuses_rather_than_resolving_the_prod_bindings,the_prod_role_holder_resolves_the_prod_topic_and_app,each_class_resolves_only_its_own_set,no_prod_role_makes_the_former_holder_test_risk_and_it_refuses,moving_the_role_moves_the_class_and_nothing_else_decides_it,exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace,the_wrong_instance_failure_mode_row_cites_this_red,moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses,a_prod_role_holder_with_no_instance_refuses_as_unrealized,a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move,no_prod_role_with_a_holder_observed_refuses_as_a_removal,an_unobservable_roster_host_refuses_rather_than_reading_as_absent,nothing_observed_and_a_valid_holder_installs_on_that_holder,the_observed_holder_equal_to_the_desired_one_is_a_noop,two_observed_holders_or_an_ownerless_broker_refuse,the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop,the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_refusal -run dag/test/claim/roadmap/serve_function_binding_witness_test.dag every_instances_serve_function_serves_that_instance -run dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag dashboard_source_transport_argv_is_exact_and_atomic,dashboard_local_origin_ref_translation_is_exact,dashboard_provider_runtime_and_auth_share_one_state_binding,dashboard_target_preflight_derives_dispatch_platform_dependencies,dashboard_health_requires_intended_instance_identity,dashboard_plan_is_end_to_end_and_production_disjoint,dashboard_refusal_receipt_is_typed_and_located,dashboard_dirty_controller_source_refuses_before_apply,dashboard_bootstrap_parent_writability_is_a_preflight_fact,dashboard_unreachable_root_refuses_before_choosing_a_parent,protected_sibling_readings_never_equate_absent_with_observed,a_host_without_a_protected_sibling_refuses_nothing_and_reads_nothing,protected_siblings_follow_the_prod_role_selection_within_the_host,sibling_isolation_still_requires_the_instance_to_own_its_paths -run dag/test/claim/approval_request_client_witness_test.dag submit_and_status_urls_follow_the_prod_role_holders_listen_bind,submit_body_nests_the_stored_request_as_a_json_string,status_words_are_closed,injected_kv_lines_in_expires_at_are_unreadable_not_approved,poll_expires_after_expires_at_unless_already_decided,a_pending_filing_expires_by_its_filed_window_not_the_callers,the_exact_expiry_instant_is_expired_and_one_second_before_is_live,post_filed_token_admits_poll_including_notify_failure,status_text_round_trips_through_the_same_grammar -run dag/test/claim/approval_decision_store_witness_test.dag keyring_loads_sixty_four_lower_hex_and_refuses_the_rest,keyring_absent_and_unreadable_are_two_refusals,stored_request_round_trips_through_json,a_record_missing_a_field_or_of_unknown_kind_is_unreadable_not_defaulted,a_newline_in_expires_at_is_unreadable_not_a_filed_request,capability_segment_round_trips_and_refuses_a_third_decision_word,fixture_tags_verify_over_the_stored_claims_and_not_across_decisions,identity_trust_is_derived_from_the_instance_bind,redemption_refuses_first_on_an_untrusted_identity_header -run dag/test/claim/approval_request_submission_witness_test.dag a_signed_envelope_parses_back_to_the_same_request_and_tag,a_non_canonical_record_is_refused_before_it_is_verified,a_correctly_signed_submission_is_admitted_and_a_wrong_key_is_not_enrolled,a_slot_with_no_minted_capability_does_not_admit_poll,the_capability_key_cannot_file_a_request,issued_links_carry_tags_that_verify_over_the_stored_claims_for_their_own_decision_only,the_message_carries_purpose_both_links_and_the_destructive_mark,a_non_hex_capability_key_issues_no_link,status_text_reports_not_filed_for_an_absent_slot_and_unreadable_off_the_success_channel,a_filed_request_with_a_publisher_token_is_published_and_without_one_is_reported,an_occupied_slot_is_renotifiable_only_while_pending_with_the_same_bytes,a_capability_key_refusal_after_filing_names_the_filed_escalation -run dag/test/claim/approval_device_enrolment_code_witness_test.dag the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code,the_remote_argv_runs_the_release_verb_as_the_operator_user,the_mode_is_rostered_and_pinned_to_the_brokers_placed_host,an_accepted_publish_with_an_unreadable_receipt_is_accepted_and_never_rendered_as_not_delivered,only_an_explicit_http_refusal_yields_delivery_refused,a_transport_refusal_or_server_error_is_indeterminate_and_says_the_code_may_have_arrived,the_enrolment_code_is_sixteen_supplied_octets_as_lowercase_hex -run dag/test/claim/approve_ios_project_witness_test.dag the_declared_release_stamps_the_building_xcodes_upgrade_check,the_committed_projection_carries_every_required_info_plist_key_and_setting,witness_a_complete_supplied_document_is_admitted,red_a_document_without_a_launch_screen_is_refused,red_a_document_without_a_face_id_purpose_is_refused,red_a_document_without_a_display_name_is_refused,red_a_dead_infoplist_key_setting_is_refused,red_a_stale_xcode_version_is_refused,red_a_test_target_without_an_info_plist_is_refused,red_a_document_without_supported_orientations_is_refused,the_ios_build_standing_names_its_missing_lane,red_an_omitted_or_substituted_apns_environment_is_refused,red_an_omitted_or_substituted_bundle_identifier_is_refused,red_an_omitted_or_substituted_swift_version_is_refused,red_an_omitted_or_xcconfig_substituted_server_host_is_refused,the_committed_projection_names_the_brokers_public_host,changing_the_tailnet_domain_changes_the_generated_host,an_undeclared_tailnet_domain_projects_an_unroutable_host,red_a_non_default_public_port_refuses_the_projection -run dag/test/claim/fabric/fabric_writer_roster_witness_test.dag the_served_roster_holds_exactly_the_observed_logins_once,the_admission_admits_a_rostered_login_and_refuses_an_outsider_and_an_absent_login,the_admission_admits_a_rostered_local_peer_and_refuses_an_outsider_and_an_unattested_peer,the_door_reads_the_login_header_only_from_the_proxy_peer,the_production_served_door_refuses_a_login_no_reading_observed,the_door_admits_the_operator_and_the_ci_runner_locally_and_no_one_else,the_placed_store_areas_admit_no_writer_but_the_service -run dag/test/claim/deployed_tree_remote_witness_test.dag an_unconfigured_remote_is_absent_not_unusable,a_path_that_is_not_a_repository_is_unusable_not_absent,absent_and_unusable_are_distinct_observations,a_zero_exit_with_no_output_is_refused_not_an_empty_url,an_observed_url_is_trimmed,an_unknown_exit_code_is_refused,the_config_key_is_projected_from_the_remote_name,the_desired_url_is_the_derived_public_clone_url,identity_separates_two_instances_on_one_host,only_an_exact_read_back_reports_success,the_remote_converges_after_its_inputs_and_before_the_belt -run dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag the_belt_service_unit_matches_the_deployed_bytes,the_belt_service_unit_carries_the_per_tick_watchdog_bound,the_belt_timer_unit_matches_the_deployed_bytes,the_serve_unit_matches_the_deployed_bytes,the_slice_unit_matches_the_deployed_bytes,the_approval_broker_slice_unit_matches_the_deployed_bytes,the_approval_broker_unit_would_install_these_bytes,the_broker_inherits_the_roadmap_evaluation_caps,the_broker_and_the_roadmap_join_different_slices,the_broker_and_the_roadmap_bind_different_ports,the_broker_slice_bound_is_not_the_roadmap_deployment_bound,the_convergence_slice_carries_its_own_bounds,belt_service_runs_as_service_principal,belt_service_joins_deployment_slice,belt_service_works_in_repo_tree,belt_service_carries_user_manager_environment,belt_service_carries_spawn_workdir,belt_service_execstart_is_belt_tick_cli,belt_service_type_is_oneshot,belt_service_has_unit_directives,belt_service_is_not_installable,belt_timer_targets_belt_service,belt_timer_has_declared_cadence,belt_timer_has_description,belt_timer_is_installable_as_timers_target,serve_unit_runs_as_service_principal,serve_unit_joins_deployment_slice,serve_unit_works_in_repo_tree,serve_unit_carries_user_manager_environment,serve_unit_type_is_simple,serve_unit_restarts_on_failure,serve_unit_execstart_carries_admitted_entrypoint,serve_unit_has_unit_directives,serve_unit_is_installable_as_multi_user_target,slice_description_names_the_unit,slice_forbids_swap,slice_memory_bounds_are_the_deployments_own,slice_unit_is_not_installable,belt_service_user_goes_red_under_different_user,serve_unit_user_goes_red_under_different_user,serve_unit_execstart_goes_red_under_different_port,belt_service_working_directory_goes_red_under_different_root,the_convergence_oneshot_does_not_join_the_broker_slice,only_the_broker_unit_joins_the_broker_slice,both_served_slices_deny_swap_from_the_same_row -run dag/test/claim/live_deploy/emit_test.dag witness_spec_targets_srv1,witness_spec_uses_apt_package_manager,witness_spec_listen_port_is_8080,witness_owned_vs_ensured_typed,witness_apply_provisions_attempt_state_root_owned,witness_attempt_state_mode_derives_to_owner_only,witness_retract_attempt_state_root_refuses_when_nonempty,witness_tailnet_domain_propagates_optional,witness_tailnet_url_absent_not_fabricated,witness_dependency_ensure_is_idempotent,witness_apply_emits_no_embedded_preflight_and_only_sudoers,witness_retract_preamble_acknowledges_shared_sudoers_install,the_convergence_slice_is_installed_before_the_convergence_restart,witness_apply_script_contains_systemd_and_tailscale,witness_apply_script_wires_memory_cap_deploy_consumer,retract_script_stays_within_ci_runner_grants,apply_script_stays_within_ci_runner_grants,git_metadata_file_transport_is_present_in_the_legacy_specimen_and_absent_from_emission,repository_convergence_is_ordered_after_binary_and_before_readback,witness_tree_sync_restart_diagnoses_on_failure,witness_retract_script_owned_only,witness_apply_script_has_no_node_server,witness_apply_script_has_no_frozen_page,witness_apply_script_excludes_readback_curls,witness_retract_owned_process_removes_serve_service,witness_execstart_carries_exactly_the_admitted_revision,witness_execstart_omitting_the_revision_is_not_the_emitted_line,witness_execstart_at_another_candidate_differs,witness_systemd_unit_emitted,witness_remote_mutation_service_op_realization_binds_emit_shell_phase_b_axis,witness_emit_shell_dissolution_trigger_is_scaffold,witness_dependency_graph_emit_frontier_binds_orch_emit_pipeline,witness_teardown_dependency_arm_refuses_golden_independent,witness_wholesale_refused_poison_carries_marker,witness_deploy_access_job_principal_refused_poison_golden_independent,witness_apply_script_access_ready_not_deploy_access_poison,a_twin_deployment_on_one_host_collides_with_nothing,twin_teardown_removes_nothing_production_owns,retract_is_the_reverse_of_what_this_spec_applies,production_apply_carries_its_own_handler_and_tree_sync_env,emitted_twin_apply_touches_none_of_productions_effects,twin_belt_unit_carries_the_twins_spawn_root_and_not_productions,production_belt_unit_carries_productions_spawn_root,emitted_twin_retract_removes_nothing_of_productions,shell_quote_escapes_an_embedded_apostrophe,production_apply_creates_productions_binary_parent_directory,twin_apply_creates_its_own_binary_parent_directory_and_not_productions,teardown_disposition_separates_owned_from_ensured,production_apply_serves_its_own_endpoint_and_scopes_no_path,twin_apply_serves_its_own_endpoint_and_not_productions,production_retract_offs_its_own_endpoint,twin_retract_offs_its_own_endpoint_and_not_productions,retract_is_not_emitted_without_an_observed_scoped_removal,fleet_converge_timer_not_installed_by_deploy_apply,emit_RED_unit_files_are_not_written_through_here_documents,emit_staged_unit_body_is_quoted_by_the_posix_authority,the_preamble_ensures_the_publication_principal_as_a_nologin_system_account,the_publication_member_emits_convergence_and_no_rsync,a_zero_mutation_plan_installs_publishes_and_restarts_nothing_of_the_release,the_full_plan_lands_its_members_in_the_deployments_order,the_fabric_storage_store_root_reaches_the_srv1_apply_path,the_fabric_storage_store_areas_reach_the_srv1_apply_path,the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_principal,the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone,the_placed_fabric_store_derives_entry_mode_0400,no_retract_can_remove_the_fabric_storage_store,a_binary_only_plan_installs_and_restarts_without_publishing_the_tree,a_matching_authorization_applies_the_staged_caps,a_withheld_decision_applies_no_caps_at_the_production_root,an_authorization_for_another_host_applies_no_caps,the_writer_reverts_only_the_authorized_target_units,the_dark_install_never_names_the_roadmap_unit,the_dark_install_execstart_carries_the_admitted_revision,a_twin_apply_neither_writes_nor_restarts_the_host_broker,the_owning_apply_does_write_and_restart_the_host_broker,both_install_paths_bind_the_brokers_one_port,a_twin_dark_install_refuses_rather_than_emitting_an_empty_stage,the_broker_unit_binds_loopback_and_runs_from_its_own_tree,the_dark_install_populates_the_broker_tree,the_dark_install_and_the_unit_name_the_same_candidate_binary,the_receipt_command_substitutes_the_digest_rather_than_splicing_it,the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it -run dag/test/claim/approval_ntfy_access_readback_witness_test.dag a_second_root_is_refused_at_every_effect_leaf,the_one_root_compiles_without_an_admission_refusal -echo "=== MUTANT: MacBook bound back to srv1-live handler" -sed -i 's/serve_function: "roadmap_serve_handle_macbook_local" as NonEmptyStr/serve_function: "roadmap_serve_handle" as NonEmptyStr/' dag/gunbc/roadmap/roadmap_dashboard_instance.dag -run dag/test/claim/roadmap/serve_function_binding_witness_test.dag every_instances_serve_function_serves_that_instance diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index fd042b81247..faf232413c1 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -1334,7 +1334,7 @@ jobs: retention-days: 30 if: always() && github.event.inputs.mode == 'r2_object_write_mint' timeout-minutes: 10 - - name: Materialize approval MAC keys on srv1 (SM pinned -> sha256 prefix -> /etc/gunbc-roadmap 0640) + - name: Materialize approval MAC keys on the approval broker host (SM pinned -> sha256 prefix -> /etc/gunbc-roadmap 0640) id: approval_keyring_converge run: |- # 🟡 dissolve-on: gunbc_ci_approval_keyring_converge_invoke - orch-emitted foreign-executor (GitHub Actions run:) credential runner: WIF access token by env, two PINNED secret versions (capability and submission MAC keys) fetched over curl, 0600 files under RUNNER_TEMP with a trap armed BEFORE the credentials touch disk. The pipeline steps are modeled (gunbc_ci_approval_keyring_converge_prelude) but the runner transport itself remains hand-shell; DISSOLVES WHEN the runner is authored as v2.extdeps.languages.bash_build nodes (typed, grammar-quoted words; command substitution; assignment; pipe; if/test; redirect; || true) emitted by v2.workflow.bash_emit bash_emit_stmts -- a route AVAILABLE today (#12422 retired the sibling pack runner on it), so this migration is unauthored, not blocked on a missing capability; set -euo pipefail, umask, export and trap are ordinary commands on that route, and the trap body is itself built from nodes and passed as one grammar-quoted word, so no new orchestration intent is a prerequisite @@ -1363,7 +1363,7 @@ jobs: FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} if: github.event.inputs.mode == 'approval_keyring_converge' timeout-minutes: 5 - - name: Issue a device enrolment code on srv1 and deliver it to the operator channel (receipt only in this log) + - name: Issue a device enrolment code on the approval broker host and deliver it to the operator channel (receipt only in this log) id: approval_device_enrolment_code_issue run: |- ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) @@ -2698,7 +2698,7 @@ jobs: env: RELEASE_BINS_KEY: ${{ needs.build.outputs.release_bins_key }} timeout-minutes: 5 - - name: Install the approval broker dark on srv1 (additive unit + slice; does not restart gunbc-roadmap.service) + - name: Install the approval broker dark on the approval broker host (additive unit + slice; does not restart gunbc-roadmap.service) id: approval_broker_dark_install run: |- ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) diff --git a/dag/test/claim/roadmap/serve_function_binding_witness_test.dag b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag index 7764d45c6b4..2aa9e858eb4 100644 --- a/dag/test/claim/roadmap/serve_function_binding_witness_test.dag +++ b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag @@ -2,7 +2,7 @@ module test.claim.serve_function_binding_witness_test import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.types { Bool, String, NonEmptyStr, List, CommitSha } -import extdeps.http.server { ServeWireResponse } +import extdeps.http.server { ServeWireResponse, ServeRequestHeader } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, public_builtin_dashboard_instances, @@ -29,17 +29,17 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // fails, and a row naming a handler this table does not carry fails as unbound. type ServeHandlerRow { name: String - handle: fn(String, String, String, String, CommitSha) -> ServeWireResponse + handle: fn(String, String, String, String, List, CommitSha) -> ServeWireResponse } fn declared_serve_handlers() -> List { [ - ServeHandlerRow { name: "roadmap_serve_handle", handle: (m, p, b, t, r) => roadmap_serve_handle(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, - ServeHandlerRow { name: "roadmap_serve_handle_macbook_local", handle: (m, p, b, t, r) => roadmap_serve_handle_macbook_local(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, - ServeHandlerRow { name: "roadmap_serve_handle_srv1_lab", handle: (m, p, b, t, r) => roadmap_serve_handle_srv1_lab(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, - ServeHandlerRow { name: "roadmap_serve_handle_srv2_preview", handle: (m, p, b, t, r) => roadmap_serve_handle_srv2_preview(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, - ServeHandlerRow { name: "roadmap_serve_handle_srv2_deploy", handle: (m, p, b, t, r) => roadmap_serve_handle_srv2_deploy(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, - ServeHandlerRow { name: "roadmap_serve_handle_srv2_lab", handle: (m, p, b, t, r) => roadmap_serve_handle_srv2_lab(method: m, path: p, body: b, tailscale_identity: t, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle", handle: (m, p, b, t, h, r) => roadmap_serve_handle(method: m, path: p, body: b, tailscale_identity: t, request_headers: h, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_macbook_local", handle: (m, p, b, t, h, r) => roadmap_serve_handle_macbook_local(method: m, path: p, body: b, tailscale_identity: t, request_headers: h, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv1_lab", handle: (m, p, b, t, h, r) => roadmap_serve_handle_srv1_lab(method: m, path: p, body: b, tailscale_identity: t, request_headers: h, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv2_preview", handle: (m, p, b, t, h, r) => roadmap_serve_handle_srv2_preview(method: m, path: p, body: b, tailscale_identity: t, request_headers: h, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv2_deploy", handle: (m, p, b, t, h, r) => roadmap_serve_handle_srv2_deploy(method: m, path: p, body: b, tailscale_identity: t, request_headers: h, release_revision: r) }, + ServeHandlerRow { name: "roadmap_serve_handle_srv2_lab", handle: (m, p, b, t, h, r) => roadmap_serve_handle_srv2_lab(method: m, path: p, body: b, tailscale_identity: t, request_headers: h, release_revision: r) }, ] } @@ -48,7 +48,7 @@ fn instance_serves_itself(instance: HostDashboardInstance) -> Bool { count(bound) == 1 && all(bound, h => { let serve = h.handle - let answer = serve("GET", "/instance.json", "", "", "0000000000000000000000000000000000000000" as CommitSha) + let answer = serve("GET", "/instance.json", "", "", [], "0000000000000000000000000000000000000000" as CommitSha) answer.status == 200 && starts_with(s: answer.body, prefix: dashboard_instance_health_identity_prefix(instance_id: instance.instance_id as String)) }) From ab8a047080f8bdb8f2c2171a7cd5ccf466e881c7 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 13:30:06 +0000 Subject: [PATCH 21/48] emit_test: the #12912 preamble claim's DeploymentSpec literal carries role_singletons (census: the only literal without it) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/test/claim/live_deploy/emit_test.dag | 1 + 1 file changed, 1 insertion(+) diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index 2f89ec5765f..688a359a75b 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -408,6 +408,7 @@ test fn witness_apply_pipeline_carries_the_preamble_after_its_frame() -> Bool { approval_broker: real.approval_broker, names: real.names, instance_id: real.instance_id, + role_singletons: real.role_singletons, } match ci_deploy_srv1_access_or_refusal() { DeployAccessReady { access: a } => From 776c407cd6c60d8d3315e830722caa521b418028 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 14:17:42 +0000 Subject: [PATCH 22/48] role_singleton_convergence: enumerate EntryPresence arms (floor NonFoldResidueRosterDiverged: wildcard over a closed coproduct) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/live_deploy/role_singleton_convergence.dag | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index 55c246ff7bb..482a9556aa0 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -171,8 +171,8 @@ fn host_role_singleton_reading(host: NonEmptyStr, rows: List, Present { value: cause } => HostRoleSingletonsUnobservable { host: host, cause: cause } Absent => HostRoleSingletonsRead { host: host, - holders: stores |> filter(s => match s.presence { EntryPresent => true _ => false }) |> map(s => s.deployment), - broker_present: match broker { EntryPresent => true _ => false }, + holders: stores |> filter(s => match s.presence { EntryPresent => true EntryAbsent => false EntryPresenceIndeterminate { cause: _ } => false }) |> map(s => s.deployment), + broker_present: match broker { EntryPresent => true EntryAbsent => false EntryPresenceIndeterminate { cause: _ } => false }, } } } From b5c62d98d5c64d75020445cdb8c2b4a241d067d4 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 14:45:16 +0000 Subject: [PATCH 23/48] D2 re-rule blockers 1-5: admitted spec token, desired-side refusals, multiplicity-preserving observer behind a reading source, store ownership marker with AdoptUnmarkedHolder, actuated holder join Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/auth/approval_broker_placement.dag | 3 +- .../approval_device_enrolment_code_issue.dag | 3 +- dag/gunbc/auth/approval_request_client.dag | 3 +- dag/gunbc/fabric/fabric_event_log.dag | 3 +- dag/gunbc/fabric/fabric_storage_binding.dag | 10 +- dag/gunbc/fabric/fabric_storage_placement.dag | 17 +- dag/gunbc/fabric/fabric_storage_serve.dag | 3 +- .../fabric/fabric_writer_identity_observe.dag | 4 +- dag/gunbc/fabric/fabric_writer_roster.dag | 4 +- dag/gunbc/fleet/fleet_converge_workflow.dag | 3 +- dag/gunbc/live_deploy/apply.dag | 51 ++- dag/gunbc/live_deploy/desired.dag | 35 ++ dag/gunbc/live_deploy/emit.dag | 3 + .../role_singleton_convergence.dag | 423 ++++++++++++++---- dag/gunbc/live_deploy/spec.dag | 29 +- .../roadmap/roadmap_dashboard_instance.dag | 15 +- .../claim/deployment_risk_witness_test.dag | 259 +++++++++-- .../devboot_subject_identity_witness_test.dag | 2 + dag/test/claim/live_deploy/emit_test.dag | 3 +- 19 files changed, 685 insertions(+), 188 deletions(-) diff --git a/dag/gunbc/auth/approval_broker_placement.dag b/dag/gunbc/auth/approval_broker_placement.dag index 71fd358f6da..a21f9edbefa 100644 --- a/dag/gunbc/auth/approval_broker_placement.dag +++ b/dag/gunbc/auth/approval_broker_placement.dag @@ -1,5 +1,6 @@ module gunbc.auth.approval_broker_placement +import gunbc.live_deploy.desired { prod_role_actuated_realization } import std.types { NonEmptyStr } import gunbc.roadmap_dashboard_instance { ProdRoleHolder, @@ -65,5 +66,5 @@ fn approval_broker_base_url_under(realization: ProdRoleRealization) -> ApprovalB } fn approval_broker_public_host_from(domain: NonEmptyStr?) -> ApprovalBrokerAddress { - approval_broker_public_host_under(realization: prod_role_realization(), domain: domain) + approval_broker_public_host_under(realization: prod_role_actuated_realization(), domain: domain) } diff --git a/dag/gunbc/auth/approval_device_enrolment_code_issue.dag b/dag/gunbc/auth/approval_device_enrolment_code_issue.dag index 87946ae0cd7..ff5a1c1f950 100644 --- a/dag/gunbc/auth/approval_device_enrolment_code_issue.dag +++ b/dag/gunbc/auth/approval_device_enrolment_code_issue.dag @@ -1,5 +1,6 @@ module gunbc.auth.approval_device_enrolment_code_issue +import gunbc.live_deploy.desired { prod_role_actuated_realization } import std.types { NonEmptyStr, String, List, Bool, CommitSha } import std.algebra { trim } import std.process { ProcessExit, ExitSuccess, ExitFailure, exit_failure } @@ -166,7 +167,7 @@ fn approval_device_enrolment_code_issue_wet() -> CliWireResponse { match actions_variable_read(name: fleet_converge_expected_host_env_name) { ActionsVariableAbsent { variable: v, detail: d } => CliWireUnprintable { cause: ("approval device enrolment code: " + v + " " + d + "; one host per run, named by the dispatch") as NonEmptyStr } - ActionsVariablePresent { value: host } => match enrolment_code_issue_placement_under(realization: prod_role_realization()) { + ActionsVariablePresent { value: host } => match enrolment_code_issue_placement_under(realization: prod_role_actuated_realization()) { EnrolmentCodeIssueUnplaced { detail } => CliWireUnprintable { cause: detail } EnrolmentCodeIssuePlaced { host: broker_host, base_url } => match enrolment_code_issue_refuses_off_broker_host(host: host, broker_host: broker_host) { diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag index ebcb67a9607..3c1842b14aa 100644 --- a/dag/gunbc/auth/approval_request_client.dag +++ b/dag/gunbc/auth/approval_request_client.dag @@ -1,5 +1,6 @@ module gunbc.auth.approval_request_client +import gunbc.live_deploy.desired { prod_role_actuated_realization } import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } import std.scoped_authorization { AuthorizationRequest } import extdeps.crypto.mac { @@ -46,7 +47,7 @@ fn approval_loopback_origin_under(realization: ProdRoleRealization) -> ApprovalL } fn approval_loopback_origin() -> ApprovalLoopbackOrigin { - approval_loopback_origin_under(realization: prod_role_realization()) + approval_loopback_origin_under(realization: prod_role_actuated_realization()) } fn approval_loopback_origin_refusal_detail(cause: ProdRoleRealizationRefusal) -> NonEmptyStr { diff --git a/dag/gunbc/fabric/fabric_event_log.dag b/dag/gunbc/fabric/fabric_event_log.dag index b0bb8ddeeb3..10e68c3ed8e 100644 --- a/dag/gunbc/fabric/fabric_event_log.dag +++ b/dag/gunbc/fabric/fabric_event_log.dag @@ -1,6 +1,6 @@ module gunbc.fabric_event_log -import gunbc.fabric_storage_binding { fabric_storage_binding_for } +import gunbc.fabric_storage_binding { fabric_storage_binding_for, fabric_storage_placement } import std.types { String, Bool, Int, NonEmptyStr, List, EpochSecs } import std.nat { Nat, nat_range_inclusive } @@ -14,7 +14,6 @@ import std.fabric_storage { FabricHeadAdvanced, FabricHeadMoved, FabricHeadAdvanceRefused, FabricClosureRead, FabricClosureBudgetExhausted, FabricClosureRefused, FabricStoredObject, } -import gunbc.fabric_storage_placement { fabric_storage_placement } import gunbc.fabric_storage_client { FabricStorageBinding, fabric_storage_head, fabric_storage_put, fabric_storage_advance, fabric_storage_closure } import product.capacity.pool { Pool, PoolReading, PoolRead, PoolReadingRefused, pool_reading_at, PoolOutcome, PoolAdvanced, PoolRefused, pool_release, pool_refusal_wire } import product.capacity.event_chain { diff --git a/dag/gunbc/fabric/fabric_storage_binding.dag b/dag/gunbc/fabric/fabric_storage_binding.dag index 8875d2124b5..01b361971e5 100644 --- a/dag/gunbc/fabric/fabric_storage_binding.dag +++ b/dag/gunbc/fabric/fabric_storage_binding.dag @@ -1,12 +1,20 @@ module gunbc.fabric_storage_binding +import gunbc.live_deploy.desired { prod_role_actuated_realization } import std.types { String, NonEmptyStr } import gunbc.fabric_storage_address { fabric_storage_state_suffix, fabric_storage_route_prefix } -import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlacement, FabricStoragePlaced, FabricStorageUnplaced, FabricStoragePlacementRefused } +import gunbc.fabric_storage_placement { fabric_storage_placement_under, FabricStoragePlacement, FabricStoragePlaced, FabricStorageUnplaced, FabricStoragePlacementRefused } import gunbc.roadmap_dashboard_instance { prod_role_realization_refusal_detail } import product.placement_supply { HostIdentity, host_identity_eq } import gunbc.fabric_storage_client { FabricStorageBinding, FabricStorageBindingUnplaced, FabricStorageBindingRefused, FabricStorageDoorSocket, FabricStorageServed } +// THE STORE'S RUNTIME PLACEMENT, over the prod role as something that can be actuated +// (gunbc.live_deploy.desired prod_role_actuated_realization): a holder with no desired deployment row +// refuses here rather than placing the store on a host nothing installs it on. +fn fabric_storage_placement() -> FabricStoragePlacement { + fabric_storage_placement_under(realization: prod_role_actuated_realization()) +} + fn fabric_storage_binding_for(placement: FabricStoragePlacement, executor: HostIdentity) -> FabricStorageBinding { match placement { FabricStorageUnplaced => FabricStorageBindingUnplaced diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index 636d069ad9a..42d92500d23 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -6,7 +6,7 @@ import std.types { String, NonEmptyStr, FilePath, Int, Bool, Port, List } import product.placement_supply { HostIdentity } import gunbc.fleet_intent_network { fleet_intent_network } import gunbc.roadmap_dashboard_instance { - HostDashboardInstance, dashboard_instance_fabric_storage_root, dashboard_instance_child, + HostDashboardInstance, dashboard_instance_fabric_storage_root, dashboard_instance_child, dashboard_ownership_marker_in, ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, } import std.effect_grant { Read, Write, Execute } @@ -77,6 +77,15 @@ fn fabric_storage_store_root(instance: HostDashboardInstance) -> NonEmptyStr { dashboard_instance_fabric_storage_root(instance: instance) as String as NonEmptyStr } +// THE STORE'S OWNERSHIP MARKER, by the instance ownership-marker rule +// (gunbc.roadmap_dashboard_instance dashboard_ownership_marker_in) applied to the store root. The +// store root, auth state and door directories are Ensured and no retract removes them, so a unit file +// alone cannot say whether a host holds a store; the marker can, and the role-singleton observation +// reads it (gunbc.live_deploy.role_singleton_convergence). +fn fabric_storage_ownership_marker_path(instance: HostDashboardInstance) -> NonEmptyStr { + dashboard_ownership_marker_in(root: dashboard_instance_fabric_storage_root(instance: instance), instance_id: instance.instance_id) as String as NonEmptyStr +} + fn fabric_storage_endpoint_for(host: HostIdentity) -> NonEmptyStr? { match fleet_intent_network.tailnet_domain { Absent => none @@ -103,9 +112,9 @@ fn fabric_storage_placement_under(realization: ProdRoleRealization) -> FabricSto } } -fn fabric_storage_placement() -> FabricStoragePlacement { - fabric_storage_placement_under(realization: prod_role_realization()) -} +// THE RUNTIME PLACEMENT (fabric_storage_placement, over the ACTUATED realization) lives in +// gunbc.fabric_storage_binding: this module is imported by gunbc.live_deploy.spec, so it cannot see +// the desired roster that decides whether the holder can be actuated. // ── WHO WRITES THE STORE, AND SO WHAT ITS DIRECTORIES ARE ──────────────────────────────────────── // diff --git a/dag/gunbc/fabric/fabric_storage_serve.dag b/dag/gunbc/fabric/fabric_storage_serve.dag index dcbd7ba3121..123ccfc8479 100644 --- a/dag/gunbc/fabric/fabric_storage_serve.dag +++ b/dag/gunbc/fabric/fabric_storage_serve.dag @@ -1,5 +1,6 @@ module gunbc.fabric_storage_serve +import gunbc.fabric_storage_binding { fabric_storage_placement } import gunbc.fabric_storage_protocol { fabric_storage_closure_bound_cap, text_response, ok, words, trim_line, word, head_name, malformed, fabric_storage_respond, AdvanceRequest, advance_request } import gunbc.fabric_storage_state_serve { fabric_storage_state_serve_over } @@ -20,7 +21,7 @@ import gunbc.fabric_storage_file_store { import gunbc.fabric_storage_wire { FabricDoorPrincipal, DoorProxied, DoorLocalPeer, DoorUnattested, fabric_storage_wire_head, fabric_storage_wire_put, fabric_storage_wire_advance, fabric_storage_wire_closure, fabric_storage_wire_identity } import gunbc.fabric_writer_roster { fabric_door_writer_admission_over, fabric_door_principal_of, fabric_served_writer_roster, fabric_door_local_roster, FabricDoorWriterAdmitted, FabricDoorWriterRefused } import gunbc.roadmap_dashboard_instance { prod_role_realization_refusal_detail } -import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlaced, FabricStorageUnplaced, FabricStoragePlacementRefused, fabric_storage_route_prefix, fabric_storage_placed_file_root, FabricStoragePlacedRootReady, FabricStoragePlacedRootRefused } +import gunbc.fabric_storage_placement { FabricStoragePlaced, FabricStorageUnplaced, FabricStoragePlacementRefused, fabric_storage_route_prefix, fabric_storage_placed_file_root, FabricStoragePlacedRootReady, FabricStoragePlacedRootRefused } // THE FABRIC DB ENDPOINT: the store side of the served handler bound to std.fabric_storage. One // `gunbc serve` process on the placed host, compiled once, answers each request by running the diff --git a/dag/gunbc/fabric/fabric_writer_identity_observe.dag b/dag/gunbc/fabric/fabric_writer_identity_observe.dag index 7dd36dd39ce..c8688132c26 100644 --- a/dag/gunbc/fabric/fabric_writer_identity_observe.dag +++ b/dag/gunbc/fabric/fabric_writer_identity_observe.dag @@ -1,6 +1,6 @@ module gunbc.fabric_writer_identity_observe -import gunbc.fabric_storage_binding { fabric_storage_binding_for } +import gunbc.fabric_storage_binding { fabric_storage_binding_for, fabric_storage_placement } import std.types { String, NonEmptyStr, List, Bool } import std.process { ProcessExit, ExitSuccess, exit_failure } @@ -8,7 +8,7 @@ import std.fabric_storage { fabric_storage_fault_wire } import extdeps.filesystem.filesystem_io { Filesystem } import extdeps.tailscale.identity { TailnetIdentity, TailnetUser, TailnetIdentityAbsent } import product.placement_supply { HostIdentity, host_identity_eq } -import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlaced, FabricStorageUnplaced } +import gunbc.fabric_storage_placement { FabricStoragePlaced, FabricStorageUnplaced } import gunbc.fabric_storage_client { FabricStorageBinding, FabricStorageLocalFiles, FabricStorageDoorSocket, FabricStorageServed, FabricStorageBindingUnplaced, FabricStorageBindingRefused, fabric_storage_presented_identity } import gunbc.fabric_storage_wire { FabricIdentityRead, FabricIdentityPresented, FabricIdentityUnproxied, FabricIdentityReadRefused, FabricIdentityLocalPeer, FabricIdentityUnattested } import gunbc.hostname_read { hostname_short_read, HostnameShortCaptured, HostnameShortCaptureRefused } diff --git a/dag/gunbc/fabric/fabric_writer_roster.dag b/dag/gunbc/fabric/fabric_writer_roster.dag index b13ff0a812c..deb1a8bfdba 100644 --- a/dag/gunbc/fabric/fabric_writer_roster.dag +++ b/dag/gunbc/fabric/fabric_writer_roster.dag @@ -1,12 +1,12 @@ module gunbc.fabric_writer_roster -import gunbc.fabric_storage_binding { fabric_storage_binding_for } +import gunbc.fabric_storage_binding { fabric_storage_binding_for, fabric_storage_placement } import std.types { String, NonEmptyStr, List, Bool } import extdeps.tailscale.identity { TailnetIdentity, TailnetUser, TailnetIdentityAbsent, tailnet_identity_of } import product.placement_supply { HostIdentity } import gunbc.fabric_storage_client { FabricStorageBinding, FabricStorageLocalFiles } import gunbc.fabric_storage_file_store { fabric_storage_file_root } -import gunbc.fabric_storage_placement { fabric_storage_placement, fabric_storage_store_root, fabric_storage_door_local_writers, fabric_storage_door_proxy_peer } +import gunbc.fabric_storage_placement { fabric_storage_store_root, fabric_storage_door_local_writers, fabric_storage_door_proxy_peer } import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } import std.fabric_storage { FabricStoreUnreachable, FabricWriterRefusal, WriterLoginAbsent, WriterLoginUnrostered, WriterPeerUnrostered, WriterPeerUnattested } import gunbc.fabric_storage_wire { FabricIdentityPresented, FabricIdentityUnproxied, FabricIdentityReadRefused, FabricDoorPrincipal, DoorProxied, DoorLocalPeer, DoorUnattested } diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index dcb200948ed..c0c1ce18f0c 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -1,5 +1,6 @@ module gunbc.fleet_converge_workflow +import gunbc.live_deploy.desired { prod_role_actuated_realization } import extdeps.github.expressions { Expression, ContextAccess, Github, StringLiteral, Equals, LogicalOr, LogicalAnd, interpolate } @@ -4353,7 +4354,7 @@ fn fleet_converge_gcp_iam_apply_step() -> Step { // still EMITTED, so a dispatch of this mode is not silently absent, but as a refused job whose only // step fails with the typed cause: no federation, no approval filing, no host it could be pinned to. fn fleet_converge_gcp_iam_converge_job() -> Job { - fleet_converge_gcp_iam_converge_job_under(realization: prod_role_realization()) + fleet_converge_gcp_iam_converge_job_under(realization: prod_role_actuated_realization()) } fn fleet_converge_gcp_iam_converge_job_under(realization: ProdRoleRealization) -> Job { diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index b9356d219d6..4ba4b937a66 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -17,7 +17,7 @@ import gunbc.live_deploy.spec { deployment_plan_listen_port, deployment_plan_host_identity, } -import gunbc.live_deploy.desired { deployment_spec_srv1 } +import gunbc.live_deploy.desired { deployment_spec_srv1, prod_role_actuated_realization } import std.types { String, CommitSha, NonEmptyStr } import gunbc.live_deploy.candidate { CandidateRelease, @@ -68,10 +68,9 @@ import gunbc.host_effect_realize { host_effect_apply_gated } import gunbc.live_deploy.member_observe { deployment_effect_plan, deployment_identity_readback, IdentityReadbackConverged, IdentityReadbackDrift, IdentityReadbackRefused } import gunbc.live_deploy.member_identity { EffectsDerived, EffectsRefused, decision_label } import gunbc.live_deploy.role_singleton_convergence { - role_singleton_convergence, observe_role_singletons, role_singleton_refusal_detail, - RoleSingletonNoop, RoleSingletonInstall, RoleSingletonRefused, + assess_and_admit, role_singleton_refusal_detail, admitted_spec, + RoleSingletonAdmission, RoleSingletonAdmitted, RoleSingletonNotAdmitted, } -import gunbc.roadmap_dashboard_instance { prod_role_realization } import gunbc.auth.approval_broker_cutover { CutoverAdmission, StageAdmitted, StageRefused, dark_install_release_discharged, cutover_refusal_reason, @@ -272,25 +271,17 @@ fn live_deploy_ensure_dependency( ) } -// THE ROLE SINGLETONS ARE ASSESSED BEFORE ANYTHING ELSE (gunbc.live_deploy.role_singleton_convergence): -// the desired prod-role realization against an observation of every roster host. A move, a removal, -// an unobservable host, two holders or an unmapped broker stops the line here, before any member is -// planned -- so no apply starts a second, empty singleton while the old one still runs. +// THE MUTATION ACTS ON THE ADMITTED SPEC ONLY. live_deploy_apply_via_transport admits the caller's +// spec against the role-singleton assessment first (gunbc.live_deploy.role_singleton_convergence +// assess_and_admit); this takes the admission token, so there is no route by which a spec the +// assessment did not admit -- one built before the role moved -- reaches member planning. fn live_deploy_apply_mutation_via_transport( - spec: DeploymentSpec, + admission: RoleSingletonAdmission, candidate: CandidateRelease, transport: HostEffectTransport, access: DeployAccess ) -> Reconciliation { - match role_singleton_convergence(desired: prod_role_realization(), observed: observe_role_singletons(applying: spec, own: transport)) { - RoleSingletonRefused { cause } => - NotConverged { - reason: join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], ""), - applied: live_deploy_preflight_intent(access: access), - } - RoleSingletonNoop => live_deploy_apply_member_plan_via_transport(spec: spec, candidate: candidate, transport: transport, access: access) - RoleSingletonInstall { holder: _ } => live_deploy_apply_member_plan_via_transport(spec: spec, candidate: candidate, transport: transport, access: access) - } + live_deploy_apply_member_plan_via_transport(spec: admitted_spec(admission: admission), candidate: candidate, transport: transport, access: access) } // THE PLAN IS DERIVED FROM THE HOST BEFORE THE MUTATION IS BUILT (gunbc.live_deploy.member_observe): @@ -421,8 +412,30 @@ fn live_deploy_apply_via_transport( transport: HostEffectTransport, access: DeployAccess ) -> Reconciliation { + match assess_and_admit(spec: spec, desired: prod_role_actuated_realization(), own: transport) { + RoleSingletonNotAdmitted { cause } => + NotConverged { + reason: join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], ""), + applied: live_deploy_preflight_intent(access: access), + } + RoleSingletonAdmitted { admission } => live_deploy_apply_admitted_via_transport(admission: admission, candidate: candidate, transport: transport, access: access) + } +} + +// THE ROLE SINGLETONS ARE ASSESSED BEFORE ANYTHING ELSE: the desired (actuated) prod-role realization +// against an observation of every roster host, and the caller's spec is admitted only when its holding +// agrees. A move, a removal, an unobservable host, a multiplicity, a partial realization or a stale +// spec stops the line above, before any member is planned. From here on every step reads the ADMITTED +// spec, never the caller's. +fn live_deploy_apply_admitted_via_transport( + admission: RoleSingletonAdmission, + candidate: CandidateRelease, + transport: HostEffectTransport, + access: DeployAccess +) -> Reconciliation { + let spec = admitted_spec(admission: admission) let mutation = live_deploy_apply_mutation_via_transport( - spec: spec, + admission: admission, candidate: candidate, transport: transport, access: access, diff --git a/dag/gunbc/live_deploy/desired.dag b/dag/gunbc/live_deploy/desired.dag index 3eeb3ead16c..1bf8941ac8a 100644 --- a/dag/gunbc/live_deploy/desired.dag +++ b/dag/gunbc/live_deploy/desired.dag @@ -19,11 +19,19 @@ import gunbc.fleet_intent_network { import gunbc.fleet_host_identity { operator_host_srv2 } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, + ProdRoleRealization, + ProdRoleRealizedBy, + ProdRoleRealizationRefused, + ProdRoleHolderUnactuatable, + prod_role_realization, + prod_role_realization_in, + public_builtin_dashboard_instances, srv1_live_dashboard_instance, srv1_lab_dashboard_instance, srv2_deploy_dashboard_instance, } import v2.std.collection { Present, Absent } +import gunbc.deployment_risk { ProdRoleSelection } import gunbc.live_deploy.spec { DeploymentSpec, DeploymentHostTarget, @@ -72,6 +80,7 @@ fn srv2_deploy_selected_identity() -> DeclarationRef { fn selected_deployment_spec(row: DesiredDeployment) -> DeploymentSpec { deployment_spec_for( + realization: prod_role_actuated_realization(), instance: row.instance, target: row.target, names: row.names, @@ -126,6 +135,32 @@ fn srv2_deploy_desired_deployment() -> DesiredDeployment { } } +// THE PROD ROLE AS SOMETHING THAT CAN BE ACTUATED. The holder resolves over the six dashboard +// instances (gunbc.roadmap_dashboard_instance prod_role_realization), but only a row of THIS roster +// has a target and a spec, so a holder outside it -- the MacBook, srv2-preview, srv2's lab -- names a +// deployment nothing can install. Joined here, against the one roster, it refuses with +// ProdRoleHolderUnactuatable instead of placing the role-following singletons nowhere. Every +// role-following reader takes this joined realization; there is no second list of eligible holders. +fn prod_role_actuated_realization_over(realization: ProdRoleRealization, rows: List) -> ProdRoleRealization { + match realization { + ProdRoleRealizedBy { holder } => + if any(rows, r => r.instance.deployment == holder.instance.deployment) { realization } + else { ProdRoleRealizationRefused { cause: ProdRoleHolderUnactuatable { deployment: holder.instance.deployment } } } + ProdRoleRealizationRefused { cause: _ } => realization + } +} + +fn prod_role_actuated_realization_in(selection: ProdRoleSelection) -> ProdRoleRealization { + prod_role_actuated_realization_over( + realization: prod_role_realization_in(selection: selection, instances: public_builtin_dashboard_instances()), + rows: desired_deployment_roster(), + ) +} + +fn prod_role_actuated_realization() -> ProdRoleRealization { + prod_role_actuated_realization_over(realization: prod_role_realization(), rows: desired_deployment_roster()) +} + fn desired_deployment_roster() -> List { [ srv1_production_desired_deployment(), diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index a467262c061..8954b725169 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -1424,6 +1424,9 @@ fn ensure_dependency_steps(dep: DeploymentDependencyStep, privileged: Bool) -> L EnsuredManagedHostDirectory { admission } => concat([deploy_raw(command: ensure_managed_directory_command(admission: admission))], map(managed_directory_entry_repair_commands(admission: admission), c => deploy_raw(command: c))) + EnsuredOwnershipMarker { path, owner } => [ + deploy_raw(command: install_file_owned_command(mode: "0644" as NonEmptyStr, owner: owner, group: owner, source: "/dev/null", dest: path as String)), + ] } } diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index 482a9556aa0..36796bf49af 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -1,6 +1,6 @@ module gunbc.live_deploy.role_singleton_convergence -import std.types { String, NonEmptyStr, List, Bool } +import std.types { String, NonEmptyStr, List, Bool, Int } import v2.std.collection { Present, Absent } import gunbc.deployment_risk { DeploymentId } import gunbc.roadmap_dashboard_instance { @@ -8,13 +8,22 @@ import gunbc.roadmap_dashboard_instance { ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, + ProdRoleRealizationRefusal, + ProdRoleUnheld, + ProdRoleHolderUnrealized, + ProdRoleHolderAmbiguous, + ProdRoleHolderUnactuatable, + prod_role_realization_refusal_detail, } import gunbc.host_effect { HostEffectTransport, SshShell } import gunbc.live_deploy.spec { DeploymentSpec, + HoldsProdRoleSingletons, + HoldsNoRoleSingleton, deployment_fabric_storage_unit_path, gunbc_approval_broker_unit_path, } +import gunbc.fabric_storage_placement { fabric_storage_ownership_marker_path } import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster } import gunbc.live_deploy.member_observe { ObservationArm, @@ -27,176 +36,406 @@ import gunbc.live_deploy.member_observe { } // THE ROLE-FOLLOWING SINGLETONS AS A CONVERGENCE SUBJECT. The approval broker and the fabric store -// (unit, route, socket, root and directories -- one subject, gunbc.live_deploy.spec +// (unit, route, socket, root, directories and ownership marker -- one subject, gunbc.live_deploy.spec // RoleSingletonHolding) are installed only by the spec that holds the prod role, and a spec's apply -// reconciles its owned members against `observed: []`. So a role move, or NoProdRole, used to leave -// the old singleton running and, on a move, start a second EMPTY one beside it -- the store's state -// does not travel with a unit. This module assesses the DESIRED realization (prod_role_selection -// resolved) against an INDEPENDENT OBSERVATION of every roster host (DESIGN 3d), before any apply. +// reconciles its owned members against `observed: []`. So a role move, or NoProdRole, would leave the +// old singleton running and, on a move, start a second EMPTY one beside it -- the store's state does not +// travel with a unit. This module assesses the DESIRED realization against an INDEPENDENT OBSERVATION +// of every roster host (DESIGN 3d) and, only when that assessment admits it, mints the one token an +// apply may act on (RoleSingletonAdmission). // -// ONLY THE FIRST INSTALL IS IMPLEMENTED. Noop when the observed holder is the desired one (or nothing -// is desired and nothing is observed); Install when a valid holder is desired and NOTHING is observed -// anywhere. Every other transition REFUSES with a typed cause: a move (persistent state would have to -// travel), a removal under NoProdRole, a host that cannot be observed (unobserved is never absent -- -// that reading is exactly how Install would start the second empty service), two observed holders, -// and a broker present on a host where no roster deployment's store unit names its owner. Move and -// Remove are refusal causes today and become arms of RoleSingletonConvergence when their actuation -// lands; neither renames the arms that exist. +// ONLY FIRST INSTALL AND ADOPTION ARE IMPLEMENTED. Noop when exactly one complete realization is +// observed and it is the desired one on its own target (or nothing is desired and nothing is observed); +// Install when a valid holder is desired and NOTHING is observed anywhere; AdoptUnmarkedHolder for the +// one pre-marker shape (below). Every other transition REFUSES with a typed cause. Move and Remove are +// refusal causes today and become arms of RoleSingletonConvergence when their actuation lands; neither +// renames the arms that exist. // // THE HOST UNIVERSE IS THE ROSTER'S TARGETS (desired_deployment_roster), derived, not listed. A // singleton on a host outside the roster is INVISIBLE to this observation: a bounded residual whose // trigger is a roster row for that host, stated in docs/plans/deployment-risk-conformance.md. +// ── What can be seen ──────────────────────────────────────────────────────────────────────────── +// THE MEMBERS a realization is made of. The broker unit is host-global and names no deployment; the +// fabric store unit and the store's ownership marker each name exactly one roster deployment. The +// marker is read because the store root, auth state and door directories are Ensured and no retract +// removes them: an old store with no unit is still a store. +type RoleSingletonMember + = BrokerUnitMember + | FabricUnitMember + | StoreMarkerMember + +// ONE SIGHTING, KEPT AT (host, deployment, member) GRAIN until multiplicity is adjudicated -- never +// deduplicated by DeploymentId first, which is how the same deployment on two hosts, or a deployment +// realized on a host it does not target, would read as one ordinary holder. +type RoleSingletonSighting + = SightedDeploymentMember { host: NonEmptyStr, deployment: DeploymentId, member: RoleSingletonMember } + | SightedBroker { host: NonEmptyStr } + +type HostRoleSingletonReading + = HostRoleSingletonsRead { host: NonEmptyStr, sightings: List } + | HostRoleSingletonsUnobservable { host: NonEmptyStr, cause: String } + +// THE READING SOURCE IS AN INTERFACE. Today it is an ssh probe from the applying host +// (ssh_probe_reading_source); host self-reports are a second realization of the same signature. Which +// one production uses is an operator decision still owed (docs/plans/deployment-risk-conformance.md), +// and the adjudication below is indifferent to it. +type RoleSingletonReadingSource { + read: fn(NonEmptyStr, List) -> HostRoleSingletonReading +} + +// ── What is refused ───────────────────────────────────────────────────────────────────────────── type RoleSingletonRefusal = RoleSingletonMoveUnimplemented { observed: DeploymentId, desired: DeploymentId } | RoleSingletonRemovalUnimplemented { observed: DeploymentId } + | RoleSingletonDesiredRealizationRefused { cause: ProdRoleRealizationRefusal } | RoleSingletonHostUnobservable { host: NonEmptyStr, cause: String } | RoleSingletonHoldersAmbiguous { observed: List } - | RoleSingletonOwnerUnmapped { host: NonEmptyStr } + | RoleSingletonRealizedOnTwoHosts { deployment: DeploymentId, hosts: List } + | RoleSingletonMisplaced { deployment: DeploymentId, target: NonEmptyStr, observed_on: NonEmptyStr } + | RoleSingletonTwoOnOneHost { host: NonEmptyStr, deployments: List } + | RoleSingletonBrokerWithoutStore { host: NonEmptyStr } + | RoleSingletonPartial { deployment: DeploymentId, host: NonEmptyStr, missing: List } + | RoleSingletonStaleSpec { instance_id: NonEmptyStr, detail: String } +// ── What is decided ───────────────────────────────────────────────────────────────────────────── +// AdoptUnmarkedHolder IS THE ADOPTION OF A PRE-MARKER REALIZATION. It exists for realizations installed +// before the store carried an ownership marker: the desired holder's fabric unit and broker are observed +// on its own target, with nothing on any other host, and only the marker is missing. Adoption writes +// the marker, inside an operator-triggered apply only (gunbc#13124: no self-repair outside an operator +// disposition), and is its own arm so the receipt counts it. DISSOLVES when every realization in the +// roster carries a marker; until then any other partial state refuses as RoleSingletonPartial. type RoleSingletonConvergence = RoleSingletonNoop | RoleSingletonInstall { holder: ProdRoleHolder } + | AdoptUnmarkedHolder { holder: ProdRoleHolder, host: NonEmptyStr } | RoleSingletonRefused { cause: RoleSingletonRefusal } // ── The observation ───────────────────────────────────────────────────────────────────────────── -// ONE HOST'S READING. A holder is a roster deployment whose fabric storage unit (named per -// deployment, so its presence maps to exactly one roster row) is present on that host. The broker -// unit is host-global and carries no deployment identity, so it is read only for presence: a broker -// with no holder beside it is a singleton nobody can be named as owning, and refuses. -type HostRoleSingletonReading - = HostRoleSingletonsRead { host: NonEmptyStr, holders: List, broker_present: Bool } - | HostRoleSingletonsUnobservable { host: NonEmptyStr, cause: String } +type ObservedRealization { + deployment: DeploymentId + host: NonEmptyStr + members: List +} type RoleSingletonObservation - = RoleSingletonsObserved { holders: List } + = RoleSingletonsObserved { realizations: List } | RoleSingletonObservationRefused { cause: RoleSingletonRefusal } +fn member_eq(a: RoleSingletonMember, b: RoleSingletonMember) -> Bool { + match a { + BrokerUnitMember => match b { BrokerUnitMember => true FabricUnitMember => false StoreMarkerMember => false } + FabricUnitMember => match b { BrokerUnitMember => false FabricUnitMember => true StoreMarkerMember => false } + StoreMarkerMember => match b { BrokerUnitMember => false FabricUnitMember => false StoreMarkerMember => true } + } +} + fn distinct_ids(ids: List) -> List { fold(ids, init: [], f: (acc, d) => if any(acc, a => a == d) { acc } else { concat(acc, [d]) }) } -// THE PURE FOLD OVER PER-HOST READINGS, so the transition REDs supply readings at this boundary -// while the wet observation below runs the real legs. -fn role_singleton_observation_of(readings: List) -> RoleSingletonObservation { +fn distinct_hosts(hosts: List) -> List { + fold(hosts, init: [], f: (acc, h) => if any(acc, a => (a as String) == (h as String)) { acc } else { concat(acc, [h]) }) +} + +fn deployment_sightings(readings: List) -> List { + readings |> flat_map(r => match r { + HostRoleSingletonsRead { host: _, sightings } => sightings |> filter(s => match s { + SightedDeploymentMember { host: _, deployment: _, member: _ } => true + SightedBroker { host: _ } => false + }) + HostRoleSingletonsUnobservable { host: _, cause: _ } => [] + }) +} + +fn sighting_host(s: RoleSingletonSighting) -> NonEmptyStr { + match s { + SightedDeploymentMember { host, deployment: _, member: _ } => host + SightedBroker { host } => host + } +} + +fn sighting_deployment(s: RoleSingletonSighting) -> DeploymentId? { + match s { + SightedDeploymentMember { host: _, deployment, member: _ } => Present { value: deployment } + SightedBroker { host: _ } => none + } +} + +fn sighting_member(s: RoleSingletonSighting) -> RoleSingletonMember { + match s { + SightedDeploymentMember { host: _, deployment: _, member } => member + SightedBroker { host: _ } => BrokerUnitMember + } +} + +fn broker_hosts(readings: List) -> List { + readings |> flat_map(r => match r { + HostRoleSingletonsRead { host: _, sightings } => sightings |> flat_map(s => match s { + SightedBroker { host } => [host] + SightedDeploymentMember { host: _, deployment: _, member: _ } => [] + }) + HostRoleSingletonsUnobservable { host: _, cause: _ } => [] + }) +} + +fn sighted_ids(sightings: List) -> List { + distinct_ids(ids: sightings |> flat_map(s => match sighting_deployment(s: s) { Present { value: d } => [d] Absent => [] })) +} + +fn hosts_of(sightings: List, deployment: DeploymentId) -> List { + distinct_hosts(hosts: sightings |> filter(s => match sighting_deployment(s: s) { Present { value: x } => x == deployment Absent => false }) |> map(s => sighting_host(s: s))) +} + +fn target_host_of(rows: List, deployment: DeploymentId) -> NonEmptyStr? { + match rows |> filter(r => r.instance.deployment == deployment) |> first { + Present { value: r } => Present { value: r.target.ssh_host } + Absent => none + } +} + +// THE ADJUDICATION, over per-host readings and the roster, with NO deduplication before the +// multiplicity checks. In order: an unobservable host; one deployment realized on two hosts; one +// deployment realized off its own target; two deployments realized on one host; two deployments +// realized anywhere; a broker on a host where no store is realized. Only then are realizations formed, +// each (deployment, host) with the members seen for it, broker included from the same host. +fn role_singleton_observation_of(readings: List, rows: List) -> RoleSingletonObservation { let unobservable = readings |> flat_map(r => match r { HostRoleSingletonsUnobservable { host, cause } => [RoleSingletonHostUnobservable { host: host, cause: cause }] - HostRoleSingletonsRead { host: _, holders: _, broker_present: _ } => [] + HostRoleSingletonsRead { host: _, sightings: _ } => [] }) - let unmapped = readings |> flat_map(r => match r { - HostRoleSingletonsRead { host, holders, broker_present } => - if broker_present && count(holders) == 0 { [RoleSingletonOwnerUnmapped { host: host }] } else { [] } - HostRoleSingletonsUnobservable { host: _, cause: _ } => [] + let sightings = deployment_sightings(readings: readings) + let ids = sighted_ids(sightings: sightings) + let two_hosts = ids |> flat_map(d => if count(hosts_of(sightings: sightings, deployment: d)) > 1 { [RoleSingletonRealizedOnTwoHosts { deployment: d, hosts: hosts_of(sightings: sightings, deployment: d) }] } else { [] }) + let misplaced = ids |> flat_map(d => match target_host_of(rows: rows, deployment: d) { + Absent => [] + Present { value: t } => hosts_of(sightings: sightings, deployment: d) |> flat_map(h => if (h as String) == (t as String) { [] } else { [RoleSingletonMisplaced { deployment: d, target: t, observed_on: h }] }) }) - let holders = distinct_ids(ids: readings |> flat_map(r => match r { - HostRoleSingletonsRead { host: _, holders: hs, broker_present: _ } => hs - HostRoleSingletonsUnobservable { host: _, cause: _ } => [] - })) - match unobservable |> first { + let store_hosts = distinct_hosts(hosts: sightings |> map(s => sighting_host(s: s))) + let two_on_one = store_hosts |> flat_map(h => { + let here = distinct_ids(ids: sightings |> filter(s => (sighting_host(s: s) as String) == (h as String)) |> flat_map(s => match sighting_deployment(s: s) { Present { value: d } => [d] Absent => [] })) + if count(here) > 1 { [RoleSingletonTwoOnOneHost { host: h, deployments: here }] } else { [] } + }) + let brokers = broker_hosts(readings: readings) + let broker_alone = brokers |> flat_map(h => if any(store_hosts, s => (s as String) == (h as String)) { [] } else { [RoleSingletonBrokerWithoutStore { host: h }] }) + let first_refusal = concat(concat(concat(unobservable, two_hosts), concat(misplaced, two_on_one)), concat(if count(ids) > 1 { [RoleSingletonHoldersAmbiguous { observed: ids }] } else { [] }, broker_alone)) |> first + match first_refusal { Present { value: c } => RoleSingletonObservationRefused { cause: c } - Absent => match unmapped |> first { - Present { value: c } => RoleSingletonObservationRefused { cause: c } - Absent => - if count(holders) > 1 { RoleSingletonObservationRefused { cause: RoleSingletonHoldersAmbiguous { observed: holders } } } - else { RoleSingletonsObserved { holders: holders } } - } + Absent => RoleSingletonsObserved { realizations: ids |> flat_map(d => hosts_of(sightings: sightings, deployment: d) |> map(h => ObservedRealization { + deployment: d, + host: h, + members: concat( + sightings |> filter(s => (sighting_host(s: s) as String) == (h as String)) |> map(s => sighting_member(s: s)), + if any(brokers, b => (b as String) == (h as String)) { [BrokerUnitMember] } else { [] }, + ), + })) } } } +fn missing_members(r: ObservedRealization) -> List { + [BrokerUnitMember, FabricUnitMember, StoreMarkerMember] |> filter(m => !any(r.members, x => member_eq(a: x, b: m))) +} + // ── The decision ──────────────────────────────────────────────────────────────────────────────── -fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingletonObservation) -> RoleSingletonConvergence { +fn desired_is_no_prod(cause: ProdRoleRealizationRefusal) -> Bool { + match cause { + ProdRoleUnheld => true + ProdRoleHolderUnrealized { deployment: _ } => false + ProdRoleHolderAmbiguous { deployment: _, count: _ } => false + ProdRoleHolderUnactuatable { deployment: _ } => false + } +} + +// ONLY ProdRoleUnheld MEANS "NO PROD". An unrealized, ambiguous or unactuatable holder is a refusal of +// the desired side, never read as the no-prod path -- which is how a typo in the selection would +// otherwise turn into a removal of the real prod singletons. +fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingletonObservation, rows: List) -> RoleSingletonConvergence { match observed { RoleSingletonObservationRefused { cause } => RoleSingletonRefused { cause: cause } - RoleSingletonsObserved { holders } => match holders |> first { - Absent => match desired { - ProdRoleRealizedBy { holder } => RoleSingletonInstall { holder: holder } - ProdRoleRealizationRefused { cause: _ } => RoleSingletonNoop - } - Present { value: seen } => match desired { - ProdRoleRealizedBy { holder } => - if holder.instance.deployment == seen { RoleSingletonNoop } - else { RoleSingletonRefused { cause: RoleSingletonMoveUnimplemented { observed: seen, desired: holder.instance.deployment } } } - ProdRoleRealizationRefused { cause: _ } => RoleSingletonRefused { cause: RoleSingletonRemovalUnimplemented { observed: seen } } + RoleSingletonsObserved { realizations } => match desired { + ProdRoleRealizationRefused { cause } => + if desired_is_no_prod(cause: cause) { + match realizations |> first { + Absent => RoleSingletonNoop + Present { value: r } => RoleSingletonRefused { cause: RoleSingletonRemovalUnimplemented { observed: r.deployment } } + } + } else { + RoleSingletonRefused { cause: RoleSingletonDesiredRealizationRefused { cause: cause } } + } + ProdRoleRealizedBy { holder } => match realizations |> first { + Absent => RoleSingletonInstall { holder: holder } + Present { value: r } => + if !(r.deployment == holder.instance.deployment) { + RoleSingletonRefused { cause: RoleSingletonMoveUnimplemented { observed: r.deployment, desired: holder.instance.deployment } } + } else { + let missing = missing_members(r: r) + if count(missing) == 0 { RoleSingletonNoop } + else if count(missing) == 1 && all(missing, m => member_eq(a: m, b: StoreMarkerMember)) { + AdoptUnmarkedHolder { holder: holder, host: r.host } + } else { + RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } + } + } } } } } +// ── The admission: the decision bound to the spec that acts ───────────────────────────────────── +// THE APPLY CONSUMES THIS TOKEN, NEVER THE CALLER'S SPEC (sole_constructor: only admit_role_singletons +// mints it). The spec inside is the one the assessment was made against, and it is admitted only when +// its own holding agrees with the desired realization: a spec built before the role moved, a holding +// spec under NoProdRole, or a non-holder spec while the holder's first install is pending all refuse +// before any member is planned. +type RoleSingletonAdmission sole_constructor { + spec: DeploymentSpec + decision: RoleSingletonConvergence +} + +type RoleSingletonAdmissionResult + = RoleSingletonAdmitted { admission: RoleSingletonAdmission } + | RoleSingletonNotAdmitted { cause: RoleSingletonRefusal } + +fn admitted_spec(admission: RoleSingletonAdmission) -> DeploymentSpec { + admission.spec +} + +fn admitted_decision(admission: RoleSingletonAdmission) -> RoleSingletonConvergence { + admission.decision +} + +fn stale(spec: DeploymentSpec, detail: String) -> RoleSingletonAdmissionResult { + RoleSingletonNotAdmitted { cause: RoleSingletonStaleSpec { instance_id: spec.instance_id, detail: detail } } +} + +fn admit_role_singletons(spec: DeploymentSpec, desired: ProdRoleRealization, decision: RoleSingletonConvergence) -> RoleSingletonAdmissionResult { + let spec_holder = match spec.role_singletons { + HoldsProdRoleSingletons { holder } => Present { value: holder.instance.deployment } + HoldsNoRoleSingleton => none + } + let desired_holder = match desired { + ProdRoleRealizedBy { holder } => Present { value: holder.instance } + ProdRoleRealizationRefused { cause: _ } => none + } + let desired_is_this_spec = match desired_holder { Present { value: i } => (i.instance_id as String) == (spec.instance_id as String) Absent => false } + let agrees = match spec_holder { + Present { value: d } => match desired_holder { Present { value: i } => i.deployment == d Absent => false } + Absent => !desired_is_this_spec + } + let admitted = RoleSingletonAdmitted { admission: RoleSingletonAdmission { spec: spec, decision: decision } } + let stale_holding = stale(spec: spec, detail: "the spec's role-singleton holding disagrees with the prod role resolved now; it was built against a different selection") + match decision { + RoleSingletonRefused { cause } => RoleSingletonNotAdmitted { cause: cause } + RoleSingletonNoop => if agrees { admitted } else { stale_holding } + AdoptUnmarkedHolder { holder: _, host: _ } => if agrees { admitted } else { stale_holding } + RoleSingletonInstall { holder: _ } => + if !agrees { stale_holding } + else if desired_is_this_spec { admitted } + else { stale(spec: spec, detail: "the holder's first install is pending; no other spec applies until it lands") } + } +} + fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { match cause { RoleSingletonMoveUnimplemented { observed, desired } => join(["role singletons refused: the prod role moved from ", observed.name as String, " to ", desired.name as String, " while the old singletons are still observed; moving their persistent state is not implemented"], "") RoleSingletonRemovalUnimplemented { observed } => join(["role singletons refused: no deployment holds the prod role but ", observed.name as String, "'s singletons are still observed; removal is not implemented"], "") + RoleSingletonDesiredRealizationRefused { cause: c } => + join(["role singletons refused: the desired realization is not a holder and not NoProdRole: ", prod_role_realization_refusal_detail(cause: c)], "") RoleSingletonHostUnobservable { host, cause: c } => join(["role singletons refused: host ", host as String, " could not be observed (", c, "); unobserved is not absent"], "") RoleSingletonHoldersAmbiguous { observed } => join(["role singletons refused: ", to_string(count(observed)), " deployments are observed holding them: ", join(map(observed, d => d.name as String), ", ")], "") - RoleSingletonOwnerUnmapped { host } => - join(["role singletons refused: the approval broker is present on ", host as String, " but no roster deployment's fabric storage unit names its owner"], "") + RoleSingletonRealizedOnTwoHosts { deployment, hosts } => + join(["role singletons refused: ", deployment.name as String, " is realized on ", join(map(hosts, h => h as String), ", ")], "") + RoleSingletonMisplaced { deployment, target, observed_on } => + join(["role singletons refused: ", deployment.name as String, " targets ", target as String, " but is realized on ", observed_on as String], "") + RoleSingletonTwoOnOneHost { host, deployments } => + join(["role singletons refused: ", host as String, " carries the singletons of ", join(map(deployments, d => d.name as String), ", ")], "") + RoleSingletonBrokerWithoutStore { host } => + join(["role singletons refused: the approval broker is present on ", host as String, " but no store is realized there"], "") + RoleSingletonPartial { deployment, host, missing } => + join(["role singletons refused: ", deployment.name as String, " on ", host as String, " is partial (missing ", to_string(count(missing)), " member(s)); an interrupted install or a store without its broker is not adopted"], "") + RoleSingletonStaleSpec { instance_id, detail } => + join(["role singletons refused: spec ", instance_id as String, " is not admitted: ", detail], "") } } -// ── The wet observation ───────────────────────────────────────────────────────────────────────── -// Each roster host is read over the transport that reaches it from the applying host: the apply's -// own transport for the host it is applying to, ssh to the target's declared ssh_host for every other -// roster host. A host whose legs refuse is unobservable and stops the line. -fn presence_reading(p: EntryPresence) -> Bool? { +// ── The ssh probe reading source ──────────────────────────────────────────────────────────────── +// Each roster host is read over the transport that reaches it from the applying host: the apply's own +// transport for the host it is applying to, ssh to the target's declared ssh_host for every other +// roster host. EVERY roster deployment's members are probed on EVERY host -- probing only where a +// deployment is targeted is what made a realization on the wrong host invisible. A host whose legs +// refuse is unobservable and stops the line. +type MemberProbe { + deployment: DeploymentId? + member: RoleSingletonMember + presence: EntryPresence +} + +fn presence_seen(p: EntryPresence) -> Bool { match p { - EntryPresent => Present { value: true } - EntryAbsent => Present { value: false } - EntryPresenceIndeterminate { cause: _ } => none + EntryPresent => true + EntryAbsent => false + EntryPresenceIndeterminate { cause: _ } => false } } -fn presence_cause(p: EntryPresence) -> String { +fn presence_cause(p: EntryPresence) -> String? { match p { - EntryPresenceIndeterminate { cause } => cause - EntryPresent => "" - EntryAbsent => "" + EntryPresenceIndeterminate { cause } => Present { value: cause } + EntryPresent => none + EntryAbsent => none } } fn host_role_singleton_reading(host: NonEmptyStr, rows: List, arm: ObservationArm) -> HostRoleSingletonReading { - let broker = entry_presence(arm: arm, path: gunbc_approval_broker_unit_path() as String) - let stores = map(rows, r => DeploymentStorePresence { - deployment: r.instance.deployment, - presence: entry_presence(arm: arm, path: deployment_fabric_storage_unit_path(names: r.names) as String), - }) - let indeterminate = concat( - match presence_reading(p: broker) { Absent => [presence_cause(p: broker)] Present { value: _ } => [] }, - stores |> flat_map(s => match presence_reading(p: s.presence) { Absent => [presence_cause(p: s.presence)] Present { value: _ } => [] }), + let probes = concat( + [MemberProbe { deployment: none, member: BrokerUnitMember, presence: entry_presence(arm: arm, path: gunbc_approval_broker_unit_path() as String) }], + rows |> flat_map(r => [ + MemberProbe { deployment: Present { value: r.instance.deployment }, member: FabricUnitMember, presence: entry_presence(arm: arm, path: deployment_fabric_storage_unit_path(names: r.names) as String) }, + MemberProbe { deployment: Present { value: r.instance.deployment }, member: StoreMarkerMember, presence: entry_presence(arm: arm, path: fabric_storage_ownership_marker_path(instance: r.instance) as String) }, + ]), ) - match indeterminate |> first { + match probes |> flat_map(p => match presence_cause(p: p.presence) { Present { value: c } => [c] Absent => [] }) |> first { Present { value: cause } => HostRoleSingletonsUnobservable { host: host, cause: cause } Absent => HostRoleSingletonsRead { host: host, - holders: stores |> filter(s => match s.presence { EntryPresent => true EntryAbsent => false EntryPresenceIndeterminate { cause: _ } => false }) |> map(s => s.deployment), - broker_present: match broker { EntryPresent => true EntryAbsent => false EntryPresenceIndeterminate { cause: _ } => false }, + sightings: probes |> filter(p => presence_seen(p: p.presence)) |> map(p => match p.deployment { + Present { value: d } => SightedDeploymentMember { host: host, deployment: d, member: p.member } + Absent => SightedBroker { host: host } + }), } } } -type DeploymentStorePresence { - deployment: DeploymentId - presence: EntryPresence -} - fn roster_hosts(rows: List) -> List { - fold(rows, init: [], f: (acc, r) => if any(acc, h => (h as String) == (r.target.ssh_host as String)) { acc } else { concat(acc, [r.target.ssh_host]) }) + distinct_hosts(hosts: rows |> map(r => r.target.ssh_host)) } fn transport_to(host: NonEmptyStr, applying: DeploymentSpec, own: HostEffectTransport) -> HostEffectTransport { if (host as String) == (applying.target.ssh_host as String) { own } else { SshShell { ssh_host: host } } } -fn observe_role_singletons(applying: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonObservation { +fn ssh_probe_reading_source(applying: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonReadingSource { + RoleSingletonReadingSource { + read: (host, rows) => host_role_singleton_reading(host: host, rows: rows, arm: observation_arm(transport: transport_to(host: host, applying: applying, own: own))), + } +} + +fn observe_role_singletons_with(source: RoleSingletonReadingSource, rows: List) -> RoleSingletonObservation { + let read = source.read + role_singleton_observation_of(readings: map(roster_hosts(rows: rows), h => read(h, rows)), rows: rows) +} + +// THE PRODUCTION ROUTE: desired (actuated) realization x the ssh probe over the roster, then admission +// of the exact spec. The apply acts on the admitted spec and nothing else. +fn assess_and_admit(spec: DeploymentSpec, desired: ProdRoleRealization, own: HostEffectTransport) -> RoleSingletonAdmissionResult { let rows = desired_deployment_roster() - role_singleton_observation_of(readings: map(roster_hosts(rows: rows), h => - host_role_singleton_reading( - host: h, - rows: rows |> filter(r => (r.target.ssh_host as String) == (h as String)), - arm: observation_arm(transport: transport_to(host: h, applying: applying, own: own)), - ) - )) + admit_role_singletons( + spec: spec, + desired: desired, + decision: role_singleton_convergence(desired: desired, observed: observe_role_singletons_with(source: ssh_probe_reading_source(applying: spec, own: own), rows: rows), rows: rows), + ) } diff --git a/dag/gunbc/live_deploy/spec.dag b/dag/gunbc/live_deploy/spec.dag index 7887377d841..ba72d46aa7c 100644 --- a/dag/gunbc/live_deploy/spec.dag +++ b/dag/gunbc/live_deploy/spec.dag @@ -41,7 +41,7 @@ import gunbc.host_layout { srv1_dispatch_attempt_state_root, srv1_devboot_artifact_store_root, } -import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_https_port, fabric_storage_door_socket } +import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_ownership_marker_path, fabric_storage_https_port, fabric_storage_door_socket } import gunbc.managed_directory { ManagedDirectoryAdmission, managed_directory_admission_path } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, @@ -49,7 +49,6 @@ import gunbc.roadmap_dashboard_instance { DashboardInstanceIdUnknown, dashboard_instance_for_id, ProdRoleRealization, - prod_role_realization, ProdRoleHolder, ProdRoleRealizedBy, ProdRoleRealizationRefused, @@ -121,6 +120,7 @@ type EnsuredDependencyKind = | DevbootArtifactStoreDirectory | CodexRuntimeReleaseDirectory | FabricStorageStoreDirectory + | FabricStorageOwnershipMarker | ProviderStateRootDirectory // ONE CONCEPT -- AN ENSURED HOST RESOURCE -- WITH N KINDS, not a second ensured mechanism beside @@ -146,12 +146,14 @@ type EnsuredSubject = EnsuredPackage { package: NonEmptyStr } | EnsuredHostDirectory { path: NonEmptyStr, owner: NonEmptyStr } | EnsuredManagedHostDirectory { admission: ManagedDirectoryAdmission } + | EnsuredOwnershipMarker { path: NonEmptyStr, owner: NonEmptyStr } fn ensured_subject_identity(subject: EnsuredSubject) -> NonEmptyStr { match subject { EnsuredPackage { package } => package EnsuredHostDirectory { path, owner } => path EnsuredManagedHostDirectory { admission } => managed_directory_admission_path(a: admission) as String as NonEmptyStr + EnsuredOwnershipMarker { path, owner: _ } => path } } @@ -1126,10 +1128,15 @@ fn deployment_fabric_storage_steps(holding: RoleSingletonHolding, names: Deploym // on srv1. Derived from the same holding, they land on exactly the target that gets the unit. fn deployment_role_singleton_directories(holding: RoleSingletonHolding) -> List { match holding { - HoldsProdRoleSingletons { holder } => map(fabric_storage_store_directories_for(instance: holder.instance), a => DeploymentDependencyStep { + HoldsProdRoleSingletons { holder } => concat(map(fabric_storage_store_directories_for(instance: holder.instance), a => DeploymentDependencyStep { kind: FabricStorageStoreDirectory, subject: EnsuredManagedHostDirectory { admission: a }, - }) + }), [ + DeploymentDependencyStep { + kind: FabricStorageOwnershipMarker, + subject: EnsuredOwnershipMarker { path: fabric_storage_ownership_marker_path(instance: holder.instance), owner: gunbc_service_user }, + }, + ]) HoldsNoRoleSingleton => [] } } @@ -1400,17 +1407,11 @@ fn deployment_service_config( // two machines shares instance and identity and differs in target. Neither case needs a new // function, which is the property that was missing. -fn deployment_spec_for( - instance: HostDashboardInstance, - target: DeploymentHostTarget, - names: DeploymentNames, -) -> DeploymentSpec { - deployment_spec_for_under(realization: prod_role_realization(), instance: instance, target: target, names: names) -} -// THE SAME CONSTRUCTION OVER A SUPPLIED REALIZATION, so a witness can move the role and read every -// spec the roster would apply without editing the production selection. -fn deployment_spec_for_under( +// THE REALIZATION IS SUPPLIED, never resolved here: the spec cannot see the desired roster that decides +// whether the holder is actuatable, so gunbc.live_deploy.desired resolves it +// (prod_role_actuated_realization) and every caller passes it in. A witness passes a moved one. +fn deployment_spec_for( realization: ProdRoleRealization, instance: HostDashboardInstance, target: DeploymentHostTarget, diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index 7d1b6524022..0ae3775ac76 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -374,9 +374,17 @@ fn dashboard_instance_dispatch_platform_executables( } fn dashboard_instance_ownership_marker_path(instance: HostDashboardInstance) -> FilePath { + dashboard_ownership_marker_in(root: instance.instance_root, instance_id: instance.instance_id) +} + +// THE OWNERSHIP MARKER'S ONE NAMING RULE: an empty file under a root, named for the instance that +// owns the root. The instance root carries it (above), and so does the prod-role holder's fabric +// store root (gunbc.fabric_storage_placement fabric_storage_ownership_marker_path), so an old store +// with no unit is still observably someone's state rather than reading as nothing. +fn dashboard_ownership_marker_in(root: FilePath, instance_id: NonEmptyStr) -> FilePath { dashboard_instance_child( - root: instance.instance_root, - child: join([".gunbc-dashboard-instance-", instance.instance_id as String], ""), + root: root, + child: join([".gunbc-dashboard-instance-", instance_id as String], ""), ) } @@ -774,6 +782,7 @@ type ProdRoleRealizationRefusal = ProdRoleUnheld | ProdRoleHolderUnrealized { deployment: DeploymentId } | ProdRoleHolderAmbiguous { deployment: DeploymentId, count: Int } + | ProdRoleHolderUnactuatable { deployment: DeploymentId } // THE HOLDER TOKEN. sole_constructor, so only this module -- and in it only prod_role_realization_in // -- can mint one: a role-following singleton is built FROM a holder, and no caller can claim @@ -819,6 +828,8 @@ fn prod_role_realization_refusal_detail(cause: ProdRoleRealizationRefusal) -> St join(["prod role refused: holder ", deployment.name as String, " has no HostDashboardInstance"], "") ProdRoleHolderAmbiguous { deployment, count: n } => join(["prod role refused: holder ", deployment.name as String, " has ", integer_int_to_decimal_string(value: n), " instances"], "") + ProdRoleHolderUnactuatable { deployment } => + join(["prod role refused: holder ", deployment.name as String, " has no desired deployment row (gunbc.live_deploy.desired desired_deployment_roster), so nothing can actuate it"], "") } } diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 44465da10c7..f45422613f6 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -27,6 +27,7 @@ import gunbc.deployment_risk { srv1_daily_workspace_deployment, srv1_lab_codex_feedback_deployment, srv2_deploy_deployment, + macbook_local_deployment, } import gunbc.recurring_failure_mode.a_test_deployment_acts_on_the_prod_deployments_state { a_test_deployment_acts_on_the_prod_deployments_state } import gunbc.roadmap_dashboard_instance { @@ -43,6 +44,7 @@ import gunbc.roadmap_dashboard_instance { ProdRoleRealizationRefused, ProdRoleUnheld, ProdRoleHolderUnrealized, + ProdRoleHolderUnactuatable, prod_role_realization, prod_role_realization_in, public_builtin_dashboard_instances, @@ -55,24 +57,42 @@ import gunbc.live_deploy.spec { FabricStorageServeUnit, FabricStorageServeMapping, FabricStorageStoreDirectory, - deployment_spec_for_under, + FabricStorageOwnershipMarker, + deployment_spec_for, deployment_owns_the_host_approval_broker, deployment_fabric_storage_door_socket, } -import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster } +import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization, prod_role_actuated_realization_in } import gunbc.live_deploy.role_singleton_convergence { RoleSingletonConvergence, RoleSingletonNoop, RoleSingletonInstall, + AdoptUnmarkedHolder, RoleSingletonRefused, RoleSingletonMoveUnimplemented, RoleSingletonRemovalUnimplemented, + RoleSingletonDesiredRealizationRefused, RoleSingletonHostUnobservable, RoleSingletonHoldersAmbiguous, - RoleSingletonOwnerUnmapped, + RoleSingletonRealizedOnTwoHosts, + RoleSingletonMisplaced, + RoleSingletonTwoOnOneHost, + RoleSingletonBrokerWithoutStore, + RoleSingletonPartial, + RoleSingletonStaleSpec, + RoleSingletonSighting, + SightedDeploymentMember, + SightedBroker, + FabricUnitMember, + StoreMarkerMember, HostRoleSingletonReading, HostRoleSingletonsRead, HostRoleSingletonsUnobservable, + RoleSingletonAdmissionResult, + RoleSingletonAdmitted, + RoleSingletonNotAdmitted, + admit_role_singletons, + admitted_spec, role_singleton_convergence, role_singleton_observation_of, } @@ -189,19 +209,19 @@ test fn the_wrong_instance_failure_mode_row_cites_this_red() -> Bool { // ── The role-following singletons, at the TRANSITION boundary ─────────────────────────────────── // Every claim below reads what an apply would actually be handed: the roster's specs built under a -// supplied realization (deployment_spec_for_under), the convergence decision over supplied per-host +// supplied realization (deployment_spec_for), the convergence decision over supplied per-host // readings, and the emitted GCP IAM job. The production entry points run the same functions over // prod_role_selection and a real observation; the positive control at the end is over those. fn fx_moved() -> ProdRoleRealization { - prod_role_realization_in(selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }, instances: public_builtin_dashboard_instances()) + prod_role_actuated_realization_in(selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }) } fn fx_unheld() -> ProdRoleRealization { - prod_role_realization_in(selection: NoProdRole, instances: public_builtin_dashboard_instances()) + prod_role_actuated_realization_in(selection: NoProdRole) } fn roster_specs_under(realization: ProdRoleRealization) -> List { - map(desired_deployment_roster(), r => deployment_spec_for_under(realization: realization, instance: r.instance, target: r.target, names: r.names)) + map(desired_deployment_roster(), r => deployment_spec_for(realization: realization, instance: r.instance, target: r.target, names: r.names)) } fn spec_store_directory_count(spec: DeploymentSpec) -> Int { @@ -278,61 +298,214 @@ test fn a_prod_role_holder_with_no_instance_refuses_as_unrealized() -> Bool { } } -// ── The convergence transitions, over supplied per-host readings ──────────────────────────────── -fn read_on(host: String, holders: List, broker: Bool) -> HostRoleSingletonReading { - HostRoleSingletonsRead { host: host as NonEmptyStr, holders: holders, broker_present: broker } +// ── The convergence transitions, over supplied per-host sightings ────────────────────────────── +// Sightings are supplied at the adjudication boundary (DESIGN 3: a witness discriminates at one +// interface); the ssh probe that produces them in production is ssh_probe_reading_source, and the +// positive control below runs the real decision over the real realization. +fn full_on(host: String, d: DeploymentId) -> List { + [ + SightedDeploymentMember { host: host as NonEmptyStr, deployment: d, member: FabricUnitMember }, + SightedDeploymentMember { host: host as NonEmptyStr, deployment: d, member: StoreMarkerMember }, + SightedBroker { host: host as NonEmptyStr }, + ] +} + +fn marker_only_on(host: String, d: DeploymentId) -> List { + [SightedDeploymentMember { host: host as NonEmptyStr, deployment: d, member: StoreMarkerMember }] +} + +fn read_on(host: String, sightings: List) -> HostRoleSingletonReading { + HostRoleSingletonsRead { host: host as NonEmptyStr, sightings: sightings } +} + +fn empty_world() -> List { + [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: [])] +} + +fn srv1_holds() -> List { + [read_on(host: "srv1", sightings: full_on(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])] } fn decide(desired: ProdRoleRealization, readings: List) -> RoleSingletonConvergence { - role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings)) + let rows = desired_deployment_roster() + role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings, rows: rows), rows: rows) +} + +fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { + match c { + RoleSingletonRefused { cause } => match cause { + RoleSingletonMoveUnimplemented { observed: _, desired: _ } => label == "move" + RoleSingletonRemovalUnimplemented { observed: _ } => label == "removal" + RoleSingletonDesiredRealizationRefused { cause: _ } => label == "desired" + RoleSingletonHostUnobservable { host: _, cause: _ } => label == "unobservable" + RoleSingletonHoldersAmbiguous { observed: _ } => label == "ambiguous" + RoleSingletonRealizedOnTwoHosts { deployment: _, hosts: _ } => label == "two-hosts" + RoleSingletonMisplaced { deployment: _, target: _, observed_on: _ } => label == "misplaced" + RoleSingletonTwoOnOneHost { host: _, deployments: _ } => label == "two-on-one" + RoleSingletonBrokerWithoutStore { host: _ } => label == "broker-alone" + RoleSingletonPartial { deployment: _, host: _, missing: _ } => label == "partial" + RoleSingletonStaleSpec { instance_id: _, detail: _ } => label == "stale" + } + RoleSingletonNoop => false + RoleSingletonInstall { holder: _ } => false + AdoptUnmarkedHolder { holder: _, host: _ } => false + } } test fn a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move() -> Bool { - match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true), read_on(host: "srv2", holders: [], broker: false)]) { - RoleSingletonRefused { cause: RoleSingletonMoveUnimplemented { observed: o, desired: d } } => o == srv1_daily_workspace_deployment && d == srv2_deploy_deployment - _ => false - } + refused_with(c: decide(desired: fx_moved(), readings: srv1_holds()), label: "move") } test fn no_prod_role_with_a_holder_observed_refuses_as_a_removal() -> Bool { - match decide(desired: fx_unheld(), readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true)]) { - RoleSingletonRefused { cause: RoleSingletonRemovalUnimplemented { observed: o } } => o == srv1_daily_workspace_deployment - _ => false - } + refused_with(c: decide(desired: fx_unheld(), readings: srv1_holds()), label: "removal") } // UNOBSERVED IS NOT ABSENT: with srv1 unreadable and srv2 empty, the moved role must NOT install. test fn an_unobservable_roster_host_refuses_rather_than_reading_as_absent() -> Bool { - match decide(desired: fx_moved(), readings: [HostRoleSingletonsUnobservable { host: "srv1" as NonEmptyStr, cause: "ssh refused" }, read_on(host: "srv2", holders: [], broker: false)]) { - RoleSingletonRefused { cause: RoleSingletonHostUnobservable { host: h, cause: _ } } => (h as String) == "srv1" - _ => false - } + refused_with(c: decide(desired: fx_moved(), readings: [HostRoleSingletonsUnobservable { host: "srv1" as NonEmptyStr, cause: "ssh refused" }, read_on(host: "srv2", sightings: [])]), label: "unobservable") } test fn nothing_observed_and_a_valid_holder_installs_on_that_holder() -> Bool { - match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [], broker: false), read_on(host: "srv2", holders: [], broker: false)]) { + match decide(desired: fx_moved(), readings: empty_world()) { RoleSingletonInstall { holder } => holder.instance.deployment == srv2_deploy_deployment _ => false } } -test fn the_observed_holder_equal_to_the_desired_one_is_a_noop() -> Bool { - match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [], broker: false), read_on(host: "srv2", holders: [srv2_deploy_deployment], broker: true)]) { +test fn exactly_one_complete_realization_of_the_desired_holder_on_its_target_is_a_noop() -> Bool { + match decide(desired: fx_moved(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: full_on(host: "srv2", d: srv2_deploy_deployment))]) { RoleSingletonNoop => true _ => false } } -test fn two_observed_holders_or_an_ownerless_broker_refuse() -> Bool { - let two = match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true), read_on(host: "srv2", holders: [srv2_deploy_deployment], broker: true)]) { - RoleSingletonRefused { cause: RoleSingletonHoldersAmbiguous { observed: os } } => count(os) == 2 +// ONLY ProdRoleUnheld IS "NO PROD". An unrealized or ambiguous holder refuses as a desired-side +// refusal in an EMPTY world (it must not install) and in an OCCUPIED one (it must not remove). +fn fx_unrealized() -> ProdRoleRealization { + prod_role_realization_in(selection: ProdRoleHeldBy { deployment: deployment_id(name: "no-such-deployment" as NonEmptyStr) }, instances: public_builtin_dashboard_instances()) +} + +fn fx_ambiguous() -> ProdRoleRealization { + prod_role_realization_in(selection: prod_role_selection, instances: [srv1_live_dashboard_instance(), srv1_live_dashboard_instance()]) +} + +test fn an_unrealized_or_ambiguous_holder_refuses_as_desired_side_in_empty_and_occupied_worlds() -> Bool { + refused_with(c: decide(desired: fx_unrealized(), readings: empty_world()), label: "desired") + && refused_with(c: decide(desired: fx_unrealized(), readings: srv1_holds()), label: "desired") + && refused_with(c: decide(desired: fx_ambiguous(), readings: empty_world()), label: "desired") + && refused_with(c: decide(desired: fx_ambiguous(), readings: srv1_holds()), label: "desired") +} + +// MULTIPLICITY IS ADJUDICATED BEFORE ANY DEDUPLICATION: the same deployment on two hosts, a deployment +// realized off its own target, and two deployments' stores on one host each refuse. +test fn the_same_deployment_on_two_hosts_refuses() -> Bool { + refused_with(c: decide(desired: prod_role_actuated_realization(), readings: [ + read_on(host: "srv1", sightings: full_on(host: "srv1", d: srv1_daily_workspace_deployment)), + read_on(host: "srv2", sightings: full_on(host: "srv2", d: srv1_daily_workspace_deployment)), + ]), label: "two-hosts") +} + +test fn a_deployment_targeted_at_srv2_but_realized_on_srv1_refuses() -> Bool { + refused_with(c: decide(desired: fx_moved(), readings: [ + read_on(host: "srv1", sightings: full_on(host: "srv1", d: srv2_deploy_deployment)), + read_on(host: "srv2", sightings: []), + ]), label: "misplaced") +} + +test fn two_deployments_stores_on_one_host_refuse() -> Bool { + refused_with(c: decide(desired: prod_role_actuated_realization(), readings: [ + read_on(host: "srv1", sightings: concat(full_on(host: "srv1", d: srv1_daily_workspace_deployment), marker_only_on(host: "srv1", d: srv1_lab_codex_feedback_deployment))), + read_on(host: "srv2", sightings: []), + ]), label: "two-on-one") +} + +test fn two_observed_holders_or_a_broker_without_a_store_refuse() -> Bool { + refused_with(c: decide(desired: fx_moved(), readings: [ + read_on(host: "srv1", sightings: full_on(host: "srv1", d: srv1_daily_workspace_deployment)), + read_on(host: "srv2", sightings: full_on(host: "srv2", d: srv2_deploy_deployment)), + ]), label: "ambiguous") + && refused_with(c: decide(desired: fx_moved(), readings: [read_on(host: "srv1", sightings: [SightedBroker { host: "srv1" as NonEmptyStr }]), read_on(host: "srv2", sightings: [])]), label: "broker-alone") +} + +// AN ABSENT UNIT IS NOT AN ABSENT STORE. The marker keeps an old store visible: during a move and +// under NoProdRole it refuses; a store without its broker and an install interrupted after the +// directories (marker) but before the units are partial and refuse -- never adopted, never installed over. +test fn an_old_store_with_no_unit_or_a_partial_realization_refuses() -> Bool { + let old_store = [read_on(host: "srv1", sightings: marker_only_on(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])] + refused_with(c: decide(desired: fx_moved(), readings: old_store), label: "move") + && refused_with(c: decide(desired: fx_unheld(), readings: old_store), label: "removal") + && refused_with(c: decide(desired: fx_moved(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: [ + SightedDeploymentMember { host: "srv2" as NonEmptyStr, deployment: srv2_deploy_deployment, member: FabricUnitMember }, + SightedDeploymentMember { host: "srv2" as NonEmptyStr, deployment: srv2_deploy_deployment, member: StoreMarkerMember }, + ])]), label: "partial") + && refused_with(c: decide(desired: fx_moved(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: marker_only_on(host: "srv2", d: srv2_deploy_deployment))]), label: "partial") +} + +// ADOPTION, EXACTLY: the desired holder's unit and broker on its own target, nothing elsewhere, only +// the marker missing -> AdoptUnmarkedHolder on that host, and the holder's spec carries the marker as +// an ensured member, so the operator-triggered apply writes it. The same units on the WRONG host refuse +// as misplaced rather than adopt. +fn units_without_marker(host: String, d: DeploymentId) -> List { + [SightedDeploymentMember { host: host as NonEmptyStr, deployment: d, member: FabricUnitMember }, SightedBroker { host: host as NonEmptyStr }] +} + +fn spec_carries_store_marker(spec: DeploymentSpec) -> Bool { + any(spec.steps, s => match s { + Dependency { step } => match step.kind { FabricStorageOwnershipMarker => true _ => false } + ArtifactStep { step: _ } => false + }) +} + +test fn an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker() -> Bool { + let real = prod_role_actuated_realization() + let adopted = match decide(desired: real, readings: [read_on(host: "srv1", sightings: units_without_marker(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])]) { + AdoptUnmarkedHolder { holder, host } => holder.instance.deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" _ => false } - let ownerless = match decide(desired: fx_moved(), readings: [read_on(host: "srv1", holders: [], broker: true), read_on(host: "srv2", holders: [], broker: false)]) { - RoleSingletonRefused { cause: RoleSingletonOwnerUnmapped { host: h } } => (h as String) == "srv1" - _ => false + let live = roster_specs_under(realization: real) |> filter(s => (s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String)) + adopted && count(live) == 1 && all(live, s => spec_carries_store_marker(spec: s)) + && all(roster_specs_under(realization: real) |> filter(s => !((s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String))), s => !spec_carries_store_marker(spec: s)) +} + +test fn an_unmarked_holder_on_the_wrong_host_is_refused_not_adopted() -> Bool { + refused_with(c: decide(desired: prod_role_actuated_realization(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: units_without_marker(host: "srv2", d: srv1_daily_workspace_deployment))]), label: "misplaced") +} + +// ── The admission: the decision is bound to the spec that acts ───────────────────────────────── +fn spec_of(realization: ProdRoleRealization, id: String) -> List { + roster_specs_under(realization: realization) |> filter(s => (s.instance_id as String) == id) +} + +fn admission_refused_as_stale(r: RoleSingletonAdmissionResult) -> Bool { + match r { + RoleSingletonNotAdmitted { cause } => match cause { RoleSingletonStaleSpec { instance_id: _, detail: _ } => true _ => false } + RoleSingletonAdmitted { admission: _ } => false } - two && ownerless +} + +test fn a_stale_spec_is_refused_before_member_planning() -> Bool { + let live_id = srv1_live_dashboard_instance().instance_id as String + let lab_id = srv1_lab_dashboard_instance().instance_id as String + let srv2_id = srv2_deploy_dashboard_instance().instance_id as String + let stale_srv1 = spec_of(realization: prod_role_actuated_realization(), id: live_id) + let moved_install = decide(desired: fx_moved(), readings: empty_world()) + let unheld_noop = decide(desired: fx_unheld(), readings: empty_world()) + all(stale_srv1, s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: fx_moved(), decision: moved_install))) + && all(stale_srv1, s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: fx_unheld(), decision: unheld_noop))) + && all(spec_of(realization: fx_moved(), id: lab_id), s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: fx_moved(), decision: moved_install))) + && all(spec_of(realization: fx_moved(), id: srv2_id), s => match admit_role_singletons(spec: s, desired: fx_moved(), decision: moved_install) { + RoleSingletonAdmitted { admission } => (admitted_spec(admission: admission).instance_id as String) == srv2_id + RoleSingletonNotAdmitted { cause: _ } => false + }) + && count(stale_srv1) == 1 +} + +// ── Role eligibility: only a row of the one desired roster can hold an actuated role ──────────── +test fn a_holder_with_no_desired_row_is_unactuatable_and_holds_nothing() -> Bool { + let mac = prod_role_actuated_realization_in(selection: ProdRoleHeldBy { deployment: macbook_local_deployment }) + (match mac { ProdRoleRealizationRefused { cause: ProdRoleHolderUnactuatable { deployment: d } } => d == macbook_local_deployment _ => false }) + && all(roster_specs_under(realization: mac), s => spec_holds_no_singleton(spec: s)) + && refused_with(c: decide(desired: mac, readings: empty_world()), label: "desired") } // ── The emitted GCP IAM job follows the holder ────────────────────────────────────────────────── @@ -357,25 +530,23 @@ test fn the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_ro // ── THE POSITIVE CONTROL, over the REAL selection and roster ──────────────────────────────────── // Today the daily-workspace deployment (srv1-live) holds the role: its spec carries every singleton -// member, srv1's lab and srv2-deploy carry none, the GCP IAM job runs on srv1, and the real -// realization with the role's singletons observed on srv1-live is a Noop. Without it the REDs above -// would pass for constructions that place nothing anywhere. +// member, srv1's lab and srv2-deploy carry none, the GCP IAM job runs on srv1, the real realization +// with one complete srv1 realization observed is a Noop, and the srv1 spec built now is admitted. test fn the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop() -> Bool { - let real = prod_role_realization() + let real = prod_role_actuated_realization() let specs = roster_specs_under(realization: real) let live_id = srv1_live_dashboard_instance().instance_id as String let live = specs |> filter(s => (s.instance_id as String) == live_id) let others = specs |> filter(s => !((s.instance_id as String) == live_id)) let job_labels = match fleet_converge_gcp_iam_converge_job_under(realization: real).runner { SelfHosted { labels } => labels _ => [] } - let noop = match decide(desired: real, readings: [read_on(host: "srv1", holders: [srv1_daily_workspace_deployment], broker: true), read_on(host: "srv2", holders: [], broker: false)]) { - RoleSingletonNoop => true - _ => false - } + let noop = decide(desired: real, readings: srv1_holds()) + let is_noop = match noop { RoleSingletonNoop => true _ => false } count(live) == 1 - && all(live, s => spec_store_directory_count(spec: s) > 0 && spec_store_artifact_count(spec: s) == 2 && deployment_owns_the_host_approval_broker(spec: s)) + && all(live, s => spec_store_directory_count(spec: s) > 0 && spec_store_artifact_count(spec: s) == 2 && deployment_owns_the_host_approval_broker(spec: s) + && match admit_role_singletons(spec: s, desired: real, decision: noop) { RoleSingletonAdmitted { admission: _ } => true RoleSingletonNotAdmitted { cause: _ } => false }) && all(others, s => spec_holds_no_singleton(spec: s)) && any(job_labels, l => l == (srv1_live_dashboard_instance().host_identity as String)) - && noop + && is_noop } // ── The broker is GIVEN its address, at the emitted-unit boundary ─────────────────────────────── @@ -395,7 +566,7 @@ fn bound(value: String) -> String { test fn the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_refusal() -> Bool { let moved = roster_specs_under(realization: fx_moved()) - let real = roster_specs_under(realization: prod_role_realization()) + let real = roster_specs_under(realization: prod_role_actuated_realization()) let srv2_moved = moved |> filter(s => is_srv2_deploy(spec: s)) let srv1_moved = moved |> filter(s => (s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String)) let live_real = real |> filter(s => (s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String)) diff --git a/dag/test/claim/devboot_subject_identity_witness_test.dag b/dag/test/claim/devboot_subject_identity_witness_test.dag index 57b9f9a2948..3dceb4b2af9 100644 --- a/dag/test/claim/devboot_subject_identity_witness_test.dag +++ b/dag/test/claim/devboot_subject_identity_witness_test.dag @@ -15,6 +15,7 @@ import std.content_hash { import extdeps.git.object_store { GitObjectId, git_object_id_from_untagged_hex } import gunbc.host_layout { srv1_devboot_artifact_store_root } import gunbc.live_deploy.spec { + EnsuredOwnershipMarker, DeploymentStep, Dependency, ArtifactStep, @@ -729,6 +730,7 @@ fn devboot_ensured_step_projects_layout(acc: Int, step: DeploymentDependencyStep if (path as String) == (srv1_devboot_artifact_store_root as String) { acc + 1 } else { acc } EnsuredPackage { package } => acc EnsuredManagedHostDirectory { admission: _ } => acc + EnsuredOwnershipMarker { path: _, owner: _ } => acc } } diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index 688a359a75b..a8ca3e16a96 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -62,7 +62,7 @@ import gunbc.live_deploy.spec { deployment_step_teardown_disposition, gunbc_service_user, } -import gunbc.live_deploy.desired { deployment_spec_srv1, srv1_lab_desired_deployment, selected_deployment_spec } +import gunbc.live_deploy.desired { deployment_spec_srv1, srv1_lab_desired_deployment, selected_deployment_spec, prod_role_actuated_realization } import gunbc.host_layout { srv1_dispatch_attempt_state_root, belt_spawn_workdir_env_var, srv1_attempt_state_directory } import gunbc.managed_directory { managed_directory_mode_octal, @@ -1114,6 +1114,7 @@ fn preserve_removal_client_target() -> DeploymentHostTarget { fn spec_with_target(target: DeploymentHostTarget) -> DeploymentSpec { deployment_spec_for( + realization: prod_role_actuated_realization(), instance: srv1_live_dashboard_instance(), target: target, names: srv1_live_deployment_names(instance: srv1_live_dashboard_instance()), From 2c6127d4cdb25e5a28b3637a13138012bce9b20a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 15:28:31 +0000 Subject: [PATCH 24/48] serve_function control: one claim per instance + a roster-coverage claim (floor: one conjunction exceeded the new-witness budget, 87009 > 72300 steps) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../serve_function_binding_witness_test.dag | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/dag/test/claim/roadmap/serve_function_binding_witness_test.dag b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag index 2aa9e858eb4..16b0f7b1b01 100644 --- a/dag/test/claim/roadmap/serve_function_binding_witness_test.dag +++ b/dag/test/claim/roadmap/serve_function_binding_witness_test.dag @@ -54,6 +54,32 @@ fn instance_serves_itself(instance: HostDashboardInstance) -> Bool { }) } -test fn every_instances_serve_function_serves_that_instance() -> Bool { - all(public_builtin_dashboard_instances(), i => instance_serves_itself(instance: i)) +// ONE CLAIM PER INSTANCE: the cases are independent, so one conjunction would force six full serves +// through one budget while losing no coverage by splitting (DESIGN 3: a witness discriminates at one +// interface). The roster claim keeps completeness: every instance's serve_function names exactly one +// declared handler, so a seventh instance cannot slip past the per-instance claims unbound. +fn the_instance(id: String) -> List { + public_builtin_dashboard_instances() |> filter(i => (i.instance_id as String) == id) } + +fn serves_itself_by_id(id: String) -> Bool { + let found = the_instance(id: id) + count(found) == 1 && all(found, i => instance_serves_itself(instance: i)) +} + +test fn every_instance_names_exactly_one_declared_handler() -> Bool { + count(public_builtin_dashboard_instances()) == count(declared_serve_handlers()) + && all(public_builtin_dashboard_instances(), i => count(filter(declared_serve_handlers(), h => h.name == (i.serve_function as String))) == 1) +} + +test fn srv1_live_serves_itself() -> Bool { serves_itself_by_id(id: "srv1-live") } + +test fn macbook_local_serves_itself() -> Bool { serves_itself_by_id(id: "macbook-local") } + +test fn srv1_lab_serves_itself() -> Bool { serves_itself_by_id(id: "srv1-lab-codex-feedback-v0") } + +test fn srv2_preview_serves_itself() -> Bool { serves_itself_by_id(id: "srv2-preview-codex-feedback-v0") } + +test fn srv2_deploy_serves_itself() -> Bool { serves_itself_by_id(id: "srv2-deploy") } + +test fn srv2_lab_serves_itself() -> Bool { serves_itself_by_id(id: "srv2-lab-v1-deletion") } From 6a7645cb55ab80f2d5b9d809b06e4f3036586cf2 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 16:10:49 +0000 Subject: [PATCH 25/48] D2 adoption: only the holder spec on the observed host is admitted; apply receipt carries role_singleton_disposition (schema v3); AdoptUnmarkedHolder carries its std.dissolution retirement row; plan refreshed Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/live_deploy/apply.dag | 51 ++++++++++--- dag/gunbc/live_deploy/apply_receipt.dag | 72 ++++++++++++++++++- .../role_singleton_convergence.dag | 24 ++++++- .../claim/deployment_risk_witness_test.dag | 56 +++++++++++++-- .../apply_receipt_witness_test.dag | 2 + ...launch_deployment_receipt_witness_test.dag | 5 +- docs/plans/deployment-risk-conformance.md | 16 ++++- 7 files changed, 200 insertions(+), 26 deletions(-) diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index 4ba4b937a66..b4f57afe3d4 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -68,8 +68,9 @@ import gunbc.host_effect_realize { host_effect_apply_gated } import gunbc.live_deploy.member_observe { deployment_effect_plan, deployment_identity_readback, IdentityReadbackConverged, IdentityReadbackDrift, IdentityReadbackRefused } import gunbc.live_deploy.member_identity { EffectsDerived, EffectsRefused, decision_label } import gunbc.live_deploy.role_singleton_convergence { - assess_and_admit, role_singleton_refusal_detail, admitted_spec, + assess_and_admit, role_singleton_refusal_detail, admitted_spec, admitted_decision, RoleSingletonAdmission, RoleSingletonAdmitted, RoleSingletonNotAdmitted, + RoleSingletonNoop, RoleSingletonInstall, AdoptUnmarkedHolder, RoleSingletonRefused, } import gunbc.auth.approval_broker_cutover { CutoverAdmission, StageAdmitted, StageRefused, @@ -127,6 +128,7 @@ import gunbc.fleet_converge_receipt { fleet_converge_plan_receipt_path, fleet_converge_apply_receipt_path, } import gunbc.live_deploy.apply_receipt { + LiveDeployRoleSingletonDisposition, RoleSingletonsNoop, RoleSingletonsInstalled, RoleSingletonsAdopted, RoleSingletonsRefusedBeforeApply, LiveDeployApplyReceipt, LiveDeployFleetProvenance, LiveDeployApplyOutcome, LiveDeployReadinessOutcome, read_live_deploy_fleet_provenance, FleetProvenancePresent, FleetProvenanceAbsent, LiveDeployFleetProvenance, @@ -1040,6 +1042,18 @@ fn live_deploy_write_transaction_receipt(spec: DeploymentSpec, receipt: LiveDepl } } +// THE ADMITTED DECISION, AS THE RECEIPT RECORDS IT. Adopted names the holder and host whose marker the +// apply wrote, so the retirement of AdoptUnmarkedHolder (its FrontierRow) has an exact-host receipt to +// point at; Installed names the first install; Noop is everything else that was admitted. +fn role_singleton_disposition_of(admission: RoleSingletonAdmission) -> LiveDeployRoleSingletonDisposition { + match admitted_decision(admission: admission) { + RoleSingletonNoop => RoleSingletonsNoop + RoleSingletonInstall { holder } => RoleSingletonsInstalled { deployment: holder.instance.deployment.name as String, host: holder.instance.host_identity as String } + AdoptUnmarkedHolder { holder, host, retirement: _ } => RoleSingletonsAdopted { deployment: holder.instance.deployment.name as String, host: host as String } + RoleSingletonRefused { cause } => RoleSingletonsRefusedBeforeApply { detail: role_singleton_refusal_detail(cause: cause) } + } +} + fn live_deploy_transaction_receipt_for( spec: DeploymentSpec, run: ActionsRunBinding, @@ -1047,6 +1061,7 @@ fn live_deploy_transaction_receipt_for( candidate: CandidateRelease, candidate_tree: String, apply_outcome: LiveDeployApplyOutcome, + role_singleton_disposition: LiveDeployRoleSingletonDisposition, readiness: LiveDeployReadinessOutcome, provenance: LiveDeployFleetProvenance, ) -> LiveDeployApplyReceipt { @@ -1059,6 +1074,7 @@ fn live_deploy_transaction_receipt_for( candidate_surface_identity: candidate.expected_surface_identity.digest as String, candidate_tree_oid: candidate_tree, apply_outcome: apply_outcome, + role_singleton_disposition: role_singleton_disposition, readiness: readiness, provenance: provenance, completed_at: clock_now_probed_at_or_unknown() as String, @@ -1078,20 +1094,33 @@ fn live_deploy_transaction_decided( if !reconciliation_converged(r: preflight) { live_deploy_write_transaction_receipt( spec: spec, - receipt: live_deploy_transaction_receipt_for(spec: spec, run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, apply_outcome: LiveDeployApplyNotConverged { reason: (match preflight { NotConverged { reason: why, applied: _ } => why Converged { evidence: _, applied: _ } => "preflight" }) }, readiness: LiveDeployReadinessRefused { detail: "not observed: preflight refused" }, provenance: provenance), + receipt: live_deploy_transaction_receipt_for(spec: spec, run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, apply_outcome: LiveDeployApplyNotConverged { reason: (match preflight { NotConverged { reason: why, applied: _ } => why Converged { evidence: _, applied: _ } => "preflight" }) }, role_singleton_disposition: RoleSingletonsRefusedBeforeApply { detail: "not assessed: the access preflight refused first" }, readiness: LiveDeployReadinessRefused { detail: "not observed: preflight refused" }, provenance: provenance), exit: live_deploy_reconciliation_exit(r: preflight), ) } else { - let applied = live_deploy_apply_with_access(spec: spec, access: access, candidate: candidate) - let outcome = match applied { - Converged { evidence: _, applied: _ } => LiveDeployApplyConverged - NotConverged { reason: why, applied: _ } => LiveDeployApplyNotConverged { reason: why } + match assess_and_admit(spec: spec, desired: prod_role_actuated_realization(), own: access.transport) { + RoleSingletonNotAdmitted { cause } => { + let why = join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], "") + live_deploy_write_transaction_receipt( + spec: spec, + receipt: live_deploy_transaction_receipt_for(spec: spec, run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, apply_outcome: LiveDeployApplyNotConverged { reason: why }, role_singleton_disposition: RoleSingletonsRefusedBeforeApply { detail: why }, readiness: LiveDeployReadinessRefused { detail: "not observed: the role-singleton assessment refused before any member was planned" }, provenance: provenance), + exit: exit_failure(reason: why), + ) + } + RoleSingletonAdmitted { admission } => { + let admitted = admitted_spec(admission: admission) + let applied = live_deploy_apply_admitted_via_transport(admission: admission, candidate: candidate, transport: access.transport, access: access) + let outcome = match applied { + Converged { evidence: _, applied: _ } => LiveDeployApplyConverged + NotConverged { reason: why, applied: _ } => LiveDeployApplyNotConverged { reason: why } + } + live_deploy_write_transaction_receipt( + spec: admitted, + receipt: live_deploy_transaction_receipt_for(spec: admitted, run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, apply_outcome: outcome, role_singleton_disposition: role_singleton_disposition_of(admission: admission), readiness: live_deploy_post_apply_readiness(spec: admitted, access: access), provenance: provenance), + exit: live_deploy_reconciliation_exit(r: applied), + ) + } } - live_deploy_write_transaction_receipt( - spec: spec, - receipt: live_deploy_transaction_receipt_for(spec: spec, run: run, expected: expected, candidate: candidate, candidate_tree: candidate_tree, apply_outcome: outcome, readiness: live_deploy_post_apply_readiness(spec: spec, access: access), provenance: provenance), - exit: live_deploy_reconciliation_exit(r: applied), - ) } } diff --git a/dag/gunbc/live_deploy/apply_receipt.dag b/dag/gunbc/live_deploy/apply_receipt.dag index 71ed5e313b9..c9713ae592e 100644 --- a/dag/gunbc/live_deploy/apply_receipt.dag +++ b/dag/gunbc/live_deploy/apply_receipt.dag @@ -47,7 +47,7 @@ import gunbc.actions_run_binding { // and '}'), so the candidate side and the deployed side // (gunbc.live_deploy.deployed_tree_observation deployed_tree_root_tree_argv) read the same object // the same way. -data live_deploy_apply_receipt_schema: String = "live-deploy-apply-receipt/v2" +data live_deploy_apply_receipt_schema: String = "live-deploy-apply-receipt/v3" fn live_deploy_apply_receipt_artifact_name_for(instance_id: String) -> String { join(["live-deploy-", instance_id, "-receipt"], "") @@ -119,6 +119,66 @@ type LiveDeployReadinessOutcome = LiveDeployReadinessObserved { revision: String, surface: String } | LiveDeployReadinessRefused { detail: String } +// WHAT THE ROLE-SINGLETON ASSESSMENT ADMITTED THIS APPLY TO DO (gunbc.live_deploy.role_singleton_convergence), +// carried in the receipt so an adoption -- the one apply that writes a pre-marker holder's store marker -- +// is visible and countable, and a refusal before any member was planned is told apart from a failed apply. +type LiveDeployRoleSingletonDisposition + = RoleSingletonsNoop + | RoleSingletonsInstalled { deployment: String, host: String } + | RoleSingletonsAdopted { deployment: String, host: String } + | RoleSingletonsRefusedBeforeApply { detail: String } + +fn live_deploy_role_singleton_disposition_text(d: LiveDeployRoleSingletonDisposition) -> String { + match d { + RoleSingletonsNoop => "noop" + RoleSingletonsInstalled { deployment, host } => join(["installed:", deployment, "@", host], "") + RoleSingletonsAdopted { deployment, host } => join(["adopted:", deployment, "@", host], "") + RoleSingletonsRefusedBeforeApply { detail } => join(["refused:", detail], "") + } +} + +fn live_deploy_role_singleton_disposition_json(d: LiveDeployRoleSingletonDisposition) -> JsonValue { + match d { + RoleSingletonsNoop => json_object(members: [json_kv(key: "disposition", value: json_string(s: "noop"))]) + RoleSingletonsInstalled { deployment, host } => json_object(members: [json_kv(key: "disposition", value: json_string(s: "installed")), json_kv(key: "deployment", value: json_string(s: deployment)), json_kv(key: "host", value: json_string(s: host))]) + RoleSingletonsAdopted { deployment, host } => json_object(members: [json_kv(key: "disposition", value: json_string(s: "adopted")), json_kv(key: "deployment", value: json_string(s: deployment)), json_kv(key: "host", value: json_string(s: host))]) + RoleSingletonsRefusedBeforeApply { detail } => json_object(members: [json_kv(key: "disposition", value: json_string(s: "refused")), json_kv(key: "detail", value: json_string(s: detail))]) + } +} + +type LiveDeployDispositionDecode + = DispositionDecoded { disposition: LiveDeployRoleSingletonDisposition } + | DispositionUnreadable { reason: String } + +fn live_deploy_role_singleton_disposition_of_member(doc: JsonValue) -> LiveDeployDispositionDecode { + match json_object_unique_member(v: doc, key: "role_singleton_disposition") { + JsonMemberAbsent => DispositionUnreadable { reason: "role_singleton_disposition is missing" } + JsonMemberDuplicated { count: c } => DispositionUnreadable { reason: join(["role_singleton_disposition appears ", to_string(c), " times; the member must be unique"], "") } + JsonMemberNotAnObject => DispositionUnreadable { reason: "the receipt document is not an object at role_singleton_disposition" } + JsonMemberFound { value: d } => match json_required_string_member(doc: d, key: "disposition") { + JsonStringMemberRefused { reason: r } => DispositionUnreadable { reason: r } + JsonStringMemberFound { value: kind } => + if kind == "noop" { DispositionDecoded { disposition: RoleSingletonsNoop } } + else if kind == "refused" { + match json_required_string_member(doc: d, key: "detail") { + JsonStringMemberRefused { reason: r } => DispositionUnreadable { reason: r } + JsonStringMemberFound { value: detail } => DispositionDecoded { disposition: RoleSingletonsRefusedBeforeApply { detail: detail } } + } + } else if kind == "installed" || kind == "adopted" { + match json_required_string_member(doc: d, key: "deployment") { + JsonStringMemberRefused { reason: r } => DispositionUnreadable { reason: r } + JsonStringMemberFound { value: dep } => match json_required_string_member(doc: d, key: "host") { + JsonStringMemberRefused { reason: r } => DispositionUnreadable { reason: r } + JsonStringMemberFound { value: host } => + if kind == "installed" { DispositionDecoded { disposition: RoleSingletonsInstalled { deployment: dep, host: host } } } + else { DispositionDecoded { disposition: RoleSingletonsAdopted { deployment: dep, host: host } } } + } + } + } else { DispositionUnreadable { reason: join(["live-deploy receipt role_singleton_disposition is not one of noop|installed|adopted|refused: ", kind], "") } } + } + } +} + type LiveDeployApplyReceipt { run: ActionsRunBinding target: String @@ -128,6 +188,7 @@ type LiveDeployApplyReceipt { candidate_surface_identity: String candidate_tree_oid: String apply_outcome: LiveDeployApplyOutcome + role_singleton_disposition: LiveDeployRoleSingletonDisposition readiness: LiveDeployReadinessOutcome provenance: LiveDeployFleetProvenance completed_at: String @@ -152,6 +213,7 @@ fn live_deploy_apply_receipt_identity_text(r: LiveDeployApplyReceipt) -> String actions_run_binding_identity_text(binding: r.run), "|", r.target, "|", r.instance_id, "|", r.expected_revision, "|", r.candidate_revision, "|", r.candidate_surface_identity, "|", r.candidate_tree_oid, "|", live_deploy_apply_outcome_text(o: r.apply_outcome), "|", + live_deploy_role_singleton_disposition_text(d: r.role_singleton_disposition), "|", live_deploy_readiness_text(o: r.readiness), "|", r.provenance.plan_run_id, "|", r.provenance.apply_run_id, "|", r.provenance.plan_artifact_hash, "|", r.provenance.plan_receipt_identity, "|", r.provenance.apply_receipt_identity, "|", @@ -161,7 +223,7 @@ fn live_deploy_apply_receipt_identity_text(r: LiveDeployApplyReceipt) -> String fn live_deploy_apply_receipt_identity_hex(r: LiveDeployApplyReceipt) -> String { content_hash_tagged_structural( - tag: "live-deploy-apply-receipt-v2" as NonEmptyStr, + tag: "live-deploy-apply-receipt-v3" as NonEmptyStr, payload: content_hash_atom(value: live_deploy_apply_receipt_identity_text(r: r) as NonEmptyStr), ).digest as String } @@ -204,6 +266,7 @@ fn live_deploy_apply_receipt_json(r: LiveDeployApplyReceipt) -> JsonValue { json_kv(key: "candidate_surface_identity", value: json_string(s: r.candidate_surface_identity)), json_kv(key: "candidate_tree_oid", value: json_string(s: r.candidate_tree_oid)), json_kv(key: "apply_outcome", value: live_deploy_apply_outcome_json(o: r.apply_outcome)), + json_kv(key: "role_singleton_disposition", value: live_deploy_role_singleton_disposition_json(d: r.role_singleton_disposition)), json_kv(key: "readiness", value: live_deploy_readiness_json(o: r.readiness)), json_kv(key: "provenance", value: json_object(members: [ json_kv(key: "plan_run_id", value: json_string(s: r.provenance.plan_run_id)), @@ -356,6 +419,9 @@ fn live_deploy_apply_receipt_decode(raw: String) -> LiveDeployApplyReceiptDecode match live_deploy_apply_outcome_of_member(doc: doc) { OutcomeUnreadable { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } OutcomeDecoded { outcome: apply_outcome } => + match live_deploy_role_singleton_disposition_of_member(doc: doc) { + DispositionUnreadable { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } + DispositionDecoded { disposition: role_singleton_disposition } => match live_deploy_readiness_of_member(doc: doc) { ReadinessUnreadable { reason: r } => LiveDeployApplyReceiptUnreadable { reason: r } ReadinessDecoded { readiness: readiness } => @@ -374,6 +440,7 @@ fn live_deploy_apply_receipt_decode(raw: String) -> LiveDeployApplyReceiptDecode candidate_surface_identity: surface, candidate_tree_oid: tree_oid, apply_outcome: apply_outcome, + role_singleton_disposition: role_singleton_disposition, readiness: readiness, provenance: provenance, completed_at: completed_at, @@ -391,6 +458,7 @@ fn live_deploy_apply_receipt_decode(raw: String) -> LiveDeployApplyReceiptDecode } } } + } } } } diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index 36796bf49af..ca61d034510 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -1,6 +1,8 @@ module gunbc.live_deploy.role_singleton_convergence import std.types { String, NonEmptyStr, List, Bool, Int } +import std.dissolution { DissolutionCondition, unbound_dissolution } +import std.roster_frontier { FrontierRow, frontier_row_path } import v2.std.collection { Present, Absent } import gunbc.deployment_risk { DeploymentId } import gunbc.roadmap_dashboard_instance { @@ -98,6 +100,19 @@ type RoleSingletonRefusal | RoleSingletonPartial { deployment: DeploymentId, host: NonEmptyStr, missing: List } | RoleSingletonStaleSpec { instance_id: NonEmptyStr, detail: String } +// ── The adoption arm's retirement ─────────────────────────────────────────────────────────────── +// THE ARM CARRIES THIS ROW (AdoptUnmarkedHolder.retirement), so every adoption names the condition +// that deletes it. After adoption a missing marker is corruption or partial state, never re-adopted. +data adopt_unmarked_holder_dissolution: DissolutionCondition = unbound_dissolution( + description: "TRIGGER: every gunbc.live_deploy.desired DesiredDeployment realization carries a store ownership marker -- shown by an exact-host live_deploy apply receipt whose role_singleton_disposition records RoleSingletonsAdopted for the holder, followed by an independent observation (gunbc.live_deploy.role_singleton_convergence role_singleton_observation_of) returning exactly one complete, marked realization and RoleSingletonNoop. Then delete the AdoptUnmarkedHolder arm. SUFFICIENT FOR: that receipt and that observation; a marker written by hand, or an observation that is not independent of the apply, is not this trigger." as NonEmptyStr +) + +data adopt_unmarked_holder_frontier: FrontierRow = frontier_row_path( + path: "gunbc.live_deploy.role_singleton_convergence AdoptUnmarkedHolder", + reason: "a one-time migration of the realization that predates the store ownership marker; any other partial state refuses as RoleSingletonPartial", + dissolution: adopt_unmarked_holder_dissolution, +) + // ── What is decided ───────────────────────────────────────────────────────────────────────────── // AdoptUnmarkedHolder IS THE ADOPTION OF A PRE-MARKER REALIZATION. It exists for realizations installed // before the store carried an ownership marker: the desired holder's fabric unit and broker are observed @@ -108,7 +123,7 @@ type RoleSingletonRefusal type RoleSingletonConvergence = RoleSingletonNoop | RoleSingletonInstall { holder: ProdRoleHolder } - | AdoptUnmarkedHolder { holder: ProdRoleHolder, host: NonEmptyStr } + | AdoptUnmarkedHolder { holder: ProdRoleHolder, host: NonEmptyStr, retirement: FrontierRow } | RoleSingletonRefused { cause: RoleSingletonRefusal } // ── The observation ───────────────────────────────────────────────────────────────────────────── @@ -271,7 +286,7 @@ fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingle let missing = missing_members(r: r) if count(missing) == 0 { RoleSingletonNoop } else if count(missing) == 1 && all(missing, m => member_eq(a: m, b: StoreMarkerMember)) { - AdoptUnmarkedHolder { holder: holder, host: r.host } + AdoptUnmarkedHolder { holder: holder, host: r.host, retirement: adopt_unmarked_holder_frontier } } else { RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } } @@ -327,7 +342,10 @@ fn admit_role_singletons(spec: DeploymentSpec, desired: ProdRoleRealization, dec match decision { RoleSingletonRefused { cause } => RoleSingletonNotAdmitted { cause: cause } RoleSingletonNoop => if agrees { admitted } else { stale_holding } - AdoptUnmarkedHolder { holder: _, host: _ } => if agrees { admitted } else { stale_holding } + AdoptUnmarkedHolder { holder: _, host, retirement: _ } => + if !agrees { stale_holding } + else if desired_is_this_spec && (spec.target.ssh_host as String) == (host as String) { admitted } + else { stale(spec: spec, detail: "the holder's marker adoption is pending; only the observed holder spec on the observed host may act") } RoleSingletonInstall { holder: _ } => if !agrees { stale_holding } else if desired_is_this_spec { admitted } diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index f45422613f6..1a4daf2810c 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -58,12 +58,18 @@ import gunbc.live_deploy.spec { FabricStorageServeMapping, FabricStorageStoreDirectory, FabricStorageOwnershipMarker, + EnsuredOwnershipMarker, + deployment_srv1_target, + srv1_live_deployment_names, deployment_spec_for, deployment_owns_the_host_approval_broker, deployment_fabric_storage_door_socket, } -import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization, prod_role_actuated_realization_in } +import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } +import gunbc.live_deploy.apply { role_singleton_disposition_of } +import gunbc.live_deploy.apply_receipt { RoleSingletonsAdopted } import gunbc.live_deploy.role_singleton_convergence { + adopt_unmarked_holder_frontier, RoleSingletonConvergence, RoleSingletonNoop, RoleSingletonInstall, @@ -106,6 +112,7 @@ import gunbc.fabric_storage_placement { fabric_storage_placement_under, fabric_storage_store_directories_for, fabric_storage_door_socket, + fabric_storage_ownership_marker_path, } import gunbc.auth.approval_request_client { ApprovalLoopbackOriginResolved, @@ -348,7 +355,7 @@ fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { } RoleSingletonNoop => false RoleSingletonInstall { holder: _ } => false - AdoptUnmarkedHolder { holder: _, host: _ } => false + AdoptUnmarkedHolder { holder: _, host: _, retirement: _ } => false } } @@ -456,15 +463,50 @@ fn spec_carries_store_marker(spec: DeploymentSpec) -> Bool { }) } +fn adopt_world() -> List { + [read_on(host: "srv1", sightings: units_without_marker(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])] +} + +fn spec_marker_ensure_is_exact(spec: DeploymentSpec) -> Bool { + any(spec.steps, s => match s { + Dependency { step } => match step.subject { + EnsuredOwnershipMarker { path, owner: _ } => (path as String) == (fabric_storage_ownership_marker_path(instance: srv1_live_dashboard_instance()) as String) + _ => false + } + ArtifactStep { step: _ } => false + }) +} + +// ADOPTION, EXACTLY, AT THE ADMISSION AND RECEIPT BOUNDARIES: (a) the exact holder spec on the observed +// host is admitted; (b) every non-holder spec refuses as stale, so a lab or srv2 apply cannot ride the +// adoption and re-adopt forever; (c) the holder spec pointed at the wrong target refuses; (d) the +// admitted plan carries the exact ownership-marker ensure and the receipt disposition names Adopted; +// (e) once the marker is observed the same holder converges as Noop. The arm carries its retirement row. test fn an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker() -> Bool { let real = prod_role_actuated_realization() - let adopted = match decide(desired: real, readings: [read_on(host: "srv1", sightings: units_without_marker(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])]) { - AdoptUnmarkedHolder { holder, host } => holder.instance.deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" + let decision = decide(desired: real, readings: adopt_world()) + let adopted = match decision { + AdoptUnmarkedHolder { holder, host, retirement } => + holder.instance.deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" + && (retirement.path as String) == (adopt_unmarked_holder_frontier.path as String) _ => false } - let live = roster_specs_under(realization: real) |> filter(s => (s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String)) - adopted && count(live) == 1 && all(live, s => spec_carries_store_marker(spec: s)) - && all(roster_specs_under(realization: real) |> filter(s => !((s.instance_id as String) == (srv1_live_dashboard_instance().instance_id as String))), s => !spec_carries_store_marker(spec: s)) + let live_id = srv1_live_dashboard_instance().instance_id as String + let holder_admitted = all(spec_of(realization: real, id: live_id), s => match admit_role_singletons(spec: s, desired: real, decision: decision) { + RoleSingletonAdmitted { admission } => + spec_marker_ensure_is_exact(spec: admitted_spec(admission: admission)) + && match role_singleton_disposition_of(admission: admission) { + RoleSingletonsAdopted { deployment, host } => deployment == (srv1_daily_workspace_deployment.name as String) && host == "srv1" + _ => false + } + RoleSingletonNotAdmitted { cause: _ } => false + }) + let others_refused = all(roster_specs_under(realization: real) |> filter(s => !((s.instance_id as String) == live_id)), s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: real, decision: decision))) + let wrong_target = deployment_spec_for(realization: real, instance: srv1_live_dashboard_instance(), target: deployment_srv2_target(), names: srv1_live_deployment_names(instance: srv1_live_dashboard_instance())) + let after = match decide(desired: real, readings: srv1_holds()) { RoleSingletonNoop => true _ => false } + adopted && count(spec_of(realization: real, id: live_id)) == 1 && holder_admitted && others_refused + && admission_refused_as_stale(r: admit_role_singletons(spec: wrong_target, desired: real, decision: decision)) + && after } test fn an_unmarked_holder_on_the_wrong_host_is_refused_not_adopted() -> Bool { diff --git a/dag/test/claim/live_deploy/apply_receipt_witness_test.dag b/dag/test/claim/live_deploy/apply_receipt_witness_test.dag index 541da29095d..8f04dfd56a6 100644 --- a/dag/test/claim/live_deploy/apply_receipt_witness_test.dag +++ b/dag/test/claim/live_deploy/apply_receipt_witness_test.dag @@ -20,6 +20,7 @@ fn fixture_dashboard_receipt() -> LiveDeployApplyReceipt { candidate_surface_identity: "0011223344556677", candidate_tree_oid: apply_receipt_fixture_tree, apply_outcome: LiveDeployApplyConverged, + role_singleton_disposition: RoleSingletonsNoop, readiness: LiveDeployReadinessObserved { revision: apply_receipt_fixture_r, surface: "0011223344556677" }, provenance: LiveDeployFleetProvenance { plan_run_id: "1001", apply_run_id: "1002", plan_artifact_hash: "a1b2c3d4e5f60718", plan_receipt_identity: "1111111111111111", apply_receipt_identity: "2222222222222222" }, completed_at: "2026-08-30T12:00:00Z", @@ -45,6 +46,7 @@ test fn witness_dashboard_receipt_not_converged_round_trips_its_reason() -> Bool run: base.run, target: base.target, instance_id: base.instance_id, expected_revision: base.expected_revision, candidate_revision: base.candidate_revision, candidate_surface_identity: base.candidate_surface_identity, candidate_tree_oid: base.candidate_tree_oid, apply_outcome: LiveDeployApplyNotConverged { reason: "terminal no-op: this run installed nothing" }, + role_singleton_disposition: RoleSingletonsNoop, readiness: LiveDeployReadinessRefused { detail: "not observed" }, provenance: base.provenance, completed_at: base.completed_at, } diff --git a/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag b/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag index b8997c5fe53..312ce6358c7 100644 --- a/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag @@ -1,5 +1,7 @@ module test.claim.roadmap.roadmap_launch_deployment_receipt_witness +import gunbc.live_deploy.apply_receipt { RoleSingletonsNoop } + import gunbc.live_deploy.desired { deployment_spec_srv1 } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -118,6 +120,7 @@ fn rlm_dashboard(run: ActionsRunBinding, expected: String) -> LiveDeployApplyRec candidate_surface_identity: rlm_fixture_surface, candidate_tree_oid: rlm_fixture_tree, apply_outcome: LiveDeployApplyConverged, + role_singleton_disposition: RoleSingletonsNoop, readiness: LiveDeployReadinessObserved { revision: expected, surface: rlm_fixture_surface }, provenance: LiveDeployFleetProvenance { plan_run_id: "1001", apply_run_id: "1002", plan_artifact_hash: rlm_fixture_plan_hash, @@ -524,7 +527,7 @@ test fn witness_dashboard_citing_another_plan_receipt_is_incomplete_at_dashboard let d = LiveDeployApplyReceipt { run: base.run, target: base.target, instance_id: base.instance_id, expected_revision: base.expected_revision, candidate_revision: base.candidate_revision, candidate_surface_identity: base.candidate_surface_identity, candidate_tree_oid: base.candidate_tree_oid, - apply_outcome: base.apply_outcome, readiness: base.readiness, + apply_outcome: base.apply_outcome, role_singleton_disposition: base.role_singleton_disposition, readiness: base.readiness, provenance: LiveDeployFleetProvenance { plan_run_id: "1001", apply_run_id: "1002", plan_artifact_hash: rlm_fixture_plan_hash, plan_receipt_identity: "9999999999999999", apply_receipt_identity: rlm_apply_identity(), diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index 9dfe63d3f27..a9b5008c9ce 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -23,8 +23,20 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - Declared frontier (review 74937; merry-tern-58 ruling 2026-10-04): `DeploymentRiskInterface`, `DeploymentHarmMagnitudeBet` and the operator-disposition gate are **deferred until an operator policy differs by class**. Today's policy (gunbc#13124: automatic repair is "no" for every deployment) is class-independent, so a gate consulting the class would decide nothing (§3c decoration), and a class-dependent policy is the operator's call. No carrier is minted before then. Design note for when the bet lands: its magnitude is an order of magnitude over `Duration` (today `std.types` `Duration`; std has no order-of-magnitude carrier yet), e.g. a decade exponent beside its unit, typed with `extdeps.external_authority` `CitedFigureStanding`, and it never decides the class. 1b. **Machine qualification migration**: one motion over machine_intake (re-census on main first; coordinate warm-crane-577's mtcollins1 untangle lane). eager-gull-22 handed it to this lane. 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. - - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). A spec carries it as `gunbc.live_deploy.spec` `RoleSingletonHolding`; the broker unit and front door and the fabric store's unit, route, socket, root and directories are built from it and move between targets as one subject (no instance-id ownership; the `"srv1-live"` literal and `operator_host_srv1` are gone). Also following the realization: the approval client origin, the GCP IAM converge job's runner (refused job under NoProdRole) and the approvals app's host (`gunbc.auth.approval_broker_placement`), and `DashboardProtectedSibling` (same-host siblings whose class is `ProdRisk`). Broker-side modules do NOT resolve the role (it would undo #13132's narrowed native-broker closure): the role decides where the broker is installed, the serving broker reads its own host at serve time (`approval_broker_serving_base_url`), and identity trust is derived from the broker unit's own bind row (`approval_broker_listen_host`). - - Role-singleton convergence (`gunbc.live_deploy.role_singleton_convergence`, gating every apply): desired realization × an observation of every roster host, reusing `member_observe` legs. `RoleSingletonNoop` | `RoleSingletonInstall` (only when nothing is observed anywhere) | `RoleSingletonRefused` with `RoleSingletonMoveUnimplemented`, `RoleSingletonRemovalUnimplemented`, `RoleSingletonHostUnobservable` (unobserved is never absent), `RoleSingletonHoldersAmbiguous`, `RoleSingletonOwnerUnmapped`. **Next trigger:** Move and Remove actuations (persistent-state move, singleton removal) become arms beside the existing ones. + - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous` | `ProdRoleHolderUnactuatable`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). **Actuatability join:** `gunbc.live_deploy.desired` `prod_role_actuated_realization` joins the holder to the one `desired_deployment_roster`; a holder with no row (MacBook, srv2-preview, srv2's lab) refuses as `ProdRoleHolderUnactuatable`. Every role follower reads the joined realization. A spec carries the token as `gunbc.live_deploy.spec` `RoleSingletonHolding`, and the singleton members -- broker unit and front door, plus fabric store unit, route, socket, root, directories and **ownership marker** -- are built from it and move between targets as one subject. The marker is the dashboard ownership-marker rule (`dashboard_ownership_marker_in`) applied to the store root, so an old store with no unit stays observable. The broker is GIVEN its address (`GUNBC_APPROVAL_BROKER_BASE_URL`, from `gunbc.auth.approval_broker_placement`); broker-side modules never resolve the role (#13132's closure). Also following the realization: approval client origin, the GCP IAM job's runner, the approvals app host, `DashboardProtectedSibling`. + - Role-singleton convergence (`gunbc.live_deploy.role_singleton_convergence`): + - **Observation.** It sits behind `RoleSingletonReadingSource`: an ssh probe today, host self-reports later, and which one production uses is still owed by the operator. Sightings are kept at (host, deployment, member) until multiplicity is adjudicated, and every roster deployment's broker, fabric unit and marker are probed on every roster host. + - **Decision.** Possible outcomes: + - `RoleSingletonNoop`: exactly one complete realization of the desired holder on its target. + - `RoleSingletonInstall`: nothing observed anywhere. + - `AdoptUnmarkedHolder`: see below. + - `RoleSingletonRefused`, with one of these causes: move, removal, `RoleSingletonDesiredRealizationRefused`, unobservable, two-hosts, misplaced, two-on-one, ambiguous, broker-without-store, partial, stale-spec. + + Only `ProdRoleUnheld` means no prod. + - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec. Apply consumes only `admitted_spec`, so a stale spec, a holding spec under NoProdRole, or a non-holder during Install refuses before member planning. The apply receipt records `role_singleton_disposition` (Noop | Installed | Adopted | RefusedBeforeApply). + - **Adoption scope.** `AdoptUnmarkedHolder { holder, host, retirement }` is a typed one-time migration for the realization that predates the marker. It applies only when the desired holder's unit and broker are on its own target, nothing is on any other host, and only the marker is missing. It admits only the holder spec on the observed host. The marker is written inside an operator-triggered apply, and the receipt names `Adopted`. + - **Retirement.** The arm carries `adopt_unmarked_holder_frontier` (`std.dissolution`). Trigger: an exact-host receipt records Adopted, then an independent observation returns one complete marked realization and Noop; then delete the arm. After adoption, a missing marker is partial state, never re-adopted. + - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. - Honest non-pins (not debt): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`, now checked roster-wide by `test.claim.serve_function_binding_witness_test`), so they must not follow the role; `fleet_reach_endpoint` is a host-shared account fact. Not claimed: that an unbound test deployment refuses everywhere — `belt_dispatch_instance_cli`'s absent-env default stays with the belt owner's lane. From d3c0384e7646e0fa3c2bf74192dcb2593cc73bf1 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 16:40:27 +0000 Subject: [PATCH 26/48] roadmap_launch_deployment_receipt witness: import RoleSingletonsNoop and rlm1_merge_commit; retire the 8 bare-provider rows the apply_receipt import now resolves (ImportsFixed) Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ap_launch_deployment_receipt_witness_test.dag | 2 +- ...loor_unimported_bare_provider_debt_roster.dag | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag b/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag index 312ce6358c7..de1ab28eaac 100644 --- a/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag +++ b/dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag @@ -1,7 +1,7 @@ module test.claim.roadmap.roadmap_launch_deployment_receipt_witness import gunbc.live_deploy.apply_receipt { RoleSingletonsNoop } - +import gunbc.roadmap_launch_deployment_cli { rlm1_merge_commit } import gunbc.live_deploy.desired { deployment_spec_srv1 } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly diff --git a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag index 37bbc6b1be1..b01cff4c131 100644 --- a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag +++ b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag @@ -1543,7 +1543,7 @@ data unimported_bare_provider_dispositions: List Date: Sun, 4 Oct 2026 17:00:03 +0000 Subject: [PATCH 27/48] Merge origin/main; retire every bare-provider row of the receipt witness the new imports resolve (ImportsFixed, 88 rows listed by the floor) Co-Authored-By: Claude Opus 5.5 (1M context) --- ...r_unimported_bare_provider_debt_roster.dag | 176 +++++++++--------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag index 16de66a01fb..151bd50f32a 100644 --- a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag +++ b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag @@ -1544,101 +1544,101 @@ data unimported_bare_provider_dispositions: List Date: Sun, 4 Oct 2026 17:07:17 +0000 Subject: [PATCH 28/48] deployment_risk witness: the adopt arm's retirement row is compared by its reason (FrontierRow has subject/reason/dissolution) Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2run19.sh | 23 ------------------- .d2run20.sh | 21 ----------------- .../claim/deployment_risk_witness_test.dag | 2 +- 3 files changed, 1 insertion(+), 45 deletions(-) delete mode 100644 .d2run19.sh delete mode 100644 .d2run20.sh diff --git a/.d2run19.sh b/.d2run19.sh deleted file mode 100644 index 663834dfa07..00000000000 --- a/.d2run19.sh +++ /dev/null @@ -1,23 +0,0 @@ -set -u -export PAGER=cat GIT_PAGER=cat -cargo build --release -p v1-compiler --bin gunbc --bin claim_batch --bin v1_src_dag_parse 2>&1 | tail -1 -mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 23622320128 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs -echo "=== PARSE"; ./target/release/v1_src_dag_parse 2>&1 | grep -v "parse-clean" | head -10 -echo "=== REGEN"; GUNBC_MEMORY_BUDGET_BYTES=23622320128 ./target/release/gunbc run --source-root dag --source-root src/v2 --entry dag/gunbc/instruments/generated_artifact_gate.dag --function main_wet_one --arg path=.github/workflows/fleet-converge.yml 2>&1 | grep -vE "^\s*(✓|◐|◷)|^\[" | tail -3; git --no-pager diff --stat -- .github/workflows/fleet-converge.yml; git checkout -- .github/workflows/fleet-converge.yml -run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^FAIL" /tmp/cb.out; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)|^(PASS|FAIL)|^claim_batch|memory-cgroup" | tail -10; fi; echo rc=$rc; } -run dag/test/claim/deployment_risk_witness_test.dag a_test_deployment_refuses_rather_than_resolving_the_prod_bindings,the_prod_role_holder_resolves_the_prod_topic_and_app,each_class_resolves_only_its_own_set,no_prod_role_makes_the_former_holder_test_risk_and_it_refuses,moving_the_role_moves_the_class_and_nothing_else_decides_it,exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace,the_wrong_instance_failure_mode_row_cites_this_red,moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses,a_prod_role_holder_with_no_instance_refuses_as_unrealized,a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move,no_prod_role_with_a_holder_observed_refuses_as_a_removal,an_unobservable_roster_host_refuses_rather_than_reading_as_absent,nothing_observed_and_a_valid_holder_installs_on_that_holder,exactly_one_complete_realization_of_the_desired_holder_on_its_target_is_a_noop,an_unrealized_or_ambiguous_holder_refuses_as_desired_side_in_empty_and_occupied_worlds,the_same_deployment_on_two_hosts_refuses,a_deployment_targeted_at_srv2_but_realized_on_srv1_refuses,two_deployments_stores_on_one_host_refuse,two_observed_holders_or_a_broker_without_a_store_refuse,an_old_store_with_no_unit_or_a_partial_realization_refuses,an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker,an_unmarked_holder_on_the_wrong_host_is_refused_not_adopted,a_stale_spec_is_refused_before_member_planning,a_holder_with_no_desired_row_is_unactuatable_and_holds_nothing,the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop,the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_refusal -run dag/test/claim/roadmap/serve_function_binding_witness_test.dag every_instance_names_exactly_one_declared_handler,srv1_live_serves_itself,macbook_local_serves_itself,srv1_lab_serves_itself,srv2_preview_serves_itself,srv2_deploy_serves_itself,srv2_lab_serves_itself -run dag/test/claim/roadmap/roadmap_dashboard_instance_apply_witness_test.dag dashboard_source_transport_argv_is_exact_and_atomic,dashboard_local_origin_ref_translation_is_exact,dashboard_provider_runtime_and_auth_share_one_state_binding,dashboard_target_preflight_derives_dispatch_platform_dependencies,dashboard_health_requires_intended_instance_identity,dashboard_plan_is_end_to_end_and_production_disjoint,dashboard_refusal_receipt_is_typed_and_located,dashboard_dirty_controller_source_refuses_before_apply,dashboard_bootstrap_parent_writability_is_a_preflight_fact,dashboard_unreachable_root_refuses_before_choosing_a_parent,protected_sibling_readings_never_equate_absent_with_observed,a_host_without_a_protected_sibling_refuses_nothing_and_reads_nothing,protected_siblings_follow_the_prod_role_selection_within_the_host,sibling_isolation_still_requires_the_instance_to_own_its_paths -run dag/test/claim/approval_request_client_witness_test.dag submit_and_status_urls_follow_the_prod_role_holders_listen_bind,submit_body_nests_the_stored_request_as_a_json_string,status_words_are_closed,injected_kv_lines_in_expires_at_are_unreadable_not_approved,poll_expires_after_expires_at_unless_already_decided,a_pending_filing_expires_by_its_filed_window_not_the_callers,the_exact_expiry_instant_is_expired_and_one_second_before_is_live,post_filed_token_admits_poll_including_notify_failure,status_text_round_trips_through_the_same_grammar -run dag/test/claim/approval_decision_store_witness_test.dag keyring_loads_sixty_four_lower_hex_and_refuses_the_rest,keyring_absent_and_unreadable_are_two_refusals,stored_request_round_trips_through_json,a_record_missing_a_field_or_of_unknown_kind_is_unreadable_not_defaulted,a_newline_in_expires_at_is_unreadable_not_a_filed_request,capability_segment_round_trips_and_refuses_a_third_decision_word,fixture_tags_verify_over_the_stored_claims_and_not_across_decisions,identity_trust_is_derived_from_the_instance_bind,redemption_refuses_first_on_an_untrusted_identity_header -run dag/test/claim/approval_request_submission_witness_test.dag a_signed_envelope_parses_back_to_the_same_request_and_tag,a_non_canonical_record_is_refused_before_it_is_verified,a_correctly_signed_submission_is_admitted_and_a_wrong_key_is_not_enrolled,a_slot_with_no_minted_capability_does_not_admit_poll,the_capability_key_cannot_file_a_request,issued_links_carry_tags_that_verify_over_the_stored_claims_for_their_own_decision_only,the_message_carries_purpose_both_links_and_the_destructive_mark,a_non_hex_capability_key_issues_no_link,status_text_reports_not_filed_for_an_absent_slot_and_unreadable_off_the_success_channel,a_filed_request_with_a_publisher_token_is_published_and_without_one_is_reported,an_occupied_slot_is_renotifiable_only_while_pending_with_the_same_bytes,a_capability_key_refusal_after_filing_names_the_filed_escalation -run dag/test/claim/approval_device_enrolment_code_witness_test.dag the_delivered_receipt_comes_from_the_published_message_and_its_line_never_carries_the_code,the_remote_argv_runs_the_release_verb_as_the_operator_user,the_mode_is_rostered_and_pinned_to_the_brokers_placed_host,an_accepted_publish_with_an_unreadable_receipt_is_accepted_and_never_rendered_as_not_delivered,only_an_explicit_http_refusal_yields_delivery_refused,a_transport_refusal_or_server_error_is_indeterminate_and_says_the_code_may_have_arrived,the_enrolment_code_is_sixteen_supplied_octets_as_lowercase_hex -run dag/test/claim/approve_ios_project_witness_test.dag the_declared_release_stamps_the_building_xcodes_upgrade_check,the_committed_projection_carries_every_required_info_plist_key_and_setting,witness_a_complete_supplied_document_is_admitted,red_a_document_without_a_launch_screen_is_refused,red_a_document_without_a_face_id_purpose_is_refused,red_a_document_without_a_display_name_is_refused,red_a_dead_infoplist_key_setting_is_refused,red_a_stale_xcode_version_is_refused,red_a_test_target_without_an_info_plist_is_refused,red_a_document_without_supported_orientations_is_refused,the_ios_build_standing_names_its_missing_lane,red_an_omitted_or_substituted_apns_environment_is_refused,red_an_omitted_or_substituted_bundle_identifier_is_refused,red_an_omitted_or_substituted_swift_version_is_refused,red_an_omitted_or_xcconfig_substituted_server_host_is_refused,the_committed_projection_names_the_brokers_public_host,changing_the_tailnet_domain_changes_the_generated_host,an_undeclared_tailnet_domain_projects_an_unroutable_host,red_a_non_default_public_port_refuses_the_projection -run dag/test/claim/fabric/fabric_writer_roster_witness_test.dag the_served_roster_holds_exactly_the_observed_logins_once,the_admission_admits_a_rostered_login_and_refuses_an_outsider_and_an_absent_login,the_admission_admits_a_rostered_local_peer_and_refuses_an_outsider_and_an_unattested_peer,the_door_reads_the_login_header_only_from_the_proxy_peer,the_production_served_door_refuses_a_login_no_reading_observed,the_door_admits_the_operator_and_the_ci_runner_locally_and_no_one_else,the_placed_store_areas_admit_no_writer_but_the_service -run dag/test/claim/deployed_tree_remote_witness_test.dag an_unconfigured_remote_is_absent_not_unusable,a_path_that_is_not_a_repository_is_unusable_not_absent,absent_and_unusable_are_distinct_observations,a_zero_exit_with_no_output_is_refused_not_an_empty_url,an_observed_url_is_trimmed,an_unknown_exit_code_is_refused,the_config_key_is_projected_from_the_remote_name,the_desired_url_is_the_derived_public_clone_url,identity_separates_two_instances_on_one_host,only_an_exact_read_back_reports_success,the_remote_converges_after_its_inputs_and_before_the_belt -run dag/test/claim/live_deploy_unit_emission_oracle_witness_test.dag the_belt_service_unit_matches_the_deployed_bytes,the_belt_service_unit_carries_the_per_tick_watchdog_bound,the_belt_timer_unit_matches_the_deployed_bytes,the_serve_unit_matches_the_deployed_bytes,the_slice_unit_matches_the_deployed_bytes,the_approval_broker_slice_unit_matches_the_deployed_bytes,the_approval_broker_unit_would_install_these_bytes,the_broker_inherits_the_roadmap_evaluation_caps,the_broker_and_the_roadmap_join_different_slices,the_broker_and_the_roadmap_bind_different_ports,the_broker_slice_bound_is_not_the_roadmap_deployment_bound,the_convergence_slice_carries_its_own_bounds,belt_service_runs_as_service_principal,belt_service_joins_deployment_slice,belt_service_works_in_repo_tree,belt_service_carries_user_manager_environment,belt_service_carries_spawn_workdir,belt_service_execstart_is_belt_tick_cli,belt_service_type_is_oneshot,belt_service_has_unit_directives,belt_service_is_not_installable,belt_timer_targets_belt_service,belt_timer_has_declared_cadence,belt_timer_has_description,belt_timer_is_installable_as_timers_target,serve_unit_runs_as_service_principal,serve_unit_joins_deployment_slice,serve_unit_works_in_repo_tree,serve_unit_carries_user_manager_environment,serve_unit_type_is_simple,serve_unit_restarts_on_failure,serve_unit_execstart_carries_admitted_entrypoint,serve_unit_has_unit_directives,serve_unit_is_installable_as_multi_user_target,slice_description_names_the_unit,slice_forbids_swap,slice_memory_bounds_are_the_deployments_own,slice_unit_is_not_installable,belt_service_user_goes_red_under_different_user,serve_unit_user_goes_red_under_different_user,serve_unit_execstart_goes_red_under_different_port,belt_service_working_directory_goes_red_under_different_root,the_convergence_oneshot_does_not_join_the_broker_slice,only_the_broker_unit_joins_the_broker_slice,both_served_slices_deny_swap_from_the_same_row -run dag/test/claim/live_deploy/emit_test.dag witness_spec_targets_srv1,witness_spec_uses_apt_package_manager,witness_spec_listen_port_is_8080,witness_owned_vs_ensured_typed,witness_apply_provisions_attempt_state_root_owned,witness_attempt_state_mode_derives_to_owner_only,witness_retract_attempt_state_root_refuses_when_nonempty,witness_tailnet_domain_propagates_optional,witness_tailnet_url_absent_not_fabricated,witness_dependency_ensure_is_idempotent,witness_apply_preamble_has_no_embedded_preflight,witness_apply_preamble_installs_only_the_deploy_sudoers,witness_apply_pipeline_carries_the_preamble_after_its_frame,witness_retract_preamble_acknowledges_shared_sudoers_install,the_convergence_slice_is_installed_before_the_convergence_restart,witness_apply_script_contains_systemd_and_tailscale,witness_apply_script_wires_memory_cap_deploy_consumer,retract_script_stays_within_ci_runner_grants,apply_script_stays_within_ci_runner_grants,git_metadata_file_transport_is_present_in_the_legacy_specimen_and_absent_from_emission,repository_convergence_is_ordered_after_binary_and_before_readback,witness_tree_sync_restart_diagnoses_on_failure,witness_retract_script_owned_only,witness_apply_script_has_no_node_server,witness_apply_script_has_no_frozen_page,witness_apply_script_excludes_readback_curls,witness_retract_owned_process_removes_serve_service,witness_execstart_carries_exactly_the_admitted_revision,witness_execstart_omitting_the_revision_is_not_the_emitted_line,witness_execstart_at_another_candidate_differs,witness_systemd_unit_emitted,witness_remote_mutation_service_op_realization_binds_emit_shell_phase_b_axis,witness_emit_shell_dissolution_trigger_is_scaffold,witness_dependency_graph_emit_frontier_binds_orch_emit_pipeline,witness_teardown_dependency_arm_refuses_golden_independent,witness_wholesale_refused_poison_carries_marker,witness_deploy_access_job_principal_refused_poison_golden_independent,witness_apply_script_access_ready_not_deploy_access_poison,a_twin_deployment_on_one_host_collides_with_nothing,twin_teardown_removes_nothing_production_owns,retract_is_the_reverse_of_what_this_spec_applies,production_apply_carries_its_own_handler_and_tree_sync_env,emitted_twin_apply_touches_none_of_productions_effects,twin_belt_unit_carries_the_twins_spawn_root_and_not_productions,production_belt_unit_carries_productions_spawn_root,emitted_twin_retract_removes_nothing_of_productions,shell_quote_escapes_an_embedded_apostrophe,production_apply_creates_productions_binary_parent_directory,twin_apply_creates_its_own_binary_parent_directory_and_not_productions,teardown_disposition_separates_owned_from_ensured,production_apply_serves_its_own_endpoint_and_scopes_no_path,twin_apply_serves_its_own_endpoint_and_not_productions,production_retract_offs_its_own_endpoint,twin_retract_offs_its_own_endpoint_and_not_productions,retract_is_not_emitted_without_an_observed_scoped_removal,fleet_converge_timer_not_installed_by_deploy_apply,emit_RED_unit_files_are_not_written_through_here_documents,emit_staged_unit_body_is_quoted_by_the_posix_authority,the_preamble_ensures_the_publication_principal_as_a_nologin_system_account,the_publication_member_emits_convergence_and_no_rsync,a_zero_mutation_plan_installs_publishes_and_restarts_nothing_of_the_release,the_full_plan_lands_its_members_in_the_deployments_order,the_fabric_storage_store_root_reaches_the_srv1_apply_path,the_fabric_storage_store_areas_reach_the_srv1_apply_path,the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_principal,the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone,the_placed_fabric_store_derives_entry_mode_0400,no_retract_can_remove_the_fabric_storage_store,a_binary_only_plan_installs_and_restarts_without_publishing_the_tree,a_matching_authorization_applies_the_staged_caps,a_withheld_decision_applies_no_caps_at_the_production_root,an_authorization_for_another_host_applies_no_caps,the_writer_reverts_only_the_authorized_target_units,the_dark_install_never_names_the_roadmap_unit,the_dark_install_execstart_carries_the_admitted_revision,a_twin_apply_neither_writes_nor_restarts_the_host_broker,the_owning_apply_does_write_and_restart_the_host_broker,both_install_paths_bind_the_brokers_one_port,a_twin_dark_install_refuses_rather_than_emitting_an_empty_stage,the_broker_unit_binds_loopback_and_runs_from_its_own_tree,the_dark_install_populates_the_broker_tree,the_dark_install_and_the_unit_name_the_same_candidate_binary,the_receipt_command_substitutes_the_digest_rather_than_splicing_it,the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it -run dag/test/claim/devboot_subject_identity_witness_test.dag an_unconstructable_digest_satisfies_neither_polarity,identical_subjects_share_one_digest,different_trees_never_collide,gnu_and_musl_are_different_subjects,features_participate_in_identity,the_product_being_built_participates_in_identity,build_environment_participates_in_identity,duplicate_feature_refuses,empty_feature_name_refuses,every_required_string_is_refused_when_empty,a_record_built_for_another_subject_is_refused_even_when_bytes_are_available,a_record_built_for_the_request_reuses_when_bytes_are_available,a_matching_record_without_bytes_does_not_reuse,produced_yesterday_with_missing_bytes_today_is_representable,only_observed_available_bytes_permit_retrieval,a_failed_build_never_reports_retrievable,an_interrupted_build_is_not_settled,the_devboot_store_root_is_an_ensured_host_resource_projecting_the_layout_row,the_devboot_store_root_reaches_the_deploy_apply_path,no_retract_can_remove_the_devboot_store_root -run dag/test/claim/live_deploy/apply_receipt_witness_test.dag witness_dashboard_receipt_round_trips,witness_dashboard_receipt_not_converged_round_trips_its_reason,witness_dashboard_receipt_with_tampered_candidate_refuses,witness_dashboard_receipt_without_completed_at_refuses,witness_dashboard_receipt_with_tampered_plan_receipt_identity_refuses -run dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag witness_complete_population_is_complete,witness_complete_receipt_document_names_its_verdict_and_identity,witness_plan_run_at_another_sha_is_incomplete_at_plan_receipt,witness_apply_run_at_another_sha_is_incomplete_at_apply_receipt,witness_dashboard_run_at_another_sha_is_incomplete_at_dashboard_receipt,witness_plan_run_id_substitution_is_incomplete_at_plan_receipt,witness_apply_bound_to_another_plan_is_incomplete_at_apply_receipt,witness_dashboard_bound_to_another_r_is_incomplete_at_dashboard_receipt,witness_plan_observed_on_another_host_is_incomplete_at_plan_receipt,witness_receipt_run_on_another_host_is_incomplete_at_observed_host,witness_deployed_tree_mismatch_is_incomplete_at_deployed_tree,witness_stale_tick_is_incomplete_at_tick_standing,witness_wrong_instance_tick_is_incomplete_at_tick_standing,witness_wrong_revision_tick_is_incomplete_at_tick_standing,witness_automatic_ready_tick_is_incomplete_at_tick_standing,witness_v3_tick_on_disk_is_incomplete_at_tick_standing,witness_disabled_timer_beside_fresh_tick_is_incomplete_at_unit_standing,witness_active_oneshot_mid_tick_is_still_complete,witness_wrong_principal_is_incomplete_at_principal,witness_window_change_is_incomplete_at_observation_window,witness_transition_marker_is_incomplete_at_transition,witness_unwritable_root_is_incomplete_at_footprint,witness_missing_provider_credential_is_incomplete_at_provider_preflight,witness_unobservable_attempt_ledger_is_incomplete_at_workflow_endpoint,witness_workflow_json_without_a_tick_is_incomplete_at_workflow_endpoint,witness_desired_ref_behind_r_is_incomplete_at_desired_revision,witness_two_substitutions_retain_both_blockers,witness_incomplete_receipt_document_carries_its_blockers,witness_receipt_identity_is_stable_and_discriminating,witness_partially_applied_exit_zero_is_incomplete_at_apply_receipt,witness_apply_admitted_against_q_is_incomplete_at_apply_receipt,witness_predecessor_non_success_conclusion_is_incomplete_at_run_provenance,witness_predecessor_at_another_sha_is_incomplete_at_run_provenance,witness_predecessor_on_another_workflow_is_incomplete_at_run_provenance,witness_predecessor_run_id_substitution_is_incomplete_at_run_provenance,witness_receipt_workflow_ref_not_named_is_incomplete_at_run_provenance,witness_provenance_unobserved_is_incomplete_at_run_provenance,witness_dashboard_citing_another_plan_receipt_is_incomplete_at_dashboard_receipt,witness_receipt_document_facts_are_machine_readable,witness_receipt_run_in_another_repository_is_incomplete_at_run_provenance,witness_routed_surface_mixed_is_incomplete_at_release_binding,witness_route_change_during_window_is_incomplete_at_observation_window,witness_receipt_document_fresh_age_is_machine_readable,witness_receipt_document_stale_age_and_bound_are_machine_readable,witness_a_full_host_plan_cannot_occupy_the_rlm_plan_slot,witness_a_fabric_cells_plan_cannot_occupy_the_rlm_plan_slot,witness_an_allocation_store_plan_cannot_occupy_the_rlm_plan_slot,witness_an_undecodable_plan_scope_wire_blocks_the_transaction -run dag/test/claim/approval_ntfy_access_readback_witness_test.dag a_second_root_is_refused_at_every_effect_leaf,the_one_root_compiles_without_an_admission_refusal diff --git a/.d2run20.sh b/.d2run20.sh deleted file mode 100644 index c84ef5c5fa6..00000000000 --- a/.d2run20.sh +++ /dev/null @@ -1,21 +0,0 @@ -set -u -export PAGER=cat GIT_PAGER=cat -cargo build --release -p v1-compiler --bin gunbc --bin claim_batch --bin v1_src_dag_parse 2>&1 | tail -1 -mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 23622320128 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs -run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^FAIL" /tmp/cb.out; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)|^(PASS|FAIL)|^claim_batch|memory-cgroup" | tail -10; fi; echo rc=$rc; } -cp dag/gunbc/live_deploy/role_singleton_convergence.dag /tmp/c.bak -echo "=== MUTANT B: multiplicity deduplicated (two-hosts and misplaced dropped)" -sed -i 's/ let two_hosts = ids |> flat_map(d => if count(/ let two_hosts_unused = ids |> flat_map(d => if count(/; s/ let misplaced = ids |> flat_map(/ let misplaced_unused = ids |> flat_map(/; s/concat(concat(unobservable, two_hosts), concat(misplaced, two_on_one))/concat(concat(unobservable, []), concat([], two_on_one))/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'two_hosts_unused' dag/gunbc/live_deploy/role_singleton_convergence.dag -run dag/test/claim/deployment_risk_witness_test.dag the_same_deployment_on_two_hosts_refuses,a_deployment_targeted_at_srv2_but_realized_on_srv1_refuses,exactly_one_complete_realization_of_the_desired_holder_on_its_target_is_a_noop -cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag -echo "=== MUTANT D: every desired refusal read as no-prod" -sed -i 's/ ProdRoleHolderUnrealized { deployment: _ } => false/ ProdRoleHolderUnrealized { deployment: _ } => true/; s/ ProdRoleHolderAmbiguous { deployment: _, count: _ } => false/ ProdRoleHolderAmbiguous { deployment: _, count: _ } => true/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'ProdRoleHolderUnrealized { deployment: _ } => true' dag/gunbc/live_deploy/role_singleton_convergence.dag -run dag/test/claim/deployment_risk_witness_test.dag an_unrealized_or_ambiguous_holder_refuses_as_desired_side_in_empty_and_occupied_worlds -cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag -echo "=== MUTANT E: holding pinned to srv1-live" -sed -i 's/ if holder.instance.deployment == instance.deployment { HoldsProdRoleSingletons/ if (instance.instance_id as String) == "srv1-live" { HoldsProdRoleSingletons/' dag/gunbc/live_deploy/spec.dag -run dag/test/claim/deployment_risk_witness_test.dag moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop -cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag -echo "=== MUTANT F: adoption admits any agreeing spec" -sed -i 's/ else if desired_is_this_spec \&\& (spec.target.ssh_host as String) == (host as String) { admitted }/ else if true { admitted }/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'else if true { admitted }' dag/gunbc/live_deploy/role_singleton_convergence.dag -run dag/test/claim/deployment_risk_witness_test.dag an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 1a4daf2810c..dbb5783c97f 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -488,7 +488,7 @@ test fn an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_ let adopted = match decision { AdoptUnmarkedHolder { holder, host, retirement } => holder.instance.deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" - && (retirement.path as String) == (adopt_unmarked_holder_frontier.path as String) + && (retirement.reason as String) == (adopt_unmarked_holder_frontier.reason as String) _ => false } let live_id = srv1_live_dashboard_instance().instance_id as String From c698dbdc9f859e5dc23589f807322387373e2dde Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 18:00:49 +0000 Subject: [PATCH 29/48] D2 adoption authority: admission mint sealed to the observing assessor (admit_callers); adoption bound to the one LegacyUnmarkedHolderStanding row; retirement subject is that realization; compile-time forgery probes Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/live_deploy/apply.dag | 16 ++- .../role_singleton_convergence.dag | 103 ++++++++++++---- .../claim/deployment_risk_witness_test.dag | 110 +++++++++++++----- docs/plans/deployment-risk-conformance.md | 6 +- 4 files changed, 175 insertions(+), 60 deletions(-) diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index b4f57afe3d4..837aab3af0e 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -17,7 +17,7 @@ import gunbc.live_deploy.spec { deployment_plan_listen_port, deployment_plan_host_identity, } -import gunbc.live_deploy.desired { deployment_spec_srv1, prod_role_actuated_realization } +import gunbc.live_deploy.desired { deployment_spec_srv1 } import std.types { String, CommitSha, NonEmptyStr } import gunbc.live_deploy.candidate { CandidateRelease, @@ -70,7 +70,7 @@ import gunbc.live_deploy.member_identity { EffectsDerived, EffectsRefused, decis import gunbc.live_deploy.role_singleton_convergence { assess_and_admit, role_singleton_refusal_detail, admitted_spec, admitted_decision, RoleSingletonAdmission, RoleSingletonAdmitted, RoleSingletonNotAdmitted, - RoleSingletonNoop, RoleSingletonInstall, AdoptUnmarkedHolder, RoleSingletonRefused, + RoleSingletonNoop, RoleSingletonInstall, AdoptUnmarkedHolder, RoleSingletonRefused, RoleSingletonConvergence, } import gunbc.auth.approval_broker_cutover { CutoverAdmission, StageAdmitted, StageRefused, @@ -414,7 +414,7 @@ fn live_deploy_apply_via_transport( transport: HostEffectTransport, access: DeployAccess ) -> Reconciliation { - match assess_and_admit(spec: spec, desired: prod_role_actuated_realization(), own: transport) { + match assess_and_admit(spec: spec, own: transport) { RoleSingletonNotAdmitted { cause } => NotConverged { reason: join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], ""), @@ -1046,10 +1046,14 @@ fn live_deploy_write_transaction_receipt(spec: DeploymentSpec, receipt: LiveDepl // apply wrote, so the retirement of AdoptUnmarkedHolder (its FrontierRow) has an exact-host receipt to // point at; Installed names the first install; Noop is everything else that was admitted. fn role_singleton_disposition_of(admission: RoleSingletonAdmission) -> LiveDeployRoleSingletonDisposition { - match admitted_decision(admission: admission) { + role_singleton_disposition_of_decision(decision: admitted_decision(admission: admission)) +} + +fn role_singleton_disposition_of_decision(decision: RoleSingletonConvergence) -> LiveDeployRoleSingletonDisposition { + match decision { RoleSingletonNoop => RoleSingletonsNoop RoleSingletonInstall { holder } => RoleSingletonsInstalled { deployment: holder.instance.deployment.name as String, host: holder.instance.host_identity as String } - AdoptUnmarkedHolder { holder, host, retirement: _ } => RoleSingletonsAdopted { deployment: holder.instance.deployment.name as String, host: host as String } + AdoptUnmarkedHolder { holder, host, standing: _, retirement: _ } => RoleSingletonsAdopted { deployment: holder.instance.deployment.name as String, host: host as String } RoleSingletonRefused { cause } => RoleSingletonsRefusedBeforeApply { detail: role_singleton_refusal_detail(cause: cause) } } } @@ -1098,7 +1102,7 @@ fn live_deploy_transaction_decided( exit: live_deploy_reconciliation_exit(r: preflight), ) } else { - match assess_and_admit(spec: spec, desired: prod_role_actuated_realization(), own: access.transport) { + match assess_and_admit(spec: spec, own: access.transport) { RoleSingletonNotAdmitted { cause } => { let why = join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], "") live_deploy_write_transaction_receipt( diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index ca61d034510..2bb78ee53ef 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -4,7 +4,8 @@ import std.types { String, NonEmptyStr, List, Bool, Int } import std.dissolution { DissolutionCondition, unbound_dissolution } import std.roster_frontier { FrontierRow, frontier_row_path } import v2.std.collection { Present, Absent } -import gunbc.deployment_risk { DeploymentId } +import gunbc.deployment_risk { DeploymentId, srv1_daily_workspace_deployment } +import std.decl_ref { decl_ref } import gunbc.roadmap_dashboard_instance { ProdRoleHolder, ProdRoleRealization, @@ -16,6 +17,7 @@ import gunbc.roadmap_dashboard_instance { ProdRoleHolderAmbiguous, ProdRoleHolderUnactuatable, prod_role_realization_refusal_detail, + srv1_live_dashboard_instance, } import gunbc.host_effect { HostEffectTransport, SshShell } import gunbc.live_deploy.spec { @@ -26,7 +28,7 @@ import gunbc.live_deploy.spec { gunbc_approval_broker_unit_path, } import gunbc.fabric_storage_placement { fabric_storage_ownership_marker_path } -import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster } +import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization } import gunbc.live_deploy.member_observe { ObservationArm, observation_arm, @@ -100,16 +102,42 @@ type RoleSingletonRefusal | RoleSingletonPartial { deployment: DeploymentId, host: NonEmptyStr, missing: List } | RoleSingletonStaleSpec { instance_id: NonEmptyStr, detail: String } +// ── The one legacy realization adoption may name ──────────────────────────────────────────────── +// ADOPTION IS NOT A GENERIC REPAIR. It is bound to this explicit, temporary authority: the one +// realization installed before the store carried an ownership marker. sole_constructor, so no module +// but this one can state another legacy pair, and exactly one row exists. A future holder whose marker +// is deleted, or a hand-partial install, matches no row and refuses as RoleSingletonPartial -- it is +// never relabelled as ownership. +type LegacyUnmarkedHolderStanding sole_constructor { + deployment: DeploymentId + host: NonEmptyStr + marker_path: NonEmptyStr + evidence: NonEmptyStr +} + +data legacy_unmarked_holders: List = [ + LegacyUnmarkedHolderStanding { + deployment: srv1_daily_workspace_deployment, + host: "srv1" as NonEmptyStr, + marker_path: fabric_storage_ownership_marker_path(instance: srv1_live_dashboard_instance()), + evidence: "srv1-live's approval broker and fabric storage unit were installed on srv1 before gunbc.fabric_storage_placement fabric_storage_ownership_marker_path existed (#13217), so its store root carries no marker" as NonEmptyStr, + }, +] + +fn legacy_standing_for(legacy: List, deployment: DeploymentId, host: NonEmptyStr, marker_path: NonEmptyStr) -> LegacyUnmarkedHolderStanding? { + legacy |> filter(l => l.deployment == deployment && (l.host as String) == (host as String) && (l.marker_path as String) == (marker_path as String)) |> first +} + // ── The adoption arm's retirement ─────────────────────────────────────────────────────────────── // THE ARM CARRIES THIS ROW (AdoptUnmarkedHolder.retirement), so every adoption names the condition -// that deletes it. After adoption a missing marker is corruption or partial state, never re-adopted. +// that deletes it. Its subject is the one extant legacy realization above, not the roster. data adopt_unmarked_holder_dissolution: DissolutionCondition = unbound_dissolution( - description: "TRIGGER: every gunbc.live_deploy.desired DesiredDeployment realization carries a store ownership marker -- shown by an exact-host live_deploy apply receipt whose role_singleton_disposition records RoleSingletonsAdopted for the holder, followed by an independent observation (gunbc.live_deploy.role_singleton_convergence role_singleton_observation_of) returning exactly one complete, marked realization and RoleSingletonNoop. Then delete the AdoptUnmarkedHolder arm. SUFFICIENT FOR: that receipt and that observation; a marker written by hand, or an observation that is not independent of the apply, is not this trigger." as NonEmptyStr + description: "TRIGGER: an exact-host live_deploy apply receipt records role_singleton_disposition RoleSingletonsAdopted for the named legacy realization (gunbc.live_deploy.role_singleton_convergence legacy_unmarked_holders: srv1-daily-workspace on srv1), and an independent LATER observation (role_singleton_observation_of) sees that same realization complete, marked and RoleSingletonNoop. Then delete legacy_unmarked_holders, LegacyUnmarkedHolderStanding and the AdoptUnmarkedHolder arm. SUFFICIENT FOR: that receipt and that later observation of the same deployment and host; a marker written by hand, an observation of a different realization, or one not independent of the adopting apply is not this trigger." as NonEmptyStr ) data adopt_unmarked_holder_frontier: FrontierRow = frontier_row_path( path: "gunbc.live_deploy.role_singleton_convergence AdoptUnmarkedHolder", - reason: "a one-time migration of the realization that predates the store ownership marker; any other partial state refuses as RoleSingletonPartial", + reason: "a one-time migration of the one realization that predates the store ownership marker (legacy_unmarked_holders); any other unmarked or partial state refuses as RoleSingletonPartial", dissolution: adopt_unmarked_holder_dissolution, ) @@ -123,7 +151,7 @@ data adopt_unmarked_holder_frontier: FrontierRow = frontier_row_path( type RoleSingletonConvergence = RoleSingletonNoop | RoleSingletonInstall { holder: ProdRoleHolder } - | AdoptUnmarkedHolder { holder: ProdRoleHolder, host: NonEmptyStr, retirement: FrontierRow } + | AdoptUnmarkedHolder { holder: ProdRoleHolder, host: NonEmptyStr, standing: LegacyUnmarkedHolderStanding, retirement: FrontierRow } | RoleSingletonRefused { cause: RoleSingletonRefusal } // ── The observation ───────────────────────────────────────────────────────────────────────────── @@ -264,7 +292,7 @@ fn desired_is_no_prod(cause: ProdRoleRealizationRefusal) -> Bool { // ONLY ProdRoleUnheld MEANS "NO PROD". An unrealized, ambiguous or unactuatable holder is a refusal of // the desired side, never read as the no-prod path -- which is how a typo in the selection would // otherwise turn into a removal of the real prod singletons. -fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingletonObservation, rows: List) -> RoleSingletonConvergence { +fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingletonObservation, rows: List, legacy: List) -> RoleSingletonConvergence { match observed { RoleSingletonObservationRefused { cause } => RoleSingletonRefused { cause: cause } RoleSingletonsObserved { realizations } => match desired { @@ -286,7 +314,10 @@ fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingle let missing = missing_members(r: r) if count(missing) == 0 { RoleSingletonNoop } else if count(missing) == 1 && all(missing, m => member_eq(a: m, b: StoreMarkerMember)) { - AdoptUnmarkedHolder { holder: holder, host: r.host, retirement: adopt_unmarked_holder_frontier } + match legacy_standing_for(legacy: legacy, deployment: r.deployment, host: r.host, marker_path: fabric_storage_ownership_marker_path(instance: holder.instance)) { + Present { value: standing } => AdoptUnmarkedHolder { holder: holder, host: r.host, standing: standing, retirement: adopt_unmarked_holder_frontier } + Absent => RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } + } } else { RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } } @@ -297,8 +328,8 @@ fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingle } // ── The admission: the decision bound to the spec that acts ───────────────────────────────────── -// THE APPLY CONSUMES THIS TOKEN, NEVER THE CALLER'S SPEC (sole_constructor: only admit_role_singletons -// mints it). The spec inside is the one the assessment was made against, and it is admitted only when +// THE APPLY CONSUMES THIS TOKEN, NEVER THE CALLER'S SPEC. sole_constructor, and its one mint +// (mint_role_singleton_admission) is sealed to the observing assessor assess_and_admit. The spec inside is the one the assessment was made against, and it is admitted only when // its own holding agrees with the desired realization: a spec built before the role moved, a holding // spec under NoProdRole, or a non-holder spec while the holder's first install is pending all refuse // before any member is planned. @@ -311,6 +342,13 @@ type RoleSingletonAdmissionResult = RoleSingletonAdmitted { admission: RoleSingletonAdmission } | RoleSingletonNotAdmitted { cause: RoleSingletonRefusal } +// THE PURE CLASSIFICATION a fixture may run: whether a spec WOULD be admitted for a decision. It +// returns a standing, never a usable token -- the only route to a RoleSingletonAdmission is the sealed +// mint below, reached only from the observing assessor. +type RoleSingletonAdmissionStanding + = RoleSingletonAdmissible { decision: RoleSingletonConvergence } + | RoleSingletonInadmissible { cause: RoleSingletonRefusal } + fn admitted_spec(admission: RoleSingletonAdmission) -> DeploymentSpec { admission.spec } @@ -319,11 +357,11 @@ fn admitted_decision(admission: RoleSingletonAdmission) -> RoleSingletonConverge admission.decision } -fn stale(spec: DeploymentSpec, detail: String) -> RoleSingletonAdmissionResult { - RoleSingletonNotAdmitted { cause: RoleSingletonStaleSpec { instance_id: spec.instance_id, detail: detail } } +fn stale(spec: DeploymentSpec, detail: String) -> RoleSingletonAdmissionStanding { + RoleSingletonInadmissible { cause: RoleSingletonStaleSpec { instance_id: spec.instance_id, detail: detail } } } -fn admit_role_singletons(spec: DeploymentSpec, desired: ProdRoleRealization, decision: RoleSingletonConvergence) -> RoleSingletonAdmissionResult { +fn classify_role_singleton_admission(spec: DeploymentSpec, desired: ProdRoleRealization, decision: RoleSingletonConvergence) -> RoleSingletonAdmissionStanding { let spec_holder = match spec.role_singletons { HoldsProdRoleSingletons { holder } => Present { value: holder.instance.deployment } HoldsNoRoleSingleton => none @@ -337,22 +375,30 @@ fn admit_role_singletons(spec: DeploymentSpec, desired: ProdRoleRealization, dec Present { value: d } => match desired_holder { Present { value: i } => i.deployment == d Absent => false } Absent => !desired_is_this_spec } - let admitted = RoleSingletonAdmitted { admission: RoleSingletonAdmission { spec: spec, decision: decision } } + let admissible = RoleSingletonAdmissible { decision: decision } let stale_holding = stale(spec: spec, detail: "the spec's role-singleton holding disagrees with the prod role resolved now; it was built against a different selection") match decision { - RoleSingletonRefused { cause } => RoleSingletonNotAdmitted { cause: cause } - RoleSingletonNoop => if agrees { admitted } else { stale_holding } - AdoptUnmarkedHolder { holder: _, host, retirement: _ } => + RoleSingletonRefused { cause } => RoleSingletonInadmissible { cause: cause } + RoleSingletonNoop => if agrees { admissible } else { stale_holding } + AdoptUnmarkedHolder { holder: _, host, standing: _, retirement: _ } => if !agrees { stale_holding } - else if desired_is_this_spec && (spec.target.ssh_host as String) == (host as String) { admitted } + else if desired_is_this_spec && (spec.target.ssh_host as String) == (host as String) { admissible } else { stale(spec: spec, detail: "the holder's marker adoption is pending; only the observed holder spec on the observed host may act") } RoleSingletonInstall { holder: _ } => if !agrees { stale_holding } - else if desired_is_this_spec { admitted } + else if desired_is_this_spec { admissible } else { stale(spec: spec, detail: "the holder's first install is pending; no other spec applies until it lands") } } } +// THE ONLY MINT, sealed to the observing assessor: no caller can hand it a fabricated decision or a +// noncanonical desired realization, because no other caller may reach it. +fn mint_role_singleton_admission(spec: DeploymentSpec, decision: RoleSingletonConvergence) -> RoleSingletonAdmission admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.role_singleton_convergence", decl_name: "assess_and_admit"), +] { + RoleSingletonAdmission { spec: spec, decision: decision } +} + fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { match cause { RoleSingletonMoveUnimplemented { observed, desired } => @@ -447,13 +493,20 @@ fn observe_role_singletons_with(source: RoleSingletonReadingSource, rows: List read(h, rows)), rows: rows) } -// THE PRODUCTION ROUTE: desired (actuated) realization x the ssh probe over the roster, then admission -// of the exact spec. The apply acts on the admitted spec and nothing else. -fn assess_and_admit(spec: DeploymentSpec, desired: ProdRoleRealization, own: HostEffectTransport) -> RoleSingletonAdmissionResult { +// THE CANONICAL ASSESSOR, AND THE ONLY ROUTE TO A TOKEN. It reads the desired realization itself +// (gunbc.live_deploy.desired prod_role_actuated_realization -- the real selection, never a caller's), +// observes every roster host through the ssh probe, decides, classifies the spec, and only then mints. +fn assess_and_admit(spec: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonAdmissionResult { let rows = desired_deployment_roster() - admit_role_singletons( - spec: spec, + let desired = prod_role_actuated_realization() + let decision = role_singleton_convergence( desired: desired, - decision: role_singleton_convergence(desired: desired, observed: observe_role_singletons_with(source: ssh_probe_reading_source(applying: spec, own: own), rows: rows), rows: rows), + observed: observe_role_singletons_with(source: ssh_probe_reading_source(applying: spec, own: own), rows: rows), + rows: rows, + legacy: legacy_unmarked_holders, ) + match classify_role_singleton_admission(spec: spec, desired: desired, decision: decision) { + RoleSingletonInadmissible { cause } => RoleSingletonNotAdmitted { cause: cause } + RoleSingletonAdmissible { decision: d } => RoleSingletonAdmitted { admission: mint_role_singleton_admission(spec: spec, decision: d) } + } } diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index dbb5783c97f..7842b7d84d2 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -66,7 +66,9 @@ import gunbc.live_deploy.spec { deployment_fabric_storage_door_socket, } import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } -import gunbc.live_deploy.apply { role_singleton_disposition_of } +import gunbc.live_deploy.apply { role_singleton_disposition_of_decision } +import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } +import gunbc.compile_diagnostic_census { CensusObserved, CensusNotRunnable } import gunbc.live_deploy.apply_receipt { RoleSingletonsAdopted } import gunbc.live_deploy.role_singleton_convergence { adopt_unmarked_holder_frontier, @@ -94,11 +96,12 @@ import gunbc.live_deploy.role_singleton_convergence { HostRoleSingletonReading, HostRoleSingletonsRead, HostRoleSingletonsUnobservable, - RoleSingletonAdmissionResult, - RoleSingletonAdmitted, - RoleSingletonNotAdmitted, - admit_role_singletons, - admitted_spec, + RoleSingletonAdmissionStanding, + RoleSingletonAdmissible, + RoleSingletonInadmissible, + classify_role_singleton_admission, + legacy_unmarked_holders, + LegacyUnmarkedHolderStanding, role_singleton_convergence, role_singleton_observation_of, } @@ -335,7 +338,7 @@ fn srv1_holds() -> List { fn decide(desired: ProdRoleRealization, readings: List) -> RoleSingletonConvergence { let rows = desired_deployment_roster() - role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings, rows: rows), rows: rows) + role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings, rows: rows), rows: rows, legacy: legacy_unmarked_holders) } fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { @@ -355,7 +358,7 @@ fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { } RoleSingletonNoop => false RoleSingletonInstall { holder: _ } => false - AdoptUnmarkedHolder { holder: _, host: _, retirement: _ } => false + AdoptUnmarkedHolder { holder: _, host: _, standing: _, retirement: _ } => false } } @@ -486,29 +489,84 @@ test fn an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_ let real = prod_role_actuated_realization() let decision = decide(desired: real, readings: adopt_world()) let adopted = match decision { - AdoptUnmarkedHolder { holder, host, retirement } => + AdoptUnmarkedHolder { holder, host, standing: _, retirement } => holder.instance.deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" && (retirement.reason as String) == (adopt_unmarked_holder_frontier.reason as String) _ => false } let live_id = srv1_live_dashboard_instance().instance_id as String - let holder_admitted = all(spec_of(realization: real, id: live_id), s => match admit_role_singletons(spec: s, desired: real, decision: decision) { - RoleSingletonAdmitted { admission } => - spec_marker_ensure_is_exact(spec: admitted_spec(admission: admission)) - && match role_singleton_disposition_of(admission: admission) { + let holder_admitted = all(spec_of(realization: real, id: live_id), s => match classify_role_singleton_admission(spec: s, desired: real, decision: decision) { + RoleSingletonAdmissible { decision: d } => + spec_marker_ensure_is_exact(spec: s) + && match role_singleton_disposition_of_decision(decision: d) { RoleSingletonsAdopted { deployment, host } => deployment == (srv1_daily_workspace_deployment.name as String) && host == "srv1" _ => false } - RoleSingletonNotAdmitted { cause: _ } => false + RoleSingletonInadmissible { cause: _ } => false }) - let others_refused = all(roster_specs_under(realization: real) |> filter(s => !((s.instance_id as String) == live_id)), s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: real, decision: decision))) + let others_refused = all(roster_specs_under(realization: real) |> filter(s => !((s.instance_id as String) == live_id)), s => admission_refused_as_stale(r: classify_role_singleton_admission(spec: s, desired: real, decision: decision))) let wrong_target = deployment_spec_for(realization: real, instance: srv1_live_dashboard_instance(), target: deployment_srv2_target(), names: srv1_live_deployment_names(instance: srv1_live_dashboard_instance())) let after = match decide(desired: real, readings: srv1_holds()) { RoleSingletonNoop => true _ => false } adopted && count(spec_of(realization: real, id: live_id)) == 1 && holder_admitted && others_refused - && admission_refused_as_stale(r: admit_role_singletons(spec: wrong_target, desired: real, decision: decision)) + && admission_refused_as_stale(r: classify_role_singleton_admission(spec: wrong_target, desired: real, decision: decision)) && after } +// ADOPTION IS BOUND TO THE ONE LEGACY ROW, NOT TO THE SHAPE. The same unmarked shape for any other holder +// -- the role moved to srv2-deploy, or a future holder -- refuses as partial even when it matches +// exactly; and in the world after the row's retirement (no legacy rows), a marker deleted from the +// once-adopted srv1 realization refuses rather than being re-adopted. +fn decide_with_legacy(desired: ProdRoleRealization, readings: List, legacy: List) -> RoleSingletonConvergence { + let rows = desired_deployment_roster() + role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings, rows: rows), rows: rows, legacy: legacy) +} + +test fn only_the_legacy_row_is_adoptable_and_a_retired_world_refuses_a_deleted_marker() -> Bool { + let moved_unmarked = [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: units_without_marker(host: "srv2", d: srv2_deploy_deployment))] + let srv1_unmarked = adopt_world() + let legacy_still_adopted = match decide_with_legacy(desired: prod_role_actuated_realization(), readings: srv1_unmarked, legacy: legacy_unmarked_holders) { + AdoptUnmarkedHolder { holder: _, host, standing: _, retirement: _ } => (host as String) == "srv1" + _ => false + } + count(legacy_unmarked_holders) == 1 + && legacy_still_adopted + && refused_with(c: decide_with_legacy(desired: fx_moved(), readings: moved_unmarked, legacy: legacy_unmarked_holders), label: "partial") + && refused_with(c: decide_with_legacy(desired: prod_role_actuated_realization(), readings: srv1_unmarked, legacy: []), label: "partial") +} + +// THE TOKEN AND THE STANDING CANNOT BE FORGED. An outside module minting an admission from a +// fabricated RoleSingletonNoop is refused at compile time by the mint's admit_callers seal, and an +// outside module stating a LegacyUnmarkedHolderStanding is refused by its sole_constructor -- each a +// located diagnostic. The canonical assessor takes no desired realization, so a noncanonical +// ProdRoleSelection has no parameter to enter through. +data forged_admission_source: String = "module probe_forged_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { mint_role_singleton_admission, RoleSingletonNoop, RoleSingletonAdmission }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nfn forge() -> RoleSingletonAdmission { mint_role_singleton_admission(spec: deployment_spec_srv1(), decision: RoleSingletonNoop) }\n" + +data forged_standing_source: String = "module probe_forged_legacy_standing\nimport gunbc.live_deploy.role_singleton_convergence { LegacyUnmarkedHolderStanding }\nimport gunbc.deployment_risk { srv2_deploy_deployment }\nfn forge() -> LegacyUnmarkedHolderStanding { LegacyUnmarkedHolderStanding { deployment: srv2_deploy_deployment, host: \"srv2\", marker_path: \"/x\", evidence: \"forged\" } }\n" + +data noncanonical_desired_source: String = "module probe_noncanonical_desired\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1, prod_role_actuated_realization_in }\nimport gunbc.deployment_risk { ProdRoleHeldBy, srv2_deploy_deployment }\nimport gunbc.host_effect { LocalShell }\nfn forge() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), desired: prod_role_actuated_realization_in(selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }), own: LocalShell) }\n" + +data canonical_assessor_source: String = "module probe_canonical_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1, prod_role_actuated_realization_in }\nimport gunbc.deployment_risk { ProdRoleHeldBy, srv2_deploy_deployment }\nimport gunbc.host_effect { LocalShell }\nfn forge() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), own: LocalShell) }\n" + +fn blocking_rows(source: String) -> Int { + match compile_dag_diagnostic_census(source) { + CensusObserved { rows } => fold(rows |> filter(r => r.blocking), init: 0, f: (acc, r) => acc + r.count) + CensusNotRunnable { cause: _ } => 0 + } +} + +fn blocking_of(source: String, class: String) -> Int { + match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: class) { + Present { value: n } => n + Absent => 0 + } +} + +test fn no_outside_module_can_mint_an_admission_or_state_a_legacy_standing() -> Bool { + blocking_of(source: forged_admission_source, class: "ConstructorCallAdmissionRefused") >= 1 + && blocking_of(source: forged_standing_source, class: "SoleConstructorViolation") >= 1 + && blocking_rows(source: noncanonical_desired_source) > blocking_rows(source: canonical_assessor_source) +} + test fn an_unmarked_holder_on_the_wrong_host_is_refused_not_adopted() -> Bool { refused_with(c: decide(desired: prod_role_actuated_realization(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: units_without_marker(host: "srv2", d: srv1_daily_workspace_deployment))]), label: "misplaced") } @@ -518,10 +576,10 @@ fn spec_of(realization: ProdRoleRealization, id: String) -> List roster_specs_under(realization: realization) |> filter(s => (s.instance_id as String) == id) } -fn admission_refused_as_stale(r: RoleSingletonAdmissionResult) -> Bool { +fn admission_refused_as_stale(r: RoleSingletonAdmissionStanding) -> Bool { match r { - RoleSingletonNotAdmitted { cause } => match cause { RoleSingletonStaleSpec { instance_id: _, detail: _ } => true _ => false } - RoleSingletonAdmitted { admission: _ } => false + RoleSingletonInadmissible { cause } => match cause { RoleSingletonStaleSpec { instance_id: _, detail: _ } => true _ => false } + RoleSingletonAdmissible { decision: _ } => false } } @@ -532,12 +590,12 @@ test fn a_stale_spec_is_refused_before_member_planning() -> Bool { let stale_srv1 = spec_of(realization: prod_role_actuated_realization(), id: live_id) let moved_install = decide(desired: fx_moved(), readings: empty_world()) let unheld_noop = decide(desired: fx_unheld(), readings: empty_world()) - all(stale_srv1, s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: fx_moved(), decision: moved_install))) - && all(stale_srv1, s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: fx_unheld(), decision: unheld_noop))) - && all(spec_of(realization: fx_moved(), id: lab_id), s => admission_refused_as_stale(r: admit_role_singletons(spec: s, desired: fx_moved(), decision: moved_install))) - && all(spec_of(realization: fx_moved(), id: srv2_id), s => match admit_role_singletons(spec: s, desired: fx_moved(), decision: moved_install) { - RoleSingletonAdmitted { admission } => (admitted_spec(admission: admission).instance_id as String) == srv2_id - RoleSingletonNotAdmitted { cause: _ } => false + all(stale_srv1, s => admission_refused_as_stale(r: classify_role_singleton_admission(spec: s, desired: fx_moved(), decision: moved_install))) + && all(stale_srv1, s => admission_refused_as_stale(r: classify_role_singleton_admission(spec: s, desired: fx_unheld(), decision: unheld_noop))) + && all(spec_of(realization: fx_moved(), id: lab_id), s => admission_refused_as_stale(r: classify_role_singleton_admission(spec: s, desired: fx_moved(), decision: moved_install))) + && all(spec_of(realization: fx_moved(), id: srv2_id), s => match classify_role_singleton_admission(spec: s, desired: fx_moved(), decision: moved_install) { + RoleSingletonAdmissible { decision: _ } => (s.instance_id as String) == srv2_id + RoleSingletonInadmissible { cause: _ } => false }) && count(stale_srv1) == 1 } @@ -585,7 +643,7 @@ test fn the_real_selection_places_every_singleton_on_its_holder_and_converges_as let is_noop = match noop { RoleSingletonNoop => true _ => false } count(live) == 1 && all(live, s => spec_store_directory_count(spec: s) > 0 && spec_store_artifact_count(spec: s) == 2 && deployment_owns_the_host_approval_broker(spec: s) - && match admit_role_singletons(spec: s, desired: real, decision: noop) { RoleSingletonAdmitted { admission: _ } => true RoleSingletonNotAdmitted { cause: _ } => false }) + && match classify_role_singleton_admission(spec: s, desired: real, decision: noop) { RoleSingletonAdmissible { decision: _ } => true RoleSingletonInadmissible { cause: _ } => false }) && all(others, s => spec_holds_no_singleton(spec: s)) && any(job_labels, l => l == (srv1_live_dashboard_instance().host_identity as String)) && is_noop diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index a9b5008c9ce..8fd871ac674 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -33,9 +33,9 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - `RoleSingletonRefused`, with one of these causes: move, removal, `RoleSingletonDesiredRealizationRefused`, unobservable, two-hosts, misplaced, two-on-one, ambiguous, broker-without-store, partial, stale-spec. Only `ProdRoleUnheld` means no prod. - - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec. Apply consumes only `admitted_spec`, so a stale spec, a holding spec under NoProdRole, or a non-holder during Install refuses before member planning. The apply receipt records `role_singleton_disposition` (Noop | Installed | Adopted | RefusedBeforeApply). - - **Adoption scope.** `AdoptUnmarkedHolder { holder, host, retirement }` is a typed one-time migration for the realization that predates the marker. It applies only when the desired holder's unit and broker are on its own target, nothing is on any other host, and only the marker is missing. It admits only the holder spec on the observed host. The marker is written inside an operator-triggered apply, and the receipt names `Adopted`. - - **Retirement.** The arm carries `adopt_unmarked_holder_frontier` (`std.dissolution`). Trigger: an exact-host receipt records Adopted, then an independent observation returns one complete marked realization and Noop; then delete the arm. After adoption, a missing marker is partial state, never re-adopted. + - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec, and apply consumes only `admitted_spec`. Its one mint, `mint_role_singleton_admission`, is sealed with `admit_callers` to the canonical assessor `assess_and_admit(spec, own)`. That assessor reads the actuated desired realization and the ssh observation itself, so no caller can supply a decision or a desired realization. Fixtures use the pure `classify_role_singleton_admission`, which returns a standing, never a token. A stale spec, a holding spec under NoProdRole, or a non-holder spec during Install refuses before member planning. The apply receipt records `role_singleton_disposition` (Noop, Installed, Adopted or RefusedBeforeApply). + - **Adoption scope.** `AdoptUnmarkedHolder { holder, host, standing, retirement }` is bound to `legacy_unmarked_holders`. That is exactly one `LegacyUnmarkedHolderStanding` (`sole_constructor`) row: srv1-daily-workspace on srv1, naming its marker path and evidence. Any other unmarked holder refuses as `RoleSingletonPartial`, even when it matches exactly. Only the holder spec on the observed host is admitted, and the marker is written inside an operator-triggered apply. + - **Retirement.** The retirement subject is that one legacy realization. Trigger: an exact-host receipt records Adopted for it, and an independent later observation sees the same realization complete, marked and Noop. Then delete `legacy_unmarked_holders`, the standing type and the arm. Until that deletion, a marker removed from the adopted srv1 store would be re-adopted, because no durable host record yet says it was adopted. After the deletion, it refuses. - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. From 2ef9541f7a7306531156579a23f829e37acf1da3 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 18:01:32 +0000 Subject: [PATCH 30/48] plan: adoption window is one apply long (retire the legacy row after the first prod apply's observation) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/plans/deployment-risk-conformance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index 8fd871ac674..263dfb21283 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -35,7 +35,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli Only `ProdRoleUnheld` means no prod. - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec, and apply consumes only `admitted_spec`. Its one mint, `mint_role_singleton_admission`, is sealed with `admit_callers` to the canonical assessor `assess_and_admit(spec, own)`. That assessor reads the actuated desired realization and the ssh observation itself, so no caller can supply a decision or a desired realization. Fixtures use the pure `classify_role_singleton_admission`, which returns a standing, never a token. A stale spec, a holding spec under NoProdRole, or a non-holder spec during Install refuses before member planning. The apply receipt records `role_singleton_disposition` (Noop, Installed, Adopted or RefusedBeforeApply). - **Adoption scope.** `AdoptUnmarkedHolder { holder, host, standing, retirement }` is bound to `legacy_unmarked_holders`. That is exactly one `LegacyUnmarkedHolderStanding` (`sole_constructor`) row: srv1-daily-workspace on srv1, naming its marker path and evidence. Any other unmarked holder refuses as `RoleSingletonPartial`, even when it matches exactly. Only the holder spec on the observed host is admitted, and the marker is written inside an operator-triggered apply. - - **Retirement.** The retirement subject is that one legacy realization. Trigger: an exact-host receipt records Adopted for it, and an independent later observation sees the same realization complete, marked and Noop. Then delete `legacy_unmarked_holders`, the standing type and the arm. Until that deletion, a marker removed from the adopted srv1 store would be re-adopted, because no durable host record yet says it was adopted. After the deletion, it refuses. + - **Retirement.** The retirement subject is that one legacy realization. Trigger: an exact-host receipt records Adopted for it, and an independent later observation sees the same realization complete, marked and Noop. Then delete `legacy_unmarked_holders`, the standing type and the arm. Until that deletion, a marker removed from the adopted srv1 store would be re-adopted, because no durable host record yet says it was adopted. After the deletion, it refuses. The window is one apply long by schedule: the first prod apply after this lands is the adoption, and its post-apply observation is the first step toward retirement. If that observation shows the realization complete, marked and Noop, the trigger is met, and the deletion PR follows immediately. Merry-tern-58 accepted this on 2026-10-04 in place of a durable host-side adoption record, which would be a second authority beside the marker (§3). - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. From 973b56f2d4f850b1c24ed7e3777a484ad9b50a93 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 18:03:19 +0000 Subject: [PATCH 31/48] D2 adoption: a LegacyAdoptionRecord (own type) written only by the adopting apply; the observer reads it, and adoption requires it absent, so a marker deleted after adoption refuses as partial Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2run22.sh | 30 +++++++++ dag/gunbc/fabric/fabric_storage_placement.dag | 8 +++ dag/gunbc/live_deploy/apply.dag | 6 +- dag/gunbc/live_deploy/emit.dag | 4 ++ .../role_singleton_convergence.dag | 65 +++++++++++++++++-- dag/gunbc/live_deploy/spec.dag | 3 + .../claim/deployment_risk_witness_test.dag | 34 ++++++++++ .../devboot_subject_identity_witness_test.dag | 2 + docs/plans/deployment-risk-conformance.md | 2 +- 9 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 .d2run22.sh diff --git a/.d2run22.sh b/.d2run22.sh new file mode 100644 index 00000000000..fb4d5f13d5d --- /dev/null +++ b/.d2run22.sh @@ -0,0 +1,30 @@ +set -u +export PAGER=cat GIT_PAGER=cat +cargo build --release -p v1-compiler --bin gunbc --bin claim_batch --bin v1_src_dag_parse 2>&1 | tail -1 +mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 23622320128 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs +run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^FAIL" /tmp/cb.out; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)|^(PASS|FAIL)" | tail -14; fi; echo rc=$rc; } +run dag/test/claim/deployment_risk_witness_test.dag a_test_deployment_refuses_rather_than_resolving_the_prod_bindings,the_prod_role_holder_resolves_the_prod_topic_and_app,each_class_resolves_only_its_own_set,no_prod_role_makes_the_former_holder_test_risk_and_it_refuses,moving_the_role_moves_the_class_and_nothing_else_decides_it,exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace,the_wrong_instance_failure_mode_row_cites_this_red,moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses,a_prod_role_holder_with_no_instance_refuses_as_unrealized,a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move,no_prod_role_with_a_holder_observed_refuses_as_a_removal,an_unobservable_roster_host_refuses_rather_than_reading_as_absent,nothing_observed_and_a_valid_holder_installs_on_that_holder,exactly_one_complete_realization_of_the_desired_holder_on_its_target_is_a_noop,an_unrealized_or_ambiguous_holder_refuses_as_desired_side_in_empty_and_occupied_worlds,the_same_deployment_on_two_hosts_refuses,a_deployment_targeted_at_srv2_but_realized_on_srv1_refuses,two_deployments_stores_on_one_host_refuse,two_observed_holders_or_a_broker_without_a_store_refuse,an_old_store_with_no_unit_or_a_partial_realization_refuses,an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker,only_the_legacy_row_is_adoptable_and_a_retired_world_refuses_a_deleted_marker,no_outside_module_can_mint_an_admission_or_state_a_legacy_standing,an_unmarked_holder_on_the_wrong_host_is_refused_not_adopted,a_stale_spec_is_refused_before_member_planning,a_holder_with_no_desired_row_is_unactuatable_and_holds_nothing,the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop,the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_refusal +run dag/test/claim/live_deploy/apply_receipt_witness_test.dag witness_dashboard_receipt_round_trips,witness_dashboard_receipt_not_converged_round_trips_its_reason,witness_dashboard_receipt_with_tampered_candidate_refuses,witness_dashboard_receipt_without_completed_at_refuses,witness_dashboard_receipt_with_tampered_plan_receipt_identity_refuses +run dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag witness_complete_population_is_complete,witness_complete_receipt_document_names_its_verdict_and_identity,witness_plan_run_at_another_sha_is_incomplete_at_plan_receipt,witness_apply_run_at_another_sha_is_incomplete_at_apply_receipt,witness_dashboard_run_at_another_sha_is_incomplete_at_dashboard_receipt,witness_plan_run_id_substitution_is_incomplete_at_plan_receipt,witness_apply_bound_to_another_plan_is_incomplete_at_apply_receipt,witness_dashboard_bound_to_another_r_is_incomplete_at_dashboard_receipt,witness_plan_observed_on_another_host_is_incomplete_at_plan_receipt,witness_receipt_run_on_another_host_is_incomplete_at_observed_host,witness_deployed_tree_mismatch_is_incomplete_at_deployed_tree,witness_stale_tick_is_incomplete_at_tick_standing,witness_wrong_instance_tick_is_incomplete_at_tick_standing,witness_wrong_revision_tick_is_incomplete_at_tick_standing,witness_automatic_ready_tick_is_incomplete_at_tick_standing,witness_v3_tick_on_disk_is_incomplete_at_tick_standing,witness_disabled_timer_beside_fresh_tick_is_incomplete_at_unit_standing,witness_active_oneshot_mid_tick_is_still_complete,witness_wrong_principal_is_incomplete_at_principal,witness_window_change_is_incomplete_at_observation_window,witness_transition_marker_is_incomplete_at_transition,witness_unwritable_root_is_incomplete_at_footprint,witness_missing_provider_credential_is_incomplete_at_provider_preflight,witness_unobservable_attempt_ledger_is_incomplete_at_workflow_endpoint,witness_workflow_json_without_a_tick_is_incomplete_at_workflow_endpoint,witness_desired_ref_behind_r_is_incomplete_at_desired_revision,witness_two_substitutions_retain_both_blockers,witness_incomplete_receipt_document_carries_its_blockers,witness_receipt_identity_is_stable_and_discriminating,witness_partially_applied_exit_zero_is_incomplete_at_apply_receipt,witness_apply_admitted_against_q_is_incomplete_at_apply_receipt,witness_predecessor_non_success_conclusion_is_incomplete_at_run_provenance,witness_predecessor_at_another_sha_is_incomplete_at_run_provenance,witness_predecessor_on_another_workflow_is_incomplete_at_run_provenance,witness_predecessor_run_id_substitution_is_incomplete_at_run_provenance,witness_receipt_workflow_ref_not_named_is_incomplete_at_run_provenance,witness_provenance_unobserved_is_incomplete_at_run_provenance,witness_dashboard_citing_another_plan_receipt_is_incomplete_at_dashboard_receipt,witness_receipt_document_facts_are_machine_readable,witness_receipt_run_in_another_repository_is_incomplete_at_run_provenance,witness_routed_surface_mixed_is_incomplete_at_release_binding,witness_route_change_during_window_is_incomplete_at_observation_window,witness_receipt_document_fresh_age_is_machine_readable,witness_receipt_document_stale_age_and_bound_are_machine_readable,witness_a_full_host_plan_cannot_occupy_the_rlm_plan_slot,witness_a_fabric_cells_plan_cannot_occupy_the_rlm_plan_slot,witness_an_allocation_store_plan_cannot_occupy_the_rlm_plan_slot,witness_an_undecodable_plan_scope_wire_blocks_the_transaction +run dag/test/claim/approval_request_submission_witness_test.dag a_signed_envelope_parses_back_to_the_same_request_and_tag,a_non_canonical_record_is_refused_before_it_is_verified,a_correctly_signed_submission_is_admitted_and_a_wrong_key_is_not_enrolled,a_slot_with_no_minted_capability_does_not_admit_poll,the_capability_key_cannot_file_a_request,issued_links_carry_tags_that_verify_over_the_stored_claims_for_their_own_decision_only,the_message_carries_purpose_both_links_and_the_destructive_mark,a_non_hex_capability_key_issues_no_link,status_text_reports_not_filed_for_an_absent_slot_and_unreadable_off_the_success_channel,a_filed_request_with_a_publisher_token_is_published_and_without_one_is_reported,an_occupied_slot_is_renotifiable_only_while_pending_with_the_same_bytes,a_capability_key_refusal_after_filing_names_the_filed_escalation +run dag/test/claim/live_deploy/emit_test.dag witness_spec_targets_srv1,witness_spec_uses_apt_package_manager,witness_spec_listen_port_is_8080,witness_owned_vs_ensured_typed,witness_apply_provisions_attempt_state_root_owned,witness_attempt_state_mode_derives_to_owner_only,witness_retract_attempt_state_root_refuses_when_nonempty,witness_tailnet_domain_propagates_optional,witness_tailnet_url_absent_not_fabricated,witness_dependency_ensure_is_idempotent,witness_apply_preamble_has_no_embedded_preflight,witness_apply_preamble_installs_only_the_deploy_sudoers,witness_apply_pipeline_carries_the_preamble_after_its_frame,witness_retract_preamble_acknowledges_shared_sudoers_install,the_convergence_slice_is_installed_before_the_convergence_restart,witness_apply_script_contains_systemd_and_tailscale,witness_apply_script_wires_memory_cap_deploy_consumer,retract_script_stays_within_ci_runner_grants,apply_script_stays_within_ci_runner_grants,git_metadata_file_transport_is_present_in_the_legacy_specimen_and_absent_from_emission,repository_convergence_is_ordered_after_binary_and_before_readback,witness_tree_sync_restart_diagnoses_on_failure,witness_retract_script_owned_only,witness_apply_script_has_no_node_server,witness_apply_script_has_no_frozen_page,witness_apply_script_excludes_readback_curls,witness_retract_owned_process_removes_serve_service,witness_execstart_carries_exactly_the_admitted_revision,witness_execstart_omitting_the_revision_is_not_the_emitted_line,witness_execstart_at_another_candidate_differs,witness_systemd_unit_emitted,witness_remote_mutation_service_op_realization_binds_emit_shell_phase_b_axis,witness_emit_shell_dissolution_trigger_is_scaffold,witness_dependency_graph_emit_frontier_binds_orch_emit_pipeline,witness_teardown_dependency_arm_refuses_golden_independent,witness_wholesale_refused_poison_carries_marker,witness_deploy_access_job_principal_refused_poison_golden_independent,witness_apply_script_access_ready_not_deploy_access_poison,a_twin_deployment_on_one_host_collides_with_nothing,twin_teardown_removes_nothing_production_owns,retract_is_the_reverse_of_what_this_spec_applies,production_apply_carries_its_own_handler_and_tree_sync_env,emitted_twin_apply_touches_none_of_productions_effects,twin_belt_unit_carries_the_twins_spawn_root_and_not_productions,production_belt_unit_carries_productions_spawn_root,emitted_twin_retract_removes_nothing_of_productions,shell_quote_escapes_an_embedded_apostrophe,production_apply_creates_productions_binary_parent_directory,twin_apply_creates_its_own_binary_parent_directory_and_not_productions,teardown_disposition_separates_owned_from_ensured,production_apply_serves_its_own_endpoint_and_scopes_no_path,twin_apply_serves_its_own_endpoint_and_not_productions,production_retract_offs_its_own_endpoint,twin_retract_offs_its_own_endpoint_and_not_productions,retract_is_not_emitted_without_an_observed_scoped_removal,fleet_converge_timer_not_installed_by_deploy_apply,emit_RED_unit_files_are_not_written_through_here_documents,emit_staged_unit_body_is_quoted_by_the_posix_authority,the_preamble_ensures_the_publication_principal_as_a_nologin_system_account,the_publication_member_emits_convergence_and_no_rsync,a_zero_mutation_plan_installs_publishes_and_restarts_nothing_of_the_release,the_full_plan_lands_its_members_in_the_deployments_order,the_fabric_storage_store_root_reaches_the_srv1_apply_path,the_fabric_storage_store_areas_reach_the_srv1_apply_path,the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_principal,the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone,the_placed_fabric_store_derives_entry_mode_0400,no_retract_can_remove_the_fabric_storage_store,a_binary_only_plan_installs_and_restarts_without_publishing_the_tree,a_matching_authorization_applies_the_staged_caps,a_withheld_decision_applies_no_caps_at_the_production_root,an_authorization_for_another_host_applies_no_caps,the_writer_reverts_only_the_authorized_target_units,the_dark_install_never_names_the_roadmap_unit,the_dark_install_execstart_carries_the_admitted_revision,a_twin_apply_neither_writes_nor_restarts_the_host_broker,the_owning_apply_does_write_and_restart_the_host_broker,both_install_paths_bind_the_brokers_one_port,a_twin_dark_install_refuses_rather_than_emitting_an_empty_stage,the_broker_unit_binds_loopback_and_runs_from_its_own_tree,the_dark_install_populates_the_broker_tree,the_dark_install_and_the_unit_name_the_same_candidate_binary,the_receipt_command_substitutes_the_digest_rather_than_splicing_it,the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it +cp dag/gunbc/live_deploy/role_singleton_convergence.dag /tmp/c.bak +echo "=== MUTANT B: multiplicity deduplicated (two-hosts and misplaced dropped)" +sed -i 's/ let two_hosts = ids |> flat_map(d => if count(/ let two_hosts_unused = ids |> flat_map(d => if count(/; s/ let misplaced = ids |> flat_map(/ let misplaced_unused = ids |> flat_map(/; s/concat(concat(unobservable, two_hosts), concat(misplaced, two_on_one))/concat(concat(unobservable, []), concat([], two_on_one))/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'two_hosts_unused' dag/gunbc/live_deploy/role_singleton_convergence.dag +run dag/test/claim/deployment_risk_witness_test.dag the_same_deployment_on_two_hosts_refuses,a_deployment_targeted_at_srv2_but_realized_on_srv1_refuses,exactly_one_complete_realization_of_the_desired_holder_on_its_target_is_a_noop +cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag +echo "=== MUTANT D: every desired refusal read as no-prod" +sed -i 's/ ProdRoleHolderUnrealized { deployment: _ } => false/ ProdRoleHolderUnrealized { deployment: _ } => true/; s/ ProdRoleHolderAmbiguous { deployment: _, count: _ } => false/ ProdRoleHolderAmbiguous { deployment: _, count: _ } => true/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'ProdRoleHolderUnrealized { deployment: _ } => true' dag/gunbc/live_deploy/role_singleton_convergence.dag +run dag/test/claim/deployment_risk_witness_test.dag an_unrealized_or_ambiguous_holder_refuses_as_desired_side_in_empty_and_occupied_worlds +cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag +echo "=== MUTANT E: holding pinned to srv1-live" +sed -i 's/ if holder.instance.deployment == instance.deployment { HoldsProdRoleSingletons/ if (instance.instance_id as String) == "srv1-live" { HoldsProdRoleSingletons/' dag/gunbc/live_deploy/spec.dag +run dag/test/claim/deployment_risk_witness_test.dag moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop +cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag +echo "=== MUTANT F: adoption admits any agreeing spec" +sed -i 's/ else if desired_is_this_spec \&\& (spec.target.ssh_host as String) == (host as String) { admissible }/ else if true { admissible }/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'else if true { admitted }' dag/gunbc/live_deploy/role_singleton_convergence.dag +run dag/test/claim/deployment_risk_witness_test.dag an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker +cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag +echo "=== MUTANT G: adoption ignores which (deployment, host) the legacy row names" +sed -i 's/ legacy |> filter(l => l.deployment == deployment \&\& (l.host as String) == (host as String) \&\& (l.marker_path as String) == (marker_path as String)) |> first/ legacy |> first/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c '^ legacy |> first$' dag/gunbc/live_deploy/role_singleton_convergence.dag +run dag/test/claim/deployment_risk_witness_test.dag only_the_legacy_row_is_adoptable_and_a_retired_world_refuses_a_deleted_marker diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index 6f9ff57c828..ce41275cd73 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -82,6 +82,14 @@ fn fabric_storage_store_root(instance: HostDashboardInstance) -> NonEmptyStr { // store root, auth state and door directories are Ensured and no retract removes them, so a unit file // alone cannot say whether a host holds a store; the marker can, and the role-singleton observation // reads it (gunbc.live_deploy.role_singleton_convergence). +// THE LEGACY ADOPTION RECORD, beside the marker in the same store root: written once, by the apply +// that adopts the one pre-marker realization (gunbc.live_deploy.role_singleton_convergence +// AdoptUnmarkedHolder), and read back by every later observation, so a marker deleted after the +// adoption reads as partial state rather than as a realization still waiting to be adopted. +fn fabric_storage_adoption_record_path(instance: HostDashboardInstance) -> NonEmptyStr { + dashboard_instance_child(root: dashboard_instance_fabric_storage_root(instance: instance), child: join([".gunbc-legacy-adoption-", instance.instance_id as String], "")) as String as NonEmptyStr +} + fn fabric_storage_ownership_marker_path(instance: HostDashboardInstance) -> NonEmptyStr { dashboard_ownership_marker_in(root: dashboard_instance_fabric_storage_root(instance: instance), instance_id: instance.instance_id) as String as NonEmptyStr } diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index 837aab3af0e..7c699fe1a62 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -68,7 +68,7 @@ import gunbc.host_effect_realize { host_effect_apply_gated } import gunbc.live_deploy.member_observe { deployment_effect_plan, deployment_identity_readback, IdentityReadbackConverged, IdentityReadbackDrift, IdentityReadbackRefused } import gunbc.live_deploy.member_identity { EffectsDerived, EffectsRefused, decision_label } import gunbc.live_deploy.role_singleton_convergence { - assess_and_admit, role_singleton_refusal_detail, admitted_spec, admitted_decision, + assess_and_admit, role_singleton_refusal_detail, admitted_spec, admitted_spec_for_candidate, admitted_decision, RoleSingletonAdmission, RoleSingletonAdmitted, RoleSingletonNotAdmitted, RoleSingletonNoop, RoleSingletonInstall, AdoptUnmarkedHolder, RoleSingletonRefused, RoleSingletonConvergence, } @@ -283,7 +283,7 @@ fn live_deploy_apply_mutation_via_transport( transport: HostEffectTransport, access: DeployAccess ) -> Reconciliation { - live_deploy_apply_member_plan_via_transport(spec: admitted_spec(admission: admission), candidate: candidate, transport: transport, access: access) + live_deploy_apply_member_plan_via_transport(spec: admitted_spec_for_candidate(admission: admission, revision: candidate.revision as String as NonEmptyStr), candidate: candidate, transport: transport, access: access) } // THE PLAN IS DERIVED FROM THE HOST BEFORE THE MUTATION IS BUILT (gunbc.live_deploy.member_observe): @@ -435,7 +435,7 @@ fn live_deploy_apply_admitted_via_transport( transport: HostEffectTransport, access: DeployAccess ) -> Reconciliation { - let spec = admitted_spec(admission: admission) + let spec = admitted_spec_for_candidate(admission: admission, revision: candidate.revision as String as NonEmptyStr) let mutation = live_deploy_apply_mutation_via_transport( admission: admission, candidate: candidate, diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index 8954b725169..2026cd25cd8 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -1427,6 +1427,10 @@ fn ensure_dependency_steps(dep: DeploymentDependencyStep, privileged: Bool) -> L EnsuredOwnershipMarker { path, owner } => [ deploy_raw(command: install_file_owned_command(mode: "0644" as NonEmptyStr, owner: owner, group: owner, source: "/dev/null", dest: path as String)), ] + EnsuredAdoptionRecord { path, content } => [ + deploy_raw(command: deploy_stage_write_command(stage_name: "legacy-adoption-record", content: content as String)), + deploy_raw(command: deploy_stage_install_command(stage_name: "legacy-adoption-record", mode: install_file_shared_mode, dest: path as String)), + ] } } diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index 2bb78ee53ef..ae08dbb5da5 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -22,12 +22,16 @@ import gunbc.roadmap_dashboard_instance { import gunbc.host_effect { HostEffectTransport, SshShell } import gunbc.live_deploy.spec { DeploymentSpec, + DeploymentDependencyStep, + Dependency, + FabricStorageLegacyAdoptionRecord, + EnsuredAdoptionRecord, HoldsProdRoleSingletons, HoldsNoRoleSingleton, deployment_fabric_storage_unit_path, gunbc_approval_broker_unit_path, } -import gunbc.fabric_storage_placement { fabric_storage_ownership_marker_path } +import gunbc.fabric_storage_placement { fabric_storage_ownership_marker_path, fabric_storage_adoption_record_path } import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization } import gunbc.live_deploy.member_observe { ObservationArm, @@ -68,6 +72,7 @@ type RoleSingletonMember = BrokerUnitMember | FabricUnitMember | StoreMarkerMember + | AdoptionRecordMember // ONE SIGHTING, KEPT AT (host, deployment, member) GRAIN until multiplicity is adjudicated -- never // deduplicated by DeploymentId first, which is how the same deployment on two hosts, or a deployment @@ -167,9 +172,10 @@ type RoleSingletonObservation fn member_eq(a: RoleSingletonMember, b: RoleSingletonMember) -> Bool { match a { - BrokerUnitMember => match b { BrokerUnitMember => true FabricUnitMember => false StoreMarkerMember => false } - FabricUnitMember => match b { BrokerUnitMember => false FabricUnitMember => true StoreMarkerMember => false } - StoreMarkerMember => match b { BrokerUnitMember => false FabricUnitMember => false StoreMarkerMember => true } + BrokerUnitMember => match b { BrokerUnitMember => true FabricUnitMember => false StoreMarkerMember => false AdoptionRecordMember => false } + FabricUnitMember => match b { BrokerUnitMember => false FabricUnitMember => true StoreMarkerMember => false AdoptionRecordMember => false } + StoreMarkerMember => match b { BrokerUnitMember => false FabricUnitMember => false StoreMarkerMember => true AdoptionRecordMember => false } + AdoptionRecordMember => match b { BrokerUnitMember => false FabricUnitMember => false StoreMarkerMember => false AdoptionRecordMember => true } } } @@ -313,7 +319,7 @@ fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingle } else { let missing = missing_members(r: r) if count(missing) == 0 { RoleSingletonNoop } - else if count(missing) == 1 && all(missing, m => member_eq(a: m, b: StoreMarkerMember)) { + else if count(missing) == 1 && all(missing, m => member_eq(a: m, b: StoreMarkerMember)) && !any(r.members, x => member_eq(a: x, b: AdoptionRecordMember)) { match legacy_standing_for(legacy: legacy, deployment: r.deployment, host: r.host, marker_path: fabric_storage_ownership_marker_path(instance: holder.instance)) { Present { value: standing } => AdoptUnmarkedHolder { holder: holder, host: r.host, standing: standing, retirement: adopt_unmarked_holder_frontier } Absent => RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } @@ -357,6 +363,54 @@ fn admitted_decision(admission: RoleSingletonAdmission) -> RoleSingletonConverge admission.decision } +// THE LEGACY ADOPTION RECORD, its own type: the marker says WHO OWNS the store; this says THAT the one +// legacy adoption happened, naming the legacy subject and the revision that adopted it. Written only +// by the adopting apply (admitted_spec_for_candidate appends it for AdoptUnmarkedHolder and for nothing +// else), and read back as AdoptionRecordMember: with the record present, a missing marker is partial +// state and refuses -- it is never adopted a second time. +type LegacyAdoptionRecord { + deployment: DeploymentId + host: NonEmptyStr + marker_path: NonEmptyStr + adopting_revision: NonEmptyStr +} + +fn legacy_adoption_record_text(r: LegacyAdoptionRecord) -> NonEmptyStr { + join(["deployment=", r.deployment.name as String, "\nhost=", r.host as String, "\nmarker=", r.marker_path as String, "\nadopting_revision=", r.adopting_revision as String, "\n"], "") as NonEmptyStr +} + +// THE SPEC THE APPLY RUNS, from the token and the candidate it installs. For every decision but +// AdoptUnmarkedHolder it is the admitted spec unchanged; for an adoption it is that spec with the +// legacy adoption record appended, written after the marker so an interrupted adoption leaves a marked +// realization (Noop next time) rather than a recorded one without its marker. +fn admitted_spec_for_candidate(admission: RoleSingletonAdmission, revision: NonEmptyStr) -> DeploymentSpec { + spec_for_decision(spec: admission.spec, decision: admission.decision, revision: revision) +} + +// The pure half, so a witness can read the adoption plan without minting a token. +fn spec_for_decision(spec: DeploymentSpec, decision: RoleSingletonConvergence, revision: NonEmptyStr) -> DeploymentSpec { + match decision { + AdoptUnmarkedHolder { holder, host, standing, retirement: _ } => { + let record = LegacyAdoptionRecord { deployment: standing.deployment, host: host, marker_path: standing.marker_path, adopting_revision: revision } + DeploymentSpec { + target: spec.target, + steps: concat(spec.steps, [Dependency { step: DeploymentDependencyStep { + kind: FabricStorageLegacyAdoptionRecord, + subject: EnsuredAdoptionRecord { path: fabric_storage_adoption_record_path(instance: holder.instance), content: legacy_adoption_record_text(r: record) }, + } }]), + service: spec.service, + approval_broker: spec.approval_broker, + names: spec.names, + instance_id: spec.instance_id, + role_singletons: spec.role_singletons, + } + } + RoleSingletonNoop => spec + RoleSingletonInstall { holder: _ } => spec + RoleSingletonRefused { cause: _ } => spec + } +} + fn stale(spec: DeploymentSpec, detail: String) -> RoleSingletonAdmissionStanding { RoleSingletonInadmissible { cause: RoleSingletonStaleSpec { instance_id: spec.instance_id, detail: detail } } } @@ -460,6 +514,7 @@ fn host_role_singleton_reading(host: NonEmptyStr, rows: List, rows |> flat_map(r => [ MemberProbe { deployment: Present { value: r.instance.deployment }, member: FabricUnitMember, presence: entry_presence(arm: arm, path: deployment_fabric_storage_unit_path(names: r.names) as String) }, MemberProbe { deployment: Present { value: r.instance.deployment }, member: StoreMarkerMember, presence: entry_presence(arm: arm, path: fabric_storage_ownership_marker_path(instance: r.instance) as String) }, + MemberProbe { deployment: Present { value: r.instance.deployment }, member: AdoptionRecordMember, presence: entry_presence(arm: arm, path: fabric_storage_adoption_record_path(instance: r.instance) as String) }, ]), ) match probes |> flat_map(p => match presence_cause(p: p.presence) { Present { value: c } => [c] Absent => [] }) |> first { diff --git a/dag/gunbc/live_deploy/spec.dag b/dag/gunbc/live_deploy/spec.dag index ba72d46aa7c..54884a9f5b4 100644 --- a/dag/gunbc/live_deploy/spec.dag +++ b/dag/gunbc/live_deploy/spec.dag @@ -121,6 +121,7 @@ type EnsuredDependencyKind = | CodexRuntimeReleaseDirectory | FabricStorageStoreDirectory | FabricStorageOwnershipMarker + | FabricStorageLegacyAdoptionRecord | ProviderStateRootDirectory // ONE CONCEPT -- AN ENSURED HOST RESOURCE -- WITH N KINDS, not a second ensured mechanism beside @@ -147,6 +148,7 @@ type EnsuredSubject | EnsuredHostDirectory { path: NonEmptyStr, owner: NonEmptyStr } | EnsuredManagedHostDirectory { admission: ManagedDirectoryAdmission } | EnsuredOwnershipMarker { path: NonEmptyStr, owner: NonEmptyStr } + | EnsuredAdoptionRecord { path: NonEmptyStr, content: NonEmptyStr } fn ensured_subject_identity(subject: EnsuredSubject) -> NonEmptyStr { match subject { @@ -154,6 +156,7 @@ fn ensured_subject_identity(subject: EnsuredSubject) -> NonEmptyStr { EnsuredHostDirectory { path, owner } => path EnsuredManagedHostDirectory { admission } => managed_directory_admission_path(a: admission) as String as NonEmptyStr EnsuredOwnershipMarker { path, owner: _ } => path + EnsuredAdoptionRecord { path, content: _ } => path } } diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 7842b7d84d2..f13c8625802 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -59,6 +59,7 @@ import gunbc.live_deploy.spec { FabricStorageStoreDirectory, FabricStorageOwnershipMarker, EnsuredOwnershipMarker, + EnsuredAdoptionRecord, deployment_srv1_target, srv1_live_deployment_names, deployment_spec_for, @@ -93,6 +94,8 @@ import gunbc.live_deploy.role_singleton_convergence { SightedBroker, FabricUnitMember, StoreMarkerMember, + AdoptionRecordMember, + spec_for_decision, HostRoleSingletonReading, HostRoleSingletonsRead, HostRoleSingletonsUnobservable, @@ -116,6 +119,7 @@ import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_door_socket, fabric_storage_ownership_marker_path, + fabric_storage_adoption_record_path, } import gunbc.auth.approval_request_client { ApprovalLoopbackOriginResolved, @@ -561,6 +565,36 @@ fn blocking_of(source: String, class: String) -> Int { } } +// THE LEGACY ADOPTION RECORD. Only the adoption plan writes it (Noop and Install plans do not), it +// names the legacy subject and the adopting revision, and once it is observed a marker deleted from +// the adopted store refuses as partial rather than being adopted again. +fn spec_writes_adoption_record(spec: DeploymentSpec) -> Bool { + any(spec.steps, s => match s { + Dependency { step } => match step.subject { + EnsuredAdoptionRecord { path, content } => + (path as String) == (fabric_storage_adoption_record_path(instance: srv1_live_dashboard_instance()) as String) + && string_contains(s: content as String, pattern: "deployment=srv1-daily-workspace") + && string_contains(s: content as String, pattern: "adopting_revision=feedface") + _ => false + } + ArtifactStep { step: _ } => false + }) +} + +test fn only_the_adoption_plan_writes_the_record_and_a_recorded_adoption_never_readopts() -> Bool { + let real = prod_role_actuated_realization() + let live = spec_of(realization: real, id: srv1_live_dashboard_instance().instance_id as String) + let adopt = decide(desired: real, readings: adopt_world()) + let recorded_marker_deleted = [read_on(host: "srv1", sightings: concat(units_without_marker(host: "srv1", d: srv1_daily_workspace_deployment), [ + SightedDeploymentMember { host: "srv1" as NonEmptyStr, deployment: srv1_daily_workspace_deployment, member: AdoptionRecordMember }, + ])), read_on(host: "srv2", sightings: [])] + count(live) == 1 + && all(live, s => spec_writes_adoption_record(spec: spec_for_decision(spec: s, decision: adopt, revision: "feedface" as NonEmptyStr))) + && all(live, s => !spec_writes_adoption_record(spec: spec_for_decision(spec: s, decision: decide(desired: real, readings: srv1_holds()), revision: "feedface" as NonEmptyStr))) + && all(live, s => !spec_writes_adoption_record(spec: s)) + && refused_with(c: decide(desired: real, readings: recorded_marker_deleted), label: "partial") +} + test fn no_outside_module_can_mint_an_admission_or_state_a_legacy_standing() -> Bool { blocking_of(source: forged_admission_source, class: "ConstructorCallAdmissionRefused") >= 1 && blocking_of(source: forged_standing_source, class: "SoleConstructorViolation") >= 1 diff --git a/dag/test/claim/devboot_subject_identity_witness_test.dag b/dag/test/claim/devboot_subject_identity_witness_test.dag index 3dceb4b2af9..4e3eb3ec6e9 100644 --- a/dag/test/claim/devboot_subject_identity_witness_test.dag +++ b/dag/test/claim/devboot_subject_identity_witness_test.dag @@ -16,6 +16,7 @@ import extdeps.git.object_store { GitObjectId, git_object_id_from_untagged_hex } import gunbc.host_layout { srv1_devboot_artifact_store_root } import gunbc.live_deploy.spec { EnsuredOwnershipMarker, + EnsuredAdoptionRecord, DeploymentStep, Dependency, ArtifactStep, @@ -731,6 +732,7 @@ fn devboot_ensured_step_projects_layout(acc: Int, step: DeploymentDependencyStep EnsuredPackage { package } => acc EnsuredManagedHostDirectory { admission: _ } => acc EnsuredOwnershipMarker { path: _, owner: _ } => acc + EnsuredAdoptionRecord { path: _, content: _ } => acc } } diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index 263dfb21283..b12f5525f52 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -35,7 +35,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli Only `ProdRoleUnheld` means no prod. - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec, and apply consumes only `admitted_spec`. Its one mint, `mint_role_singleton_admission`, is sealed with `admit_callers` to the canonical assessor `assess_and_admit(spec, own)`. That assessor reads the actuated desired realization and the ssh observation itself, so no caller can supply a decision or a desired realization. Fixtures use the pure `classify_role_singleton_admission`, which returns a standing, never a token. A stale spec, a holding spec under NoProdRole, or a non-holder spec during Install refuses before member planning. The apply receipt records `role_singleton_disposition` (Noop, Installed, Adopted or RefusedBeforeApply). - **Adoption scope.** `AdoptUnmarkedHolder { holder, host, standing, retirement }` is bound to `legacy_unmarked_holders`. That is exactly one `LegacyUnmarkedHolderStanding` (`sole_constructor`) row: srv1-daily-workspace on srv1, naming its marker path and evidence. Any other unmarked holder refuses as `RoleSingletonPartial`, even when it matches exactly. Only the holder spec on the observed host is admitted, and the marker is written inside an operator-triggered apply. - - **Retirement.** The retirement subject is that one legacy realization. Trigger: an exact-host receipt records Adopted for it, and an independent later observation sees the same realization complete, marked and Noop. Then delete `legacy_unmarked_holders`, the standing type and the arm. Until that deletion, a marker removed from the adopted srv1 store would be re-adopted, because no durable host record yet says it was adopted. After the deletion, it refuses. The window is one apply long by schedule: the first prod apply after this lands is the adoption, and its post-apply observation is the first step toward retirement. If that observation shows the realization complete, marked and Noop, the trigger is met, and the deletion PR follows immediately. Merry-tern-58 accepted this on 2026-10-04 in place of a durable host-side adoption record, which would be a second authority beside the marker (§3). + - **Retirement.** The retirement subject is that one legacy realization. Trigger: an exact-host receipt records Adopted for it, and an independent later observation sees the same realization complete, marked and Noop. Then delete `legacy_unmarked_holders`, the standing type and the arm. The adopting apply alone writes a `LegacyAdoptionRecord` (`.gunbc-legacy-adoption-` beside the marker: deployment, host, marker path, adopting revision). The observer reads it as a member, and adoption is admitted only while it is ABSENT, so a marker deleted after adoption refuses as partial rather than re-adopting. The marker says who owns the store; the record says the legacy adoption happened. Residual case: deleting BOTH files reads as the never-adopted shape. That window ends when the standing row is deleted by its trigger, which is scheduled for right after the first prod apply following this change: that apply adopts, and its next independent observation retires the row. - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. From 17b8bbc8fb3e2b3e4dc374dde7a3a3d78ea33e8c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 18:30:01 +0000 Subject: [PATCH 32/48] deployment_risk witness: blocking-row count through compile_diagnostic_census selectors Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/test/claim/deployment_risk_witness_test.dag | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index f13c8625802..adb2f2ac29e 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -69,7 +69,7 @@ import gunbc.live_deploy.spec { import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } import gunbc.live_deploy.apply { role_singleton_disposition_of_decision } import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } -import gunbc.compile_diagnostic_census { CensusObserved, CensusNotRunnable } +import gunbc.compile_diagnostic_census { CensusObserved, CensusNotRunnable, census_blocking_rows, census_total_count } import gunbc.live_deploy.apply_receipt { RoleSingletonsAdopted } import gunbc.live_deploy.role_singleton_convergence { adopt_unmarked_holder_frontier, @@ -553,7 +553,7 @@ data canonical_assessor_source: String = "module probe_canonical_assessor\nimpor fn blocking_rows(source: String) -> Int { match compile_dag_diagnostic_census(source) { - CensusObserved { rows } => fold(rows |> filter(r => r.blocking), init: 0, f: (acc, r) => acc + r.count) + CensusObserved { rows } => census_total_count(rows: census_blocking_rows(rows: rows)) CensusNotRunnable { cause: _ } => 0 } } From 9660e3fd19c7cb1fc75aab343f2035afbe63c4a9 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 19:40:49 +0000 Subject: [PATCH 33/48] D2: drop automated adoption; unmarked legacy store refuses naming the operator's one-time marker disposition; admission assessor, readers and consumers sealed to the canonical assess-and-act compositions; remove committed scratch harness Co-Authored-By: Claude Opus 5.5 (1M context) --- .d2run22.sh | 30 --- dag/gunbc/fabric/fabric_storage_placement.dag | 15 +- dag/gunbc/live_deploy/apply.dag | 30 ++- dag/gunbc/live_deploy/apply_receipt.dag | 14 +- dag/gunbc/live_deploy/emit.dag | 11 +- .../role_singleton_convergence.dag | 169 ++++---------- dag/gunbc/live_deploy/spec.dag | 11 +- ...ent_acts_on_the_prod_deployments_state.dag | 2 + .../claim/deployment_risk_witness_test.dag | 221 ++++++------------ .../devboot_subject_identity_witness_test.dag | 4 +- docs/plans/deployment-risk-conformance.md | 13 +- 11 files changed, 163 insertions(+), 357 deletions(-) delete mode 100644 .d2run22.sh diff --git a/.d2run22.sh b/.d2run22.sh deleted file mode 100644 index fb4d5f13d5d..00000000000 --- a/.d2run22.sh +++ /dev/null @@ -1,30 +0,0 @@ -set -u -export PAGER=cat GIT_PAGER=cat -cargo build --release -p v1-compiler --bin gunbc --bin claim_batch --bin v1_src_dag_parse 2>&1 | tail -1 -mkdir -p /sys/fs/cgroup/gunbcrun && echo +memory > /sys/fs/cgroup/cgroup.subtree_control && echo 23622320128 > /sys/fs/cgroup/gunbcrun/memory.max && echo $BASHPID > /sys/fs/cgroup/gunbcrun/cgroup.procs -run(){ echo "=== $1"; ./target/release/claim_batch --source-root dag --source-root src/v2 --entry $1 --functions $2 > /tmp/cb.out 2>&1; rc=$?; grep -E "^FAIL" /tmp/cb.out; echo pass=$(grep -c "^PASS" /tmp/cb.out); if [ $rc -ne 0 ]; then grep -v "^\[" /tmp/cb.out | grep -vE "^\s*(✓|◐|◷)|^(PASS|FAIL)" | tail -14; fi; echo rc=$rc; } -run dag/test/claim/deployment_risk_witness_test.dag a_test_deployment_refuses_rather_than_resolving_the_prod_bindings,the_prod_role_holder_resolves_the_prod_topic_and_app,each_class_resolves_only_its_own_set,no_prod_role_makes_the_former_holder_test_risk_and_it_refuses,moving_the_role_moves_the_class_and_nothing_else_decides_it,exactly_one_dashboard_instance_derives_prod_risk_and_it_is_the_daily_workspace,the_wrong_instance_failure_mode_row_cites_this_red,moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,under_no_prod_role_no_spec_holds_a_role_singleton_and_every_follower_refuses,a_prod_role_holder_with_no_instance_refuses_as_unrealized,a_moved_role_with_the_old_holder_still_observed_refuses_as_a_move,no_prod_role_with_a_holder_observed_refuses_as_a_removal,an_unobservable_roster_host_refuses_rather_than_reading_as_absent,nothing_observed_and_a_valid_holder_installs_on_that_holder,exactly_one_complete_realization_of_the_desired_holder_on_its_target_is_a_noop,an_unrealized_or_ambiguous_holder_refuses_as_desired_side_in_empty_and_occupied_worlds,the_same_deployment_on_two_hosts_refuses,a_deployment_targeted_at_srv2_but_realized_on_srv1_refuses,two_deployments_stores_on_one_host_refuse,two_observed_holders_or_a_broker_without_a_store_refuse,an_old_store_with_no_unit_or_a_partial_realization_refuses,an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker,only_the_legacy_row_is_adoptable_and_a_retired_world_refuses_a_deleted_marker,no_outside_module_can_mint_an_admission_or_state_a_legacy_standing,an_unmarked_holder_on_the_wrong_host_is_refused_not_adopted,a_stale_spec_is_refused_before_member_planning,a_holder_with_no_desired_row_is_unactuatable_and_holds_nothing,the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_role,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop,the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_refusal -run dag/test/claim/live_deploy/apply_receipt_witness_test.dag witness_dashboard_receipt_round_trips,witness_dashboard_receipt_not_converged_round_trips_its_reason,witness_dashboard_receipt_with_tampered_candidate_refuses,witness_dashboard_receipt_without_completed_at_refuses,witness_dashboard_receipt_with_tampered_plan_receipt_identity_refuses -run dag/test/claim/roadmap/roadmap_launch_deployment_receipt_witness_test.dag witness_complete_population_is_complete,witness_complete_receipt_document_names_its_verdict_and_identity,witness_plan_run_at_another_sha_is_incomplete_at_plan_receipt,witness_apply_run_at_another_sha_is_incomplete_at_apply_receipt,witness_dashboard_run_at_another_sha_is_incomplete_at_dashboard_receipt,witness_plan_run_id_substitution_is_incomplete_at_plan_receipt,witness_apply_bound_to_another_plan_is_incomplete_at_apply_receipt,witness_dashboard_bound_to_another_r_is_incomplete_at_dashboard_receipt,witness_plan_observed_on_another_host_is_incomplete_at_plan_receipt,witness_receipt_run_on_another_host_is_incomplete_at_observed_host,witness_deployed_tree_mismatch_is_incomplete_at_deployed_tree,witness_stale_tick_is_incomplete_at_tick_standing,witness_wrong_instance_tick_is_incomplete_at_tick_standing,witness_wrong_revision_tick_is_incomplete_at_tick_standing,witness_automatic_ready_tick_is_incomplete_at_tick_standing,witness_v3_tick_on_disk_is_incomplete_at_tick_standing,witness_disabled_timer_beside_fresh_tick_is_incomplete_at_unit_standing,witness_active_oneshot_mid_tick_is_still_complete,witness_wrong_principal_is_incomplete_at_principal,witness_window_change_is_incomplete_at_observation_window,witness_transition_marker_is_incomplete_at_transition,witness_unwritable_root_is_incomplete_at_footprint,witness_missing_provider_credential_is_incomplete_at_provider_preflight,witness_unobservable_attempt_ledger_is_incomplete_at_workflow_endpoint,witness_workflow_json_without_a_tick_is_incomplete_at_workflow_endpoint,witness_desired_ref_behind_r_is_incomplete_at_desired_revision,witness_two_substitutions_retain_both_blockers,witness_incomplete_receipt_document_carries_its_blockers,witness_receipt_identity_is_stable_and_discriminating,witness_partially_applied_exit_zero_is_incomplete_at_apply_receipt,witness_apply_admitted_against_q_is_incomplete_at_apply_receipt,witness_predecessor_non_success_conclusion_is_incomplete_at_run_provenance,witness_predecessor_at_another_sha_is_incomplete_at_run_provenance,witness_predecessor_on_another_workflow_is_incomplete_at_run_provenance,witness_predecessor_run_id_substitution_is_incomplete_at_run_provenance,witness_receipt_workflow_ref_not_named_is_incomplete_at_run_provenance,witness_provenance_unobserved_is_incomplete_at_run_provenance,witness_dashboard_citing_another_plan_receipt_is_incomplete_at_dashboard_receipt,witness_receipt_document_facts_are_machine_readable,witness_receipt_run_in_another_repository_is_incomplete_at_run_provenance,witness_routed_surface_mixed_is_incomplete_at_release_binding,witness_route_change_during_window_is_incomplete_at_observation_window,witness_receipt_document_fresh_age_is_machine_readable,witness_receipt_document_stale_age_and_bound_are_machine_readable,witness_a_full_host_plan_cannot_occupy_the_rlm_plan_slot,witness_a_fabric_cells_plan_cannot_occupy_the_rlm_plan_slot,witness_an_allocation_store_plan_cannot_occupy_the_rlm_plan_slot,witness_an_undecodable_plan_scope_wire_blocks_the_transaction -run dag/test/claim/approval_request_submission_witness_test.dag a_signed_envelope_parses_back_to_the_same_request_and_tag,a_non_canonical_record_is_refused_before_it_is_verified,a_correctly_signed_submission_is_admitted_and_a_wrong_key_is_not_enrolled,a_slot_with_no_minted_capability_does_not_admit_poll,the_capability_key_cannot_file_a_request,issued_links_carry_tags_that_verify_over_the_stored_claims_for_their_own_decision_only,the_message_carries_purpose_both_links_and_the_destructive_mark,a_non_hex_capability_key_issues_no_link,status_text_reports_not_filed_for_an_absent_slot_and_unreadable_off_the_success_channel,a_filed_request_with_a_publisher_token_is_published_and_without_one_is_reported,an_occupied_slot_is_renotifiable_only_while_pending_with_the_same_bytes,a_capability_key_refusal_after_filing_names_the_filed_escalation -run dag/test/claim/live_deploy/emit_test.dag witness_spec_targets_srv1,witness_spec_uses_apt_package_manager,witness_spec_listen_port_is_8080,witness_owned_vs_ensured_typed,witness_apply_provisions_attempt_state_root_owned,witness_attempt_state_mode_derives_to_owner_only,witness_retract_attempt_state_root_refuses_when_nonempty,witness_tailnet_domain_propagates_optional,witness_tailnet_url_absent_not_fabricated,witness_dependency_ensure_is_idempotent,witness_apply_preamble_has_no_embedded_preflight,witness_apply_preamble_installs_only_the_deploy_sudoers,witness_apply_pipeline_carries_the_preamble_after_its_frame,witness_retract_preamble_acknowledges_shared_sudoers_install,the_convergence_slice_is_installed_before_the_convergence_restart,witness_apply_script_contains_systemd_and_tailscale,witness_apply_script_wires_memory_cap_deploy_consumer,retract_script_stays_within_ci_runner_grants,apply_script_stays_within_ci_runner_grants,git_metadata_file_transport_is_present_in_the_legacy_specimen_and_absent_from_emission,repository_convergence_is_ordered_after_binary_and_before_readback,witness_tree_sync_restart_diagnoses_on_failure,witness_retract_script_owned_only,witness_apply_script_has_no_node_server,witness_apply_script_has_no_frozen_page,witness_apply_script_excludes_readback_curls,witness_retract_owned_process_removes_serve_service,witness_execstart_carries_exactly_the_admitted_revision,witness_execstart_omitting_the_revision_is_not_the_emitted_line,witness_execstart_at_another_candidate_differs,witness_systemd_unit_emitted,witness_remote_mutation_service_op_realization_binds_emit_shell_phase_b_axis,witness_emit_shell_dissolution_trigger_is_scaffold,witness_dependency_graph_emit_frontier_binds_orch_emit_pipeline,witness_teardown_dependency_arm_refuses_golden_independent,witness_wholesale_refused_poison_carries_marker,witness_deploy_access_job_principal_refused_poison_golden_independent,witness_apply_script_access_ready_not_deploy_access_poison,a_twin_deployment_on_one_host_collides_with_nothing,twin_teardown_removes_nothing_production_owns,retract_is_the_reverse_of_what_this_spec_applies,production_apply_carries_its_own_handler_and_tree_sync_env,emitted_twin_apply_touches_none_of_productions_effects,twin_belt_unit_carries_the_twins_spawn_root_and_not_productions,production_belt_unit_carries_productions_spawn_root,emitted_twin_retract_removes_nothing_of_productions,shell_quote_escapes_an_embedded_apostrophe,production_apply_creates_productions_binary_parent_directory,twin_apply_creates_its_own_binary_parent_directory_and_not_productions,teardown_disposition_separates_owned_from_ensured,production_apply_serves_its_own_endpoint_and_scopes_no_path,twin_apply_serves_its_own_endpoint_and_not_productions,production_retract_offs_its_own_endpoint,twin_retract_offs_its_own_endpoint_and_not_productions,retract_is_not_emitted_without_an_observed_scoped_removal,fleet_converge_timer_not_installed_by_deploy_apply,emit_RED_unit_files_are_not_written_through_here_documents,emit_staged_unit_body_is_quoted_by_the_posix_authority,the_preamble_ensures_the_publication_principal_as_a_nologin_system_account,the_publication_member_emits_convergence_and_no_rsync,a_zero_mutation_plan_installs_publishes_and_restarts_nothing_of_the_release,the_full_plan_lands_its_members_in_the_deployments_order,the_fabric_storage_store_root_reaches_the_srv1_apply_path,the_fabric_storage_store_areas_reach_the_srv1_apply_path,the_fabric_storage_areas_are_the_services_and_the_door_admits_the_claim_principal,the_fabric_storage_area_ensure_leaves_the_services_own_entries_alone,the_placed_fabric_store_derives_entry_mode_0400,no_retract_can_remove_the_fabric_storage_store,a_binary_only_plan_installs_and_restarts_without_publishing_the_tree,a_matching_authorization_applies_the_staged_caps,a_withheld_decision_applies_no_caps_at_the_production_root,an_authorization_for_another_host_applies_no_caps,the_writer_reverts_only_the_authorized_target_units,the_dark_install_never_names_the_roadmap_unit,the_dark_install_execstart_carries_the_admitted_revision,a_twin_apply_neither_writes_nor_restarts_the_host_broker,the_owning_apply_does_write_and_restart_the_host_broker,both_install_paths_bind_the_brokers_one_port,a_twin_dark_install_refuses_rather_than_emitting_an_empty_stage,the_broker_unit_binds_loopback_and_runs_from_its_own_tree,the_dark_install_populates_the_broker_tree,the_dark_install_and_the_unit_name_the_same_candidate_binary,the_receipt_command_substitutes_the_digest_rather_than_splicing_it,the_fabric_storage_unit_binds_its_door_socket_and_its_route_proxies_to_it -cp dag/gunbc/live_deploy/role_singleton_convergence.dag /tmp/c.bak -echo "=== MUTANT B: multiplicity deduplicated (two-hosts and misplaced dropped)" -sed -i 's/ let two_hosts = ids |> flat_map(d => if count(/ let two_hosts_unused = ids |> flat_map(d => if count(/; s/ let misplaced = ids |> flat_map(/ let misplaced_unused = ids |> flat_map(/; s/concat(concat(unobservable, two_hosts), concat(misplaced, two_on_one))/concat(concat(unobservable, []), concat([], two_on_one))/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'two_hosts_unused' dag/gunbc/live_deploy/role_singleton_convergence.dag -run dag/test/claim/deployment_risk_witness_test.dag the_same_deployment_on_two_hosts_refuses,a_deployment_targeted_at_srv2_but_realized_on_srv1_refuses,exactly_one_complete_realization_of_the_desired_holder_on_its_target_is_a_noop -cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag -echo "=== MUTANT D: every desired refusal read as no-prod" -sed -i 's/ ProdRoleHolderUnrealized { deployment: _ } => false/ ProdRoleHolderUnrealized { deployment: _ } => true/; s/ ProdRoleHolderAmbiguous { deployment: _, count: _ } => false/ ProdRoleHolderAmbiguous { deployment: _, count: _ } => true/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'ProdRoleHolderUnrealized { deployment: _ } => true' dag/gunbc/live_deploy/role_singleton_convergence.dag -run dag/test/claim/deployment_risk_witness_test.dag an_unrealized_or_ambiguous_holder_refuses_as_desired_side_in_empty_and_occupied_worlds -cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag -echo "=== MUTANT E: holding pinned to srv1-live" -sed -i 's/ if holder.instance.deployment == instance.deployment { HoldsProdRoleSingletons/ if (instance.instance_id as String) == "srv1-live" { HoldsProdRoleSingletons/' dag/gunbc/live_deploy/spec.dag -run dag/test/claim/deployment_risk_witness_test.dag moving_the_prod_role_moves_every_role_singleton_member_to_the_new_holders_spec,the_real_selection_places_every_singleton_on_its_holder_and_converges_as_noop -cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag -echo "=== MUTANT F: adoption admits any agreeing spec" -sed -i 's/ else if desired_is_this_spec \&\& (spec.target.ssh_host as String) == (host as String) { admissible }/ else if true { admissible }/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c 'else if true { admitted }' dag/gunbc/live_deploy/role_singleton_convergence.dag -run dag/test/claim/deployment_risk_witness_test.dag an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker -cp /tmp/c.bak dag/gunbc/live_deploy/role_singleton_convergence.dag -echo "=== MUTANT G: adoption ignores which (deployment, host) the legacy row names" -sed -i 's/ legacy |> filter(l => l.deployment == deployment \&\& (l.host as String) == (host as String) \&\& (l.marker_path as String) == (marker_path as String)) |> first/ legacy |> first/' dag/gunbc/live_deploy/role_singleton_convergence.dag; grep -c '^ legacy |> first$' dag/gunbc/live_deploy/role_singleton_convergence.dag -run dag/test/claim/deployment_risk_witness_test.dag only_the_legacy_row_is_adoptable_and_a_retired_world_refuses_a_deleted_marker diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index ce41275cd73..df619d66d95 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -82,18 +82,17 @@ fn fabric_storage_store_root(instance: HostDashboardInstance) -> NonEmptyStr { // store root, auth state and door directories are Ensured and no retract removes them, so a unit file // alone cannot say whether a host holds a store; the marker can, and the role-singleton observation // reads it (gunbc.live_deploy.role_singleton_convergence). -// THE LEGACY ADOPTION RECORD, beside the marker in the same store root: written once, by the apply -// that adopts the one pre-marker realization (gunbc.live_deploy.role_singleton_convergence -// AdoptUnmarkedHolder), and read back by every later observation, so a marker deleted after the -// adoption reads as partial state rather than as a realization still waiting to be adopted. -fn fabric_storage_adoption_record_path(instance: HostDashboardInstance) -> NonEmptyStr { - dashboard_instance_child(root: dashboard_instance_fabric_storage_root(instance: instance), child: join([".gunbc-legacy-adoption-", instance.instance_id as String], "")) as String as NonEmptyStr -} - fn fabric_storage_ownership_marker_path(instance: HostDashboardInstance) -> NonEmptyStr { dashboard_ownership_marker_in(root: dashboard_instance_fabric_storage_root(instance: instance), instance_id: instance.instance_id) as String as NonEmptyStr } +// THE MARKER'S CONTENT names whose store it is: the deployment and the host. The install writes it, +// and the refusal for an unmarked realization quotes it verbatim, so the operator's one-time disposition +// writes exactly what an install would have. +fn fabric_storage_ownership_marker_content(instance: HostDashboardInstance) -> NonEmptyStr { + join(["deployment=", instance.deployment.name as String, "\nhost=", instance.host_identity as String, "\n"], "") as NonEmptyStr +} + fn fabric_storage_endpoint_for(host: HostIdentity) -> NonEmptyStr? { match fleet_intent_network.tailnet_domain { Absent => none diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index 7c699fe1a62..d77d646a4b4 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -1,5 +1,6 @@ module gunbc.live_deploy.apply +import std.decl_ref { decl_ref } import gunbc.runner_microvm_cell_readiness_location { cell_readiness_store_location } import gunbc.runner_microvm_cell_readiness_store { read_store_ownership, OwnershipRead, OwnershipUnread } import gunbc.runner_microvm_cell_readiness { StoreRootOwned, StoreNotRootOwned } @@ -68,9 +69,9 @@ import gunbc.host_effect_realize { host_effect_apply_gated } import gunbc.live_deploy.member_observe { deployment_effect_plan, deployment_identity_readback, IdentityReadbackConverged, IdentityReadbackDrift, IdentityReadbackRefused } import gunbc.live_deploy.member_identity { EffectsDerived, EffectsRefused, decision_label } import gunbc.live_deploy.role_singleton_convergence { - assess_and_admit, role_singleton_refusal_detail, admitted_spec, admitted_spec_for_candidate, admitted_decision, + assess_and_admit, role_singleton_refusal_detail, admitted_spec, admitted_decision, RoleSingletonAdmission, RoleSingletonAdmitted, RoleSingletonNotAdmitted, - RoleSingletonNoop, RoleSingletonInstall, AdoptUnmarkedHolder, RoleSingletonRefused, RoleSingletonConvergence, + RoleSingletonNoop, RoleSingletonInstall, RoleSingletonRefused, RoleSingletonConvergence, } import gunbc.auth.approval_broker_cutover { CutoverAdmission, StageAdmitted, StageRefused, @@ -128,7 +129,7 @@ import gunbc.fleet_converge_receipt { fleet_converge_plan_receipt_path, fleet_converge_apply_receipt_path, } import gunbc.live_deploy.apply_receipt { - LiveDeployRoleSingletonDisposition, RoleSingletonsNoop, RoleSingletonsInstalled, RoleSingletonsAdopted, RoleSingletonsRefusedBeforeApply, + LiveDeployRoleSingletonDisposition, RoleSingletonsNoop, RoleSingletonsInstalled, RoleSingletonsRefusedBeforeApply, LiveDeployApplyReceipt, LiveDeployFleetProvenance, LiveDeployApplyOutcome, LiveDeployReadinessOutcome, read_live_deploy_fleet_provenance, FleetProvenancePresent, FleetProvenanceAbsent, LiveDeployFleetProvenance, @@ -282,8 +283,10 @@ fn live_deploy_apply_mutation_via_transport( candidate: CandidateRelease, transport: HostEffectTransport, access: DeployAccess -) -> Reconciliation { - live_deploy_apply_member_plan_via_transport(spec: admitted_spec_for_candidate(admission: admission, revision: candidate.revision as String as NonEmptyStr), candidate: candidate, transport: transport, access: access) +) -> Reconciliation admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_admitted_via_transport"), +] { + live_deploy_apply_member_plan_via_transport(spec: admitted_spec(admission: admission), candidate: candidate, transport: transport, access: access) } // THE PLAN IS DERIVED FROM THE HOST BEFORE THE MUTATION IS BUILT (gunbc.live_deploy.member_observe): @@ -434,8 +437,11 @@ fn live_deploy_apply_admitted_via_transport( candidate: CandidateRelease, transport: HostEffectTransport, access: DeployAccess -) -> Reconciliation { - let spec = admitted_spec_for_candidate(admission: admission, revision: candidate.revision as String as NonEmptyStr) +) -> Reconciliation admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_via_transport"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_transaction_decided"), +] { + let spec = admitted_spec(admission: admission) let mutation = live_deploy_apply_mutation_via_transport( admission: admission, candidate: candidate, @@ -1042,10 +1048,11 @@ fn live_deploy_write_transaction_receipt(spec: DeploymentSpec, receipt: LiveDepl } } -// THE ADMITTED DECISION, AS THE RECEIPT RECORDS IT. Adopted names the holder and host whose marker the -// apply wrote, so the retirement of AdoptUnmarkedHolder (its FrontierRow) has an exact-host receipt to -// point at; Installed names the first install; Noop is everything else that was admitted. -fn role_singleton_disposition_of(admission: RoleSingletonAdmission) -> LiveDeployRoleSingletonDisposition { +// THE ADMITTED DECISION, AS THE RECEIPT RECORDS IT: Installed names the first install, Noop is +// everything else that was admitted, and a refusal before any member was planned is RefusedBeforeApply. +fn role_singleton_disposition_of(admission: RoleSingletonAdmission) -> LiveDeployRoleSingletonDisposition admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_transaction_decided"), +] { role_singleton_disposition_of_decision(decision: admitted_decision(admission: admission)) } @@ -1053,7 +1060,6 @@ fn role_singleton_disposition_of_decision(decision: RoleSingletonConvergence) -> match decision { RoleSingletonNoop => RoleSingletonsNoop RoleSingletonInstall { holder } => RoleSingletonsInstalled { deployment: holder.instance.deployment.name as String, host: holder.instance.host_identity as String } - AdoptUnmarkedHolder { holder, host, standing: _, retirement: _ } => RoleSingletonsAdopted { deployment: holder.instance.deployment.name as String, host: host as String } RoleSingletonRefused { cause } => RoleSingletonsRefusedBeforeApply { detail: role_singleton_refusal_detail(cause: cause) } } } diff --git a/dag/gunbc/live_deploy/apply_receipt.dag b/dag/gunbc/live_deploy/apply_receipt.dag index c9713ae592e..4982ea6f8c0 100644 --- a/dag/gunbc/live_deploy/apply_receipt.dag +++ b/dag/gunbc/live_deploy/apply_receipt.dag @@ -120,19 +120,17 @@ type LiveDeployReadinessOutcome | LiveDeployReadinessRefused { detail: String } // WHAT THE ROLE-SINGLETON ASSESSMENT ADMITTED THIS APPLY TO DO (gunbc.live_deploy.role_singleton_convergence), -// carried in the receipt so an adoption -- the one apply that writes a pre-marker holder's store marker -- -// is visible and countable, and a refusal before any member was planned is told apart from a failed apply. +// carried in the receipt so a first install is visible and countable, +// and a refusal before any member was planned is told apart from a failed apply. type LiveDeployRoleSingletonDisposition = RoleSingletonsNoop | RoleSingletonsInstalled { deployment: String, host: String } - | RoleSingletonsAdopted { deployment: String, host: String } | RoleSingletonsRefusedBeforeApply { detail: String } fn live_deploy_role_singleton_disposition_text(d: LiveDeployRoleSingletonDisposition) -> String { match d { RoleSingletonsNoop => "noop" RoleSingletonsInstalled { deployment, host } => join(["installed:", deployment, "@", host], "") - RoleSingletonsAdopted { deployment, host } => join(["adopted:", deployment, "@", host], "") RoleSingletonsRefusedBeforeApply { detail } => join(["refused:", detail], "") } } @@ -141,7 +139,6 @@ fn live_deploy_role_singleton_disposition_json(d: LiveDeployRoleSingletonDisposi match d { RoleSingletonsNoop => json_object(members: [json_kv(key: "disposition", value: json_string(s: "noop"))]) RoleSingletonsInstalled { deployment, host } => json_object(members: [json_kv(key: "disposition", value: json_string(s: "installed")), json_kv(key: "deployment", value: json_string(s: deployment)), json_kv(key: "host", value: json_string(s: host))]) - RoleSingletonsAdopted { deployment, host } => json_object(members: [json_kv(key: "disposition", value: json_string(s: "adopted")), json_kv(key: "deployment", value: json_string(s: deployment)), json_kv(key: "host", value: json_string(s: host))]) RoleSingletonsRefusedBeforeApply { detail } => json_object(members: [json_kv(key: "disposition", value: json_string(s: "refused")), json_kv(key: "detail", value: json_string(s: detail))]) } } @@ -164,17 +161,16 @@ fn live_deploy_role_singleton_disposition_of_member(doc: JsonValue) -> LiveDeplo JsonStringMemberRefused { reason: r } => DispositionUnreadable { reason: r } JsonStringMemberFound { value: detail } => DispositionDecoded { disposition: RoleSingletonsRefusedBeforeApply { detail: detail } } } - } else if kind == "installed" || kind == "adopted" { + } else if kind == "installed" { match json_required_string_member(doc: d, key: "deployment") { JsonStringMemberRefused { reason: r } => DispositionUnreadable { reason: r } JsonStringMemberFound { value: dep } => match json_required_string_member(doc: d, key: "host") { JsonStringMemberRefused { reason: r } => DispositionUnreadable { reason: r } JsonStringMemberFound { value: host } => - if kind == "installed" { DispositionDecoded { disposition: RoleSingletonsInstalled { deployment: dep, host: host } } } - else { DispositionDecoded { disposition: RoleSingletonsAdopted { deployment: dep, host: host } } } + DispositionDecoded { disposition: RoleSingletonsInstalled { deployment: dep, host: host } } } } - } else { DispositionUnreadable { reason: join(["live-deploy receipt role_singleton_disposition is not one of noop|installed|adopted|refused: ", kind], "") } } + } else { DispositionUnreadable { reason: join(["live-deploy receipt role_singleton_disposition is not one of noop|installed|refused: ", kind], "") } } } } } diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index 2026cd25cd8..2e8e4b98194 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -191,6 +191,7 @@ import gunbc.membership_reconcile { } import gunbc.shell_command_text { shell_command_text_of_argv, shell_privileged_command_text_of_argv } import gunbc.live_deploy.operations { + install_file_owned_command, deploy_stage_dir_var, deploy_stage_write_command, deploy_stage_write_expansion_command, @@ -208,7 +209,6 @@ import gunbc.live_deploy.operations { rm_empty_directory_command, rm_force_recursive_command, install_directory_owned_command, - install_file_owned_command, } import gunbc.live_deploy.intent { live_deploy_unit_status_tail_command } import v2.std.orchestration { @@ -1424,12 +1424,9 @@ fn ensure_dependency_steps(dep: DeploymentDependencyStep, privileged: Bool) -> L EnsuredManagedHostDirectory { admission } => concat([deploy_raw(command: ensure_managed_directory_command(admission: admission))], map(managed_directory_entry_repair_commands(admission: admission), c => deploy_raw(command: c))) - EnsuredOwnershipMarker { path, owner } => [ - deploy_raw(command: install_file_owned_command(mode: "0644" as NonEmptyStr, owner: owner, group: owner, source: "/dev/null", dest: path as String)), - ] - EnsuredAdoptionRecord { path, content } => [ - deploy_raw(command: deploy_stage_write_command(stage_name: "legacy-adoption-record", content: content as String)), - deploy_raw(command: deploy_stage_install_command(stage_name: "legacy-adoption-record", mode: install_file_shared_mode, dest: path as String)), + EnsuredOwnershipMarker { path, owner: _, content } => [ + deploy_raw(command: deploy_stage_write_command(stage_name: "store-ownership-marker", content: content as String)), + deploy_raw(command: deploy_stage_install_command(stage_name: "store-ownership-marker", mode: install_file_shared_mode, dest: path as String)), ] } } diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index ae08dbb5da5..c786a9097ce 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -1,10 +1,8 @@ module gunbc.live_deploy.role_singleton_convergence import std.types { String, NonEmptyStr, List, Bool, Int } -import std.dissolution { DissolutionCondition, unbound_dissolution } -import std.roster_frontier { FrontierRow, frontier_row_path } import v2.std.collection { Present, Absent } -import gunbc.deployment_risk { DeploymentId, srv1_daily_workspace_deployment } +import gunbc.deployment_risk { DeploymentId } import std.decl_ref { decl_ref } import gunbc.roadmap_dashboard_instance { ProdRoleHolder, @@ -17,21 +15,16 @@ import gunbc.roadmap_dashboard_instance { ProdRoleHolderAmbiguous, ProdRoleHolderUnactuatable, prod_role_realization_refusal_detail, - srv1_live_dashboard_instance, } import gunbc.host_effect { HostEffectTransport, SshShell } import gunbc.live_deploy.spec { DeploymentSpec, - DeploymentDependencyStep, - Dependency, - FabricStorageLegacyAdoptionRecord, - EnsuredAdoptionRecord, HoldsProdRoleSingletons, HoldsNoRoleSingleton, deployment_fabric_storage_unit_path, gunbc_approval_broker_unit_path, } -import gunbc.fabric_storage_placement { fabric_storage_ownership_marker_path, fabric_storage_adoption_record_path } +import gunbc.fabric_storage_placement { fabric_storage_ownership_marker_path, fabric_storage_ownership_marker_content } import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization } import gunbc.live_deploy.member_observe { ObservationArm, @@ -52,10 +45,12 @@ import gunbc.live_deploy.member_observe { // of every roster host (DESIGN 3d) and, only when that assessment admits it, mints the one token an // apply may act on (RoleSingletonAdmission). // -// ONLY FIRST INSTALL AND ADOPTION ARE IMPLEMENTED. Noop when exactly one complete realization is -// observed and it is the desired one on its own target (or nothing is desired and nothing is observed); -// Install when a valid holder is desired and NOTHING is observed anywhere; AdoptUnmarkedHolder for the -// one pre-marker shape (below). Every other transition REFUSES with a typed cause. Move and Remove are +// ONLY FIRST INSTALL IS IMPLEMENTED. Noop when exactly one complete realization is observed and it is +// the desired one on its own target (or nothing is desired and nothing is observed); Install when a +// valid holder is desired and NOTHING is observed anywhere. Every other transition REFUSES with a typed +// cause -- including a realization that predates the store ownership marker (RoleSingletonUnmarked), +// which waits for the operator's one-time disposition named in its own refusal text (gunbc#13124: +// manual resume, no self-repair outside an operator disposition). Move and Remove are // refusal causes today and become arms of RoleSingletonConvergence when their actuation lands; neither // renames the arms that exist. // @@ -72,7 +67,6 @@ type RoleSingletonMember = BrokerUnitMember | FabricUnitMember | StoreMarkerMember - | AdoptionRecordMember // ONE SIGHTING, KEPT AT (host, deployment, member) GRAIN until multiplicity is adjudicated -- never // deduplicated by DeploymentId first, which is how the same deployment on two hosts, or a deployment @@ -105,58 +99,13 @@ type RoleSingletonRefusal | RoleSingletonTwoOnOneHost { host: NonEmptyStr, deployments: List } | RoleSingletonBrokerWithoutStore { host: NonEmptyStr } | RoleSingletonPartial { deployment: DeploymentId, host: NonEmptyStr, missing: List } + | RoleSingletonUnmarked { deployment: DeploymentId, host: NonEmptyStr, marker_path: NonEmptyStr, marker_content: NonEmptyStr } | RoleSingletonStaleSpec { instance_id: NonEmptyStr, detail: String } -// ── The one legacy realization adoption may name ──────────────────────────────────────────────── -// ADOPTION IS NOT A GENERIC REPAIR. It is bound to this explicit, temporary authority: the one -// realization installed before the store carried an ownership marker. sole_constructor, so no module -// but this one can state another legacy pair, and exactly one row exists. A future holder whose marker -// is deleted, or a hand-partial install, matches no row and refuses as RoleSingletonPartial -- it is -// never relabelled as ownership. -type LegacyUnmarkedHolderStanding sole_constructor { - deployment: DeploymentId - host: NonEmptyStr - marker_path: NonEmptyStr - evidence: NonEmptyStr -} - -data legacy_unmarked_holders: List = [ - LegacyUnmarkedHolderStanding { - deployment: srv1_daily_workspace_deployment, - host: "srv1" as NonEmptyStr, - marker_path: fabric_storage_ownership_marker_path(instance: srv1_live_dashboard_instance()), - evidence: "srv1-live's approval broker and fabric storage unit were installed on srv1 before gunbc.fabric_storage_placement fabric_storage_ownership_marker_path existed (#13217), so its store root carries no marker" as NonEmptyStr, - }, -] - -fn legacy_standing_for(legacy: List, deployment: DeploymentId, host: NonEmptyStr, marker_path: NonEmptyStr) -> LegacyUnmarkedHolderStanding? { - legacy |> filter(l => l.deployment == deployment && (l.host as String) == (host as String) && (l.marker_path as String) == (marker_path as String)) |> first -} - -// ── The adoption arm's retirement ─────────────────────────────────────────────────────────────── -// THE ARM CARRIES THIS ROW (AdoptUnmarkedHolder.retirement), so every adoption names the condition -// that deletes it. Its subject is the one extant legacy realization above, not the roster. -data adopt_unmarked_holder_dissolution: DissolutionCondition = unbound_dissolution( - description: "TRIGGER: an exact-host live_deploy apply receipt records role_singleton_disposition RoleSingletonsAdopted for the named legacy realization (gunbc.live_deploy.role_singleton_convergence legacy_unmarked_holders: srv1-daily-workspace on srv1), and an independent LATER observation (role_singleton_observation_of) sees that same realization complete, marked and RoleSingletonNoop. Then delete legacy_unmarked_holders, LegacyUnmarkedHolderStanding and the AdoptUnmarkedHolder arm. SUFFICIENT FOR: that receipt and that later observation of the same deployment and host; a marker written by hand, an observation of a different realization, or one not independent of the adopting apply is not this trigger." as NonEmptyStr -) - -data adopt_unmarked_holder_frontier: FrontierRow = frontier_row_path( - path: "gunbc.live_deploy.role_singleton_convergence AdoptUnmarkedHolder", - reason: "a one-time migration of the one realization that predates the store ownership marker (legacy_unmarked_holders); any other unmarked or partial state refuses as RoleSingletonPartial", - dissolution: adopt_unmarked_holder_dissolution, -) - // ── What is decided ───────────────────────────────────────────────────────────────────────────── -// AdoptUnmarkedHolder IS THE ADOPTION OF A PRE-MARKER REALIZATION. It exists for realizations installed -// before the store carried an ownership marker: the desired holder's fabric unit and broker are observed -// on its own target, with nothing on any other host, and only the marker is missing. Adoption writes -// the marker, inside an operator-triggered apply only (gunbc#13124: no self-repair outside an operator -// disposition), and is its own arm so the receipt counts it. DISSOLVES when every realization in the -// roster carries a marker; until then any other partial state refuses as RoleSingletonPartial. type RoleSingletonConvergence = RoleSingletonNoop | RoleSingletonInstall { holder: ProdRoleHolder } - | AdoptUnmarkedHolder { holder: ProdRoleHolder, host: NonEmptyStr, standing: LegacyUnmarkedHolderStanding, retirement: FrontierRow } | RoleSingletonRefused { cause: RoleSingletonRefusal } // ── The observation ───────────────────────────────────────────────────────────────────────────── @@ -172,10 +121,9 @@ type RoleSingletonObservation fn member_eq(a: RoleSingletonMember, b: RoleSingletonMember) -> Bool { match a { - BrokerUnitMember => match b { BrokerUnitMember => true FabricUnitMember => false StoreMarkerMember => false AdoptionRecordMember => false } - FabricUnitMember => match b { BrokerUnitMember => false FabricUnitMember => true StoreMarkerMember => false AdoptionRecordMember => false } - StoreMarkerMember => match b { BrokerUnitMember => false FabricUnitMember => false StoreMarkerMember => true AdoptionRecordMember => false } - AdoptionRecordMember => match b { BrokerUnitMember => false FabricUnitMember => false StoreMarkerMember => false AdoptionRecordMember => true } + BrokerUnitMember => match b { BrokerUnitMember => true FabricUnitMember => false StoreMarkerMember => false } + FabricUnitMember => match b { BrokerUnitMember => false FabricUnitMember => true StoreMarkerMember => false } + StoreMarkerMember => match b { BrokerUnitMember => false FabricUnitMember => false StoreMarkerMember => true } } } @@ -298,7 +246,7 @@ fn desired_is_no_prod(cause: ProdRoleRealizationRefusal) -> Bool { // ONLY ProdRoleUnheld MEANS "NO PROD". An unrealized, ambiguous or unactuatable holder is a refusal of // the desired side, never read as the no-prod path -- which is how a typo in the selection would // otherwise turn into a removal of the real prod singletons. -fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingletonObservation, rows: List, legacy: List) -> RoleSingletonConvergence { +fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingletonObservation, rows: List) -> RoleSingletonConvergence { match observed { RoleSingletonObservationRefused { cause } => RoleSingletonRefused { cause: cause } RoleSingletonsObserved { realizations } => match desired { @@ -319,11 +267,13 @@ fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingle } else { let missing = missing_members(r: r) if count(missing) == 0 { RoleSingletonNoop } - else if count(missing) == 1 && all(missing, m => member_eq(a: m, b: StoreMarkerMember)) && !any(r.members, x => member_eq(a: x, b: AdoptionRecordMember)) { - match legacy_standing_for(legacy: legacy, deployment: r.deployment, host: r.host, marker_path: fabric_storage_ownership_marker_path(instance: holder.instance)) { - Present { value: standing } => AdoptUnmarkedHolder { holder: holder, host: r.host, standing: standing, retirement: adopt_unmarked_holder_frontier } - Absent => RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } - } + else if count(missing) == 1 && all(missing, m => member_eq(a: m, b: StoreMarkerMember)) { + RoleSingletonRefused { cause: RoleSingletonUnmarked { + deployment: r.deployment, + host: r.host, + marker_path: fabric_storage_ownership_marker_path(instance: holder.instance), + marker_content: fabric_storage_ownership_marker_content(instance: holder.instance), + } } } else { RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } } @@ -355,62 +305,26 @@ type RoleSingletonAdmissionStanding = RoleSingletonAdmissible { decision: RoleSingletonConvergence } | RoleSingletonInadmissible { cause: RoleSingletonRefusal } -fn admitted_spec(admission: RoleSingletonAdmission) -> DeploymentSpec { +// THE TOKEN'S READERS ARE SEALED TOO, to the apply steps that consume it inside one assess-and-act +// composition, so a token cannot be carried out of the apply it was minted for and replayed later. +fn admitted_spec(admission: RoleSingletonAdmission) -> DeploymentSpec admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_admitted_via_transport"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_mutation_via_transport"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "role_singleton_disposition_of"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_transaction_decided"), +] { admission.spec } -fn admitted_decision(admission: RoleSingletonAdmission) -> RoleSingletonConvergence { +fn admitted_decision(admission: RoleSingletonAdmission) -> RoleSingletonConvergence admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_admitted_via_transport"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_mutation_via_transport"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "role_singleton_disposition_of"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_transaction_decided"), +] { admission.decision } -// THE LEGACY ADOPTION RECORD, its own type: the marker says WHO OWNS the store; this says THAT the one -// legacy adoption happened, naming the legacy subject and the revision that adopted it. Written only -// by the adopting apply (admitted_spec_for_candidate appends it for AdoptUnmarkedHolder and for nothing -// else), and read back as AdoptionRecordMember: with the record present, a missing marker is partial -// state and refuses -- it is never adopted a second time. -type LegacyAdoptionRecord { - deployment: DeploymentId - host: NonEmptyStr - marker_path: NonEmptyStr - adopting_revision: NonEmptyStr -} - -fn legacy_adoption_record_text(r: LegacyAdoptionRecord) -> NonEmptyStr { - join(["deployment=", r.deployment.name as String, "\nhost=", r.host as String, "\nmarker=", r.marker_path as String, "\nadopting_revision=", r.adopting_revision as String, "\n"], "") as NonEmptyStr -} - -// THE SPEC THE APPLY RUNS, from the token and the candidate it installs. For every decision but -// AdoptUnmarkedHolder it is the admitted spec unchanged; for an adoption it is that spec with the -// legacy adoption record appended, written after the marker so an interrupted adoption leaves a marked -// realization (Noop next time) rather than a recorded one without its marker. -fn admitted_spec_for_candidate(admission: RoleSingletonAdmission, revision: NonEmptyStr) -> DeploymentSpec { - spec_for_decision(spec: admission.spec, decision: admission.decision, revision: revision) -} - -// The pure half, so a witness can read the adoption plan without minting a token. -fn spec_for_decision(spec: DeploymentSpec, decision: RoleSingletonConvergence, revision: NonEmptyStr) -> DeploymentSpec { - match decision { - AdoptUnmarkedHolder { holder, host, standing, retirement: _ } => { - let record = LegacyAdoptionRecord { deployment: standing.deployment, host: host, marker_path: standing.marker_path, adopting_revision: revision } - DeploymentSpec { - target: spec.target, - steps: concat(spec.steps, [Dependency { step: DeploymentDependencyStep { - kind: FabricStorageLegacyAdoptionRecord, - subject: EnsuredAdoptionRecord { path: fabric_storage_adoption_record_path(instance: holder.instance), content: legacy_adoption_record_text(r: record) }, - } }]), - service: spec.service, - approval_broker: spec.approval_broker, - names: spec.names, - instance_id: spec.instance_id, - role_singletons: spec.role_singletons, - } - } - RoleSingletonNoop => spec - RoleSingletonInstall { holder: _ } => spec - RoleSingletonRefused { cause: _ } => spec - } -} - fn stale(spec: DeploymentSpec, detail: String) -> RoleSingletonAdmissionStanding { RoleSingletonInadmissible { cause: RoleSingletonStaleSpec { instance_id: spec.instance_id, detail: detail } } } @@ -434,10 +348,6 @@ fn classify_role_singleton_admission(spec: DeploymentSpec, desired: ProdRoleReal match decision { RoleSingletonRefused { cause } => RoleSingletonInadmissible { cause: cause } RoleSingletonNoop => if agrees { admissible } else { stale_holding } - AdoptUnmarkedHolder { holder: _, host, standing: _, retirement: _ } => - if !agrees { stale_holding } - else if desired_is_this_spec && (spec.target.ssh_host as String) == (host as String) { admissible } - else { stale(spec: spec, detail: "the holder's marker adoption is pending; only the observed holder spec on the observed host may act") } RoleSingletonInstall { holder: _ } => if !agrees { stale_holding } else if desired_is_this_spec { admissible } @@ -475,6 +385,8 @@ fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { join(["role singletons refused: the approval broker is present on ", host as String, " but no store is realized there"], "") RoleSingletonPartial { deployment, host, missing } => join(["role singletons refused: ", deployment.name as String, " on ", host as String, " is partial (missing ", to_string(count(missing)), " member(s)); an interrupted install or a store without its broker is not adopted"], "") + RoleSingletonUnmarked { deployment, host, marker_path, marker_content } => + join(["role singletons refused: ", deployment.name as String, " on ", host as String, " has its broker and fabric store unit but no store ownership marker, so whether that store is this holder's cannot be established. The operator's one-time disposition: on ", host as String, " write ", marker_path as String, " (mode 0644, owner briansrls) with exactly this content: ", marker_content as String], "") RoleSingletonStaleSpec { instance_id, detail } => join(["role singletons refused: spec ", instance_id as String, " is not admitted: ", detail], "") } @@ -514,7 +426,6 @@ fn host_role_singleton_reading(host: NonEmptyStr, rows: List, rows |> flat_map(r => [ MemberProbe { deployment: Present { value: r.instance.deployment }, member: FabricUnitMember, presence: entry_presence(arm: arm, path: deployment_fabric_storage_unit_path(names: r.names) as String) }, MemberProbe { deployment: Present { value: r.instance.deployment }, member: StoreMarkerMember, presence: entry_presence(arm: arm, path: fabric_storage_ownership_marker_path(instance: r.instance) as String) }, - MemberProbe { deployment: Present { value: r.instance.deployment }, member: AdoptionRecordMember, presence: entry_presence(arm: arm, path: fabric_storage_adoption_record_path(instance: r.instance) as String) }, ]), ) match probes |> flat_map(p => match presence_cause(p: p.presence) { Present { value: c } => [c] Absent => [] }) |> first { @@ -551,15 +462,17 @@ fn observe_role_singletons_with(source: RoleSingletonReadingSource, rows: List RoleSingletonAdmissionResult { +fn assess_and_admit(spec: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonAdmissionResult admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_via_transport"), + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_transaction_decided"), +] { let rows = desired_deployment_roster() let desired = prod_role_actuated_realization() let decision = role_singleton_convergence( desired: desired, observed: observe_role_singletons_with(source: ssh_probe_reading_source(applying: spec, own: own), rows: rows), rows: rows, - legacy: legacy_unmarked_holders, - ) + ) match classify_role_singleton_admission(spec: spec, desired: desired, decision: decision) { RoleSingletonInadmissible { cause } => RoleSingletonNotAdmitted { cause: cause } RoleSingletonAdmissible { decision: d } => RoleSingletonAdmitted { admission: mint_role_singleton_admission(spec: spec, decision: d) } diff --git a/dag/gunbc/live_deploy/spec.dag b/dag/gunbc/live_deploy/spec.dag index 54884a9f5b4..b804dbac6e4 100644 --- a/dag/gunbc/live_deploy/spec.dag +++ b/dag/gunbc/live_deploy/spec.dag @@ -41,7 +41,7 @@ import gunbc.host_layout { srv1_dispatch_attempt_state_root, srv1_devboot_artifact_store_root, } -import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_ownership_marker_path, fabric_storage_https_port, fabric_storage_door_socket } +import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_ownership_marker_path, fabric_storage_ownership_marker_content, fabric_storage_https_port, fabric_storage_door_socket } import gunbc.managed_directory { ManagedDirectoryAdmission, managed_directory_admission_path } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, @@ -121,7 +121,6 @@ type EnsuredDependencyKind = | CodexRuntimeReleaseDirectory | FabricStorageStoreDirectory | FabricStorageOwnershipMarker - | FabricStorageLegacyAdoptionRecord | ProviderStateRootDirectory // ONE CONCEPT -- AN ENSURED HOST RESOURCE -- WITH N KINDS, not a second ensured mechanism beside @@ -147,16 +146,14 @@ type EnsuredSubject = EnsuredPackage { package: NonEmptyStr } | EnsuredHostDirectory { path: NonEmptyStr, owner: NonEmptyStr } | EnsuredManagedHostDirectory { admission: ManagedDirectoryAdmission } - | EnsuredOwnershipMarker { path: NonEmptyStr, owner: NonEmptyStr } - | EnsuredAdoptionRecord { path: NonEmptyStr, content: NonEmptyStr } + | EnsuredOwnershipMarker { path: NonEmptyStr, owner: NonEmptyStr, content: NonEmptyStr } fn ensured_subject_identity(subject: EnsuredSubject) -> NonEmptyStr { match subject { EnsuredPackage { package } => package EnsuredHostDirectory { path, owner } => path EnsuredManagedHostDirectory { admission } => managed_directory_admission_path(a: admission) as String as NonEmptyStr - EnsuredOwnershipMarker { path, owner: _ } => path - EnsuredAdoptionRecord { path, content: _ } => path + EnsuredOwnershipMarker { path, owner: _, content: _ } => path } } @@ -1137,7 +1134,7 @@ fn deployment_role_singleton_directories(holding: RoleSingletonHolding) -> List< }), [ DeploymentDependencyStep { kind: FabricStorageOwnershipMarker, - subject: EnsuredOwnershipMarker { path: fabric_storage_ownership_marker_path(instance: holder.instance), owner: gunbc_service_user }, + subject: EnsuredOwnershipMarker { path: fabric_storage_ownership_marker_path(instance: holder.instance), owner: gunbc_service_user, content: fabric_storage_ownership_marker_content(instance: holder.instance) }, }, ]) HoldsNoRoleSingleton => [] diff --git a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag index d556c5944c2..6810a47fb78 100644 --- a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag +++ b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag @@ -24,6 +24,8 @@ data a_test_deployment_acts_on_the_prod_deployments_state: RecurringFailureMode "PINS REPOINTED BY DELIVERABLE 2: the role-following singletons follow gunbc.roadmap_dashboard_instance prod_role_realization -- one lookup, the instance roster filtered by its own deployment field -- which REFUSES with a typed ProdRoleRealizationRefusal under NoProdRole, an unrealized holder or an ambiguous one, and mints the only holder token (ProdRoleHolder, sole_constructor). A spec carries that token as gunbc.live_deploy.spec RoleSingletonHolding and every singleton member -- the approval broker unit and front door, the fabric store unit, route, socket, root and directories -- is built from it, never from a caller-spelled instance_id, so they move between targets as one subject. The apply first assesses the desired realization against an observation of every roster host (gunbc.live_deploy.role_singleton_convergence): Noop, Install only when nothing is observed anywhere, and a typed refusal for a move, a removal, an unobservable host, two holders or an ownerless broker. The approval client origin, the GCP IAM converge job's runner (gunbc.fleet_converge_workflow fleet_converge_gcp_iam_converge_job_under, refused under NoProdRole), the approvals app's host (gunbc.auth.approval_broker_placement) and the apply's protected siblings (dashboard_protected_siblings) follow the same realization; broker-side modules receive their host at serve time rather than resolving the role. The REDs are in test.claim.deployment_risk_witness_test at the spec, convergence and emitted-job boundaries.", + "OPERATOR DISPOSITION OWED, ONE-TIME: today's prod-role holder (srv1-daily-workspace, realized on srv1) predates the fabric store ownership marker, so the role-singleton assessment refuses it as RoleSingletonUnmarked until the operator writes, on srv1, /opt/gunbc/fabric-storage/.gunbc-dashboard-instance-srv1-live (mode 0644, owner briansrls) containing exactly 'deployment=srv1-daily-workspace' newline 'host=srv1' newline -- the content gunbc.fabric_storage_placement fabric_storage_ownership_marker_content derives and the refusal text quotes. No automated adoption exists (gunbc#13124: manual resume).", + "HONEST NON-PINS, not remaining debt: (a) the nullary serve, belt, dispatch and launch entry points that call srv1_live_dashboard_instance are srv1-live's OWN entry points (HostDashboardInstance.serve_function names roadmap_serve_handle for srv1-live and roadmap_serve_handle_ for every other instance, a binding test.claim.serve_function_binding_witness_test now checks roster-wide), so they must NOT follow the role -- following it would make srv1-live act on the new holder's state; (b) gunbc.fleet_reach_endpoint fleet_probe_remote_user_for's srv1/srv2 test is which hosts carry the gunbc-fleet account, a host-shared fact. NOT CLAIMED: that an unbound test deployment refuses everywhere -- roadmap_belt_actuate belt_dispatch_instance_cli still defaults an absent instance env to srv1-live, which stays with the belt owner's lane.", "NEXT-RUNG TRIGGER: (1) the role-singleton convergence gains its Move and Remove actuations, so a role change converges the old singleton away instead of refusing; (2) every attachment the srv1 lab shares with prod today (ntfy, Codex account and home, admitted ref, publication identity, R2/GCP/workload identity, OIDC) resolves through DeploymentExternalBindingTable or refuses (deliverable 3). One binding is not sufficient: the class is the route, and any remaining named route is the next receipt.", diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index adb2f2ac29e..6b6dbf2ed0e 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -57,9 +57,7 @@ import gunbc.live_deploy.spec { FabricStorageServeUnit, FabricStorageServeMapping, FabricStorageStoreDirectory, - FabricStorageOwnershipMarker, EnsuredOwnershipMarker, - EnsuredAdoptionRecord, deployment_srv1_target, srv1_live_deployment_names, deployment_spec_for, @@ -67,16 +65,11 @@ import gunbc.live_deploy.spec { deployment_fabric_storage_door_socket, } import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } -import gunbc.live_deploy.apply { role_singleton_disposition_of_decision } import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } -import gunbc.compile_diagnostic_census { CensusObserved, CensusNotRunnable, census_blocking_rows, census_total_count } -import gunbc.live_deploy.apply_receipt { RoleSingletonsAdopted } import gunbc.live_deploy.role_singleton_convergence { - adopt_unmarked_holder_frontier, RoleSingletonConvergence, RoleSingletonNoop, RoleSingletonInstall, - AdoptUnmarkedHolder, RoleSingletonRefused, RoleSingletonMoveUnimplemented, RoleSingletonRemovalUnimplemented, @@ -88,14 +81,14 @@ import gunbc.live_deploy.role_singleton_convergence { RoleSingletonTwoOnOneHost, RoleSingletonBrokerWithoutStore, RoleSingletonPartial, + RoleSingletonUnmarked, + role_singleton_refusal_detail, RoleSingletonStaleSpec, RoleSingletonSighting, SightedDeploymentMember, SightedBroker, FabricUnitMember, StoreMarkerMember, - AdoptionRecordMember, - spec_for_decision, HostRoleSingletonReading, HostRoleSingletonsRead, HostRoleSingletonsUnobservable, @@ -103,8 +96,6 @@ import gunbc.live_deploy.role_singleton_convergence { RoleSingletonAdmissible, RoleSingletonInadmissible, classify_role_singleton_admission, - legacy_unmarked_holders, - LegacyUnmarkedHolderStanding, role_singleton_convergence, role_singleton_observation_of, } @@ -119,7 +110,7 @@ import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_door_socket, fabric_storage_ownership_marker_path, - fabric_storage_adoption_record_path, + fabric_storage_ownership_marker_content, } import gunbc.auth.approval_request_client { ApprovalLoopbackOriginResolved, @@ -342,7 +333,7 @@ fn srv1_holds() -> List { fn decide(desired: ProdRoleRealization, readings: List) -> RoleSingletonConvergence { let rows = desired_deployment_roster() - role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings, rows: rows), rows: rows, legacy: legacy_unmarked_holders) + role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings, rows: rows), rows: rows) } fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { @@ -358,11 +349,11 @@ fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { RoleSingletonTwoOnOneHost { host: _, deployments: _ } => label == "two-on-one" RoleSingletonBrokerWithoutStore { host: _ } => label == "broker-alone" RoleSingletonPartial { deployment: _, host: _, missing: _ } => label == "partial" + RoleSingletonUnmarked { deployment: _, host: _, marker_path: _, marker_content: _ } => label == "unmarked" RoleSingletonStaleSpec { instance_id: _, detail: _ } => label == "stale" } RoleSingletonNoop => false RoleSingletonInstall { holder: _ } => false - AdoptUnmarkedHolder { holder: _, host: _, standing: _, retirement: _ } => false } } @@ -455,155 +446,21 @@ test fn an_old_store_with_no_unit_or_a_partial_realization_refuses() -> Bool { && refused_with(c: decide(desired: fx_moved(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: marker_only_on(host: "srv2", d: srv2_deploy_deployment))]), label: "partial") } -// ADOPTION, EXACTLY: the desired holder's unit and broker on its own target, nothing elsewhere, only -// the marker missing -> AdoptUnmarkedHolder on that host, and the holder's spec carries the marker as -// an ensured member, so the operator-triggered apply writes it. The same units on the WRONG host refuse -// as misplaced rather than adopt. -fn units_without_marker(host: String, d: DeploymentId) -> List { - [SightedDeploymentMember { host: host as NonEmptyStr, deployment: d, member: FabricUnitMember }, SightedBroker { host: host as NonEmptyStr }] -} -fn spec_carries_store_marker(spec: DeploymentSpec) -> Bool { - any(spec.steps, s => match s { - Dependency { step } => match step.kind { FabricStorageOwnershipMarker => true _ => false } - ArtifactStep { step: _ } => false - }) -} -fn adopt_world() -> List { - [read_on(host: "srv1", sightings: units_without_marker(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])] -} -fn spec_marker_ensure_is_exact(spec: DeploymentSpec) -> Bool { - any(spec.steps, s => match s { - Dependency { step } => match step.subject { - EnsuredOwnershipMarker { path, owner: _ } => (path as String) == (fabric_storage_ownership_marker_path(instance: srv1_live_dashboard_instance()) as String) - _ => false - } - ArtifactStep { step: _ } => false - }) -} -// ADOPTION, EXACTLY, AT THE ADMISSION AND RECEIPT BOUNDARIES: (a) the exact holder spec on the observed -// host is admitted; (b) every non-holder spec refuses as stale, so a lab or srv2 apply cannot ride the -// adoption and re-adopt forever; (c) the holder spec pointed at the wrong target refuses; (d) the -// admitted plan carries the exact ownership-marker ensure and the receipt disposition names Adopted; -// (e) once the marker is observed the same holder converges as Noop. The arm carries its retirement row. -test fn an_unmarked_holder_on_its_own_target_is_adopted_and_the_plan_writes_the_marker() -> Bool { - let real = prod_role_actuated_realization() - let decision = decide(desired: real, readings: adopt_world()) - let adopted = match decision { - AdoptUnmarkedHolder { holder, host, standing: _, retirement } => - holder.instance.deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" - && (retirement.reason as String) == (adopt_unmarked_holder_frontier.reason as String) - _ => false - } - let live_id = srv1_live_dashboard_instance().instance_id as String - let holder_admitted = all(spec_of(realization: real, id: live_id), s => match classify_role_singleton_admission(spec: s, desired: real, decision: decision) { - RoleSingletonAdmissible { decision: d } => - spec_marker_ensure_is_exact(spec: s) - && match role_singleton_disposition_of_decision(decision: d) { - RoleSingletonsAdopted { deployment, host } => deployment == (srv1_daily_workspace_deployment.name as String) && host == "srv1" - _ => false - } - RoleSingletonInadmissible { cause: _ } => false - }) - let others_refused = all(roster_specs_under(realization: real) |> filter(s => !((s.instance_id as String) == live_id)), s => admission_refused_as_stale(r: classify_role_singleton_admission(spec: s, desired: real, decision: decision))) - let wrong_target = deployment_spec_for(realization: real, instance: srv1_live_dashboard_instance(), target: deployment_srv2_target(), names: srv1_live_deployment_names(instance: srv1_live_dashboard_instance())) - let after = match decide(desired: real, readings: srv1_holds()) { RoleSingletonNoop => true _ => false } - adopted && count(spec_of(realization: real, id: live_id)) == 1 && holder_admitted && others_refused - && admission_refused_as_stale(r: classify_role_singleton_admission(spec: wrong_target, desired: real, decision: decision)) - && after -} - -// ADOPTION IS BOUND TO THE ONE LEGACY ROW, NOT TO THE SHAPE. The same unmarked shape for any other holder -// -- the role moved to srv2-deploy, or a future holder -- refuses as partial even when it matches -// exactly; and in the world after the row's retirement (no legacy rows), a marker deleted from the -// once-adopted srv1 realization refuses rather than being re-adopted. -fn decide_with_legacy(desired: ProdRoleRealization, readings: List, legacy: List) -> RoleSingletonConvergence { - let rows = desired_deployment_roster() - role_singleton_convergence(desired: desired, observed: role_singleton_observation_of(readings: readings, rows: rows), rows: rows, legacy: legacy) -} -test fn only_the_legacy_row_is_adoptable_and_a_retired_world_refuses_a_deleted_marker() -> Bool { - let moved_unmarked = [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: units_without_marker(host: "srv2", d: srv2_deploy_deployment))] - let srv1_unmarked = adopt_world() - let legacy_still_adopted = match decide_with_legacy(desired: prod_role_actuated_realization(), readings: srv1_unmarked, legacy: legacy_unmarked_holders) { - AdoptUnmarkedHolder { holder: _, host, standing: _, retirement: _ } => (host as String) == "srv1" - _ => false - } - count(legacy_unmarked_holders) == 1 - && legacy_still_adopted - && refused_with(c: decide_with_legacy(desired: fx_moved(), readings: moved_unmarked, legacy: legacy_unmarked_holders), label: "partial") - && refused_with(c: decide_with_legacy(desired: prod_role_actuated_realization(), readings: srv1_unmarked, legacy: []), label: "partial") -} -// THE TOKEN AND THE STANDING CANNOT BE FORGED. An outside module minting an admission from a -// fabricated RoleSingletonNoop is refused at compile time by the mint's admit_callers seal, and an -// outside module stating a LegacyUnmarkedHolderStanding is refused by its sole_constructor -- each a -// located diagnostic. The canonical assessor takes no desired realization, so a noncanonical -// ProdRoleSelection has no parameter to enter through. -data forged_admission_source: String = "module probe_forged_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { mint_role_singleton_admission, RoleSingletonNoop, RoleSingletonAdmission }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nfn forge() -> RoleSingletonAdmission { mint_role_singleton_admission(spec: deployment_spec_srv1(), decision: RoleSingletonNoop) }\n" -data forged_standing_source: String = "module probe_forged_legacy_standing\nimport gunbc.live_deploy.role_singleton_convergence { LegacyUnmarkedHolderStanding }\nimport gunbc.deployment_risk { srv2_deploy_deployment }\nfn forge() -> LegacyUnmarkedHolderStanding { LegacyUnmarkedHolderStanding { deployment: srv2_deploy_deployment, host: \"srv2\", marker_path: \"/x\", evidence: \"forged\" } }\n" -data noncanonical_desired_source: String = "module probe_noncanonical_desired\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1, prod_role_actuated_realization_in }\nimport gunbc.deployment_risk { ProdRoleHeldBy, srv2_deploy_deployment }\nimport gunbc.host_effect { LocalShell }\nfn forge() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), desired: prod_role_actuated_realization_in(selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }), own: LocalShell) }\n" -data canonical_assessor_source: String = "module probe_canonical_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1, prod_role_actuated_realization_in }\nimport gunbc.deployment_risk { ProdRoleHeldBy, srv2_deploy_deployment }\nimport gunbc.host_effect { LocalShell }\nfn forge() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), own: LocalShell) }\n" -fn blocking_rows(source: String) -> Int { - match compile_dag_diagnostic_census(source) { - CensusObserved { rows } => census_total_count(rows: census_blocking_rows(rows: rows)) - CensusNotRunnable { cause: _ } => 0 - } -} -fn blocking_of(source: String, class: String) -> Int { - match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: class) { - Present { value: n } => n - Absent => 0 - } -} -// THE LEGACY ADOPTION RECORD. Only the adoption plan writes it (Noop and Install plans do not), it -// names the legacy subject and the adopting revision, and once it is observed a marker deleted from -// the adopted store refuses as partial rather than being adopted again. -fn spec_writes_adoption_record(spec: DeploymentSpec) -> Bool { - any(spec.steps, s => match s { - Dependency { step } => match step.subject { - EnsuredAdoptionRecord { path, content } => - (path as String) == (fabric_storage_adoption_record_path(instance: srv1_live_dashboard_instance()) as String) - && string_contains(s: content as String, pattern: "deployment=srv1-daily-workspace") - && string_contains(s: content as String, pattern: "adopting_revision=feedface") - _ => false - } - ArtifactStep { step: _ } => false - }) -} -test fn only_the_adoption_plan_writes_the_record_and_a_recorded_adoption_never_readopts() -> Bool { - let real = prod_role_actuated_realization() - let live = spec_of(realization: real, id: srv1_live_dashboard_instance().instance_id as String) - let adopt = decide(desired: real, readings: adopt_world()) - let recorded_marker_deleted = [read_on(host: "srv1", sightings: concat(units_without_marker(host: "srv1", d: srv1_daily_workspace_deployment), [ - SightedDeploymentMember { host: "srv1" as NonEmptyStr, deployment: srv1_daily_workspace_deployment, member: AdoptionRecordMember }, - ])), read_on(host: "srv2", sightings: [])] - count(live) == 1 - && all(live, s => spec_writes_adoption_record(spec: spec_for_decision(spec: s, decision: adopt, revision: "feedface" as NonEmptyStr))) - && all(live, s => !spec_writes_adoption_record(spec: spec_for_decision(spec: s, decision: decide(desired: real, readings: srv1_holds()), revision: "feedface" as NonEmptyStr))) - && all(live, s => !spec_writes_adoption_record(spec: s)) - && refused_with(c: decide(desired: real, readings: recorded_marker_deleted), label: "partial") -} -test fn no_outside_module_can_mint_an_admission_or_state_a_legacy_standing() -> Bool { - blocking_of(source: forged_admission_source, class: "ConstructorCallAdmissionRefused") >= 1 - && blocking_of(source: forged_standing_source, class: "SoleConstructorViolation") >= 1 - && blocking_rows(source: noncanonical_desired_source) > blocking_rows(source: canonical_assessor_source) -} -test fn an_unmarked_holder_on_the_wrong_host_is_refused_not_adopted() -> Bool { - refused_with(c: decide(desired: prod_role_actuated_realization(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: units_without_marker(host: "srv2", d: srv1_daily_workspace_deployment))]), label: "misplaced") -} // ── The admission: the decision is bound to the spec that acts ───────────────────────────────── fn spec_of(realization: ProdRoleRealization, id: String) -> List { @@ -711,3 +568,71 @@ test fn the_broker_unit_is_given_the_holders_base_url_and_a_non_holder_gets_a_re && match approval_broker_serving_base_url_of(bound: Present { value: approval_broker_base_url_unbound_marker }) { ApprovalBrokerAddressRefused { detail: _ } => true _ => false } && match approval_broker_serving_base_url_of(bound: none) { ApprovalBrokerAddressRefused { detail: _ } => true _ => false } } + +// ── The unmarked legacy store waits for the operator ──────────────────────────────────────────── +// A realization that predates the store ownership marker -- today's srv1 holder, its broker and fabric +// unit present and the marker missing -- REFUSES with RoleSingletonUnmarked, and the refusal names +// exactly what the operator's one-time disposition writes: the marker path, its content (deployment, +// host) and the host. It is the same content the holder spec's own marker ensure writes, so the +// disposition and an install agree byte for byte. Nothing adopts it automatically (gunbc#13124). +fn units_without_marker_on(host: String, d: DeploymentId) -> List { + [SightedDeploymentMember { host: host as NonEmptyStr, deployment: d, member: FabricUnitMember }, SightedBroker { host: host as NonEmptyStr }] +} + +fn spec_marker_content(spec: DeploymentSpec) -> List { + spec.steps |> flat_map(s => match s { + Dependency { step } => match step.subject { + EnsuredOwnershipMarker { path: _, owner: _, content } => [content as String] + _ => [] + } + ArtifactStep { step: _ } => [] + }) +} + +test fn an_unmarked_legacy_store_refuses_naming_the_operators_disposition() -> Bool { + let real = prod_role_actuated_realization() + let live = srv1_live_dashboard_instance() + let want_path = fabric_storage_ownership_marker_path(instance: live) as String + let want_content = fabric_storage_ownership_marker_content(instance: live) as String + let decision = decide(desired: real, readings: [read_on(host: "srv1", sightings: units_without_marker_on(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])]) + let names_it = match decision { + RoleSingletonRefused { cause } => match cause { + RoleSingletonUnmarked { deployment, host, marker_path, marker_content } => + deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" + && (marker_path as String) == want_path && (marker_content as String) == want_content + && string_contains(s: role_singleton_refusal_detail(cause: cause), pattern: want_path) + && string_contains(s: role_singleton_refusal_detail(cause: cause), pattern: want_content) + _ => false + } + _ => false + } + names_it + && string_contains(s: want_content, pattern: "deployment=srv1-daily-workspace") + && string_contains(s: want_content, pattern: "host=srv1") + && all(spec_of(realization: real, id: live.instance_id as String), s => spec_marker_content(spec: s) == [want_content]) + && refused_with(c: decide(desired: fx_moved(), readings: [read_on(host: "srv1", sightings: []), read_on(host: "srv2", sightings: units_without_marker_on(host: "srv2", d: srv2_deploy_deployment))]), label: "unmarked") +} + +// ── A token cannot be forged or replayed ──────────────────────────────────────────────────────── +// The mint is sealed to the observing assessor, the assessor to the two canonical apply compositions, +// and the token's readers and every admitted apply step to those same compositions. So an outside +// module can neither mint an admission from a fabricated decision, obtain one from the assessor, nor +// carry one into an apply step later -- each refused at compile time as a located diagnostic. +data forged_admission_source: String = "module probe_forged_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { mint_role_singleton_admission, RoleSingletonNoop, RoleSingletonAdmission }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nfn forge() -> RoleSingletonAdmission { mint_role_singleton_admission(spec: deployment_spec_srv1(), decision: RoleSingletonNoop) }\n" + +data obtained_admission_source: String = "module probe_obtained_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn obtain() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), own: LocalShell) }\n" + +data replayed_admission_source: String = "module probe_replayed_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { RoleSingletonAdmission, admitted_spec }\nimport gunbc.live_deploy.spec { DeploymentSpec }\nfn replay(a: RoleSingletonAdmission) -> DeploymentSpec { admitted_spec(admission: a) }\n" + +fn admission_refusals_in(source: String) -> Int { + match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: "ConstructorCallAdmissionRefused") { + Present { value: n } => n + Absent => 0 + } +} + +test fn no_outside_module_can_mint_obtain_or_replay_an_admission() -> Bool { + admission_refusals_in(source: forged_admission_source) >= 1 + && admission_refusals_in(source: obtained_admission_source) >= 1 + && admission_refusals_in(source: replayed_admission_source) >= 1 +} diff --git a/dag/test/claim/devboot_subject_identity_witness_test.dag b/dag/test/claim/devboot_subject_identity_witness_test.dag index 4e3eb3ec6e9..b71228b14d7 100644 --- a/dag/test/claim/devboot_subject_identity_witness_test.dag +++ b/dag/test/claim/devboot_subject_identity_witness_test.dag @@ -16,7 +16,6 @@ import extdeps.git.object_store { GitObjectId, git_object_id_from_untagged_hex } import gunbc.host_layout { srv1_devboot_artifact_store_root } import gunbc.live_deploy.spec { EnsuredOwnershipMarker, - EnsuredAdoptionRecord, DeploymentStep, Dependency, ArtifactStep, @@ -731,8 +730,7 @@ fn devboot_ensured_step_projects_layout(acc: Int, step: DeploymentDependencyStep if (path as String) == (srv1_devboot_artifact_store_root as String) { acc + 1 } else { acc } EnsuredPackage { package } => acc EnsuredManagedHostDirectory { admission: _ } => acc - EnsuredOwnershipMarker { path: _, owner: _ } => acc - EnsuredAdoptionRecord { path: _, content: _ } => acc + EnsuredOwnershipMarker { path: _, owner: _, content: _ } => acc } } diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index b12f5525f52..eefaf908bdf 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -29,13 +29,16 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - **Decision.** Possible outcomes: - `RoleSingletonNoop`: exactly one complete realization of the desired holder on its target. - `RoleSingletonInstall`: nothing observed anywhere. - - `AdoptUnmarkedHolder`: see below. - - `RoleSingletonRefused`, with one of these causes: move, removal, `RoleSingletonDesiredRealizationRefused`, unobservable, two-hosts, misplaced, two-on-one, ambiguous, broker-without-store, partial, stale-spec. + - `RoleSingletonRefused`, with one of these causes: move, removal, `RoleSingletonDesiredRealizationRefused`, unmarked, unobservable, two-hosts, misplaced, two-on-one, ambiguous, broker-without-store, partial, stale-spec. Only `ProdRoleUnheld` means no prod. - - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec, and apply consumes only `admitted_spec`. Its one mint, `mint_role_singleton_admission`, is sealed with `admit_callers` to the canonical assessor `assess_and_admit(spec, own)`. That assessor reads the actuated desired realization and the ssh observation itself, so no caller can supply a decision or a desired realization. Fixtures use the pure `classify_role_singleton_admission`, which returns a standing, never a token. A stale spec, a holding spec under NoProdRole, or a non-holder spec during Install refuses before member planning. The apply receipt records `role_singleton_disposition` (Noop, Installed, Adopted or RefusedBeforeApply). - - **Adoption scope.** `AdoptUnmarkedHolder { holder, host, standing, retirement }` is bound to `legacy_unmarked_holders`. That is exactly one `LegacyUnmarkedHolderStanding` (`sole_constructor`) row: srv1-daily-workspace on srv1, naming its marker path and evidence. Any other unmarked holder refuses as `RoleSingletonPartial`, even when it matches exactly. Only the holder spec on the observed host is admitted, and the marker is written inside an operator-triggered apply. - - **Retirement.** The retirement subject is that one legacy realization. Trigger: an exact-host receipt records Adopted for it, and an independent later observation sees the same realization complete, marked and Noop. Then delete `legacy_unmarked_holders`, the standing type and the arm. The adopting apply alone writes a `LegacyAdoptionRecord` (`.gunbc-legacy-adoption-` beside the marker: deployment, host, marker path, adopting revision). The observer reads it as a member, and adoption is admitted only while it is ABSENT, so a marker deleted after adoption refuses as partial rather than re-adopting. The marker says who owns the store; the record says the legacy adoption happened. Residual case: deleting BOTH files reads as the never-adopted shape. That window ends when the standing row is deleted by its trigger, which is scheduled for right after the first prod apply following this change: that apply adopts, and its next independent observation retires the row. + - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec. Its mint is sealed (`admit_callers`) to the canonical assessor `assess_and_admit(spec, own)`, which reads the actuated desired realization and the ssh observation itself. The assessor, the token's readers (`admitted_spec`, `admitted_decision`) and every admitted apply step are in turn sealed to the two canonical assess-and-act compositions (`live_deploy_apply_via_transport`, `live_deploy_transaction_decided`). So a token can be neither forged, obtained elsewhere nor replayed later. Fixtures use the pure `classify_role_singleton_admission`. The apply receipt records `role_singleton_disposition` (Noop, Installed or RefusedBeforeApply). + - **Unmarked legacy store (no automated adoption).** A realization with its broker and fabric unit but no store ownership marker refuses as `RoleSingletonUnmarked`. Today's srv1 holder predates the marker, so it is the expected case. Under the manual-resume ruling (#13124) it waits for the operator, and the refusal text names exactly what to write. **Operator's one-time disposition:** + - on `srv1`, write `/opt/gunbc/fabric-storage/.gunbc-dashboard-instance-srv1-live`; + - mode 0644, owner briansrls; + - content exactly `deployment=srv1-daily-workspace` + newline + `host=srv1` + newline. + + This is the same path and content the holder spec's own marker ensure writes. After it, the next apply observes a complete realization and converges as Noop. - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. From ee1d7e66ee4c60c9e4f9a7d9ee2332c88487f29a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 21:31:51 +0000 Subject: [PATCH 34/48] D2 effect boundary: LiveDeployApply carries a sole_constructor AdmittedLiveDeployApply warrant minted only by the sealed member-plan step; the store marker has one typed row (path, content, mode, owner, group), installed owned and read back field by field, a mismatch refusing distinctly from absence Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/shell.dag | 16 +++ dag/gunbc/fabric/fabric_storage_placement.dag | 71 ++++++++++- dag/gunbc/host/host_effect.dag | 3 +- dag/gunbc/host/host_effect_realize.dag | 18 +-- dag/gunbc/live_deploy/apply.dag | 9 +- dag/gunbc/live_deploy/apply_warrant.dag | 25 ++++ dag/gunbc/live_deploy/emit.dag | 7 +- .../role_singleton_convergence.dag | 119 +++++++++++++++--- dag/gunbc/live_deploy/spec.dag | 8 +- .../claim/deployment_risk_witness_test.dag | 76 ++++++++++- .../devboot_subject_identity_witness_test.dag | 2 +- 11 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 dag/gunbc/live_deploy/apply_warrant.dag diff --git a/dag/extdeps/shell.dag b/dag/extdeps/shell.dag index 5ca1df8d0ee..34eb0759d41 100644 --- a/dag/extdeps/shell.dag +++ b/dag/extdeps/shell.dag @@ -528,6 +528,22 @@ service shell.Stat { nonzero => String "stat could not read the path" } } + + operation GroupOf { + requires none + input { path: FilePath } + output { + success: Bool from "exit_success" + stdout: String from "stdout" + stderr: String from "stderr" + } + readonly + transport shell { argv: ["stat", "-c", "%G", "--", "{path}"] } + exit { + 0 => Unit "the path exists and its owning group is on stdout" + nonzero => String "stat could not read the path" + } + } } service shell.Move { diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index 63f5ad2d50f..056c5ec1670 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -18,7 +18,8 @@ import gunbc.managed_directory { } import gunbc.fleet_posix_accounts { fleet_posix_ci_runner_user } import gunbc.fabric_storage_file_store { FabricStorageFileRoot, fabric_storage_file_root, fabric_storage_file_root_with } -import extdeps.access.posix { FileMode, file_mode_bits } +import extdeps.access.posix { FileMode, PermissionBits, file_mode_bits, file_mode_octal, file_mode_of_octal_text } +import std.algebra { trim } // WHERE THE FABRIC DB IS: one host holds the store's files and answers the endpoint. This is the // POLICY fact of the three (DESIGN 3) -- std.fabric_storage is the interface, the file store and the @@ -87,13 +88,75 @@ fn fabric_storage_ownership_marker_path(instance: HostDashboardInstance) -> NonE dashboard_ownership_marker_in(root: dashboard_instance_fabric_storage_root(instance: instance), instance_id: instance.instance_id) as String as NonEmptyStr } -// THE MARKER'S CONTENT names whose store it is: the deployment and the host. The install writes it, -// and the refusal for an unmarked realization quotes it verbatim, so the operator's one-time disposition -// writes exactly what an install would have. +// THE MARKER'S CONTENT names whose store it is: the deployment and the host. fn fabric_storage_ownership_marker_content(instance: HostDashboardInstance) -> NonEmptyStr { join(["deployment=", instance.deployment.name as String, "\nhost=", instance.host_identity as String, "\n"], "") as NonEmptyStr } +// THE MARKER'S ONE AUTHORITY: path, exact content, mode, owner and group, in one row derived from the +// instance. The holder spec installs it from this row (owner, group and mode applied, not defaulted), +// the observer reads every field back and admits a marker only on an exact match, and the refusal for +// an unmarked realization quotes this row -- so the install, the readback and the operator's one-time +// disposition cannot disagree. The owner and group are the store's service principal, the one writer +// of the store (fabric_storage_directory). +type FabricStorageOwnershipMarkerSpec { + path: NonEmptyStr + content: NonEmptyStr + mode: FileMode + owner: NonEmptyStr + group: NonEmptyStr +} + +data fabric_storage_ownership_marker_mode: FileMode = FileMode { + owner: PermissionBits { read: true, write: true, execute: false }, + group: PermissionBits { read: true, write: false, execute: false }, + other: PermissionBits { read: true, write: false, execute: false }, + setuid: false, + setgid: false, + sticky: false, +} + +fn fabric_storage_ownership_marker_spec(instance: HostDashboardInstance) -> FabricStorageOwnershipMarkerSpec { + FabricStorageOwnershipMarkerSpec { + path: fabric_storage_ownership_marker_path(instance: instance), + content: fabric_storage_ownership_marker_content(instance: instance), + mode: fabric_storage_ownership_marker_mode, + owner: srv1_dashboard_service_principal().name, + group: srv1_dashboard_service_principal().name, + } +} + +// WHAT A READBACK OF THE MARKER SAW, field for field, and whether it is exactly the row. Absent is not +// a reading (the observer reports it as no marker); a reading that differs in ANY field is a mismatch, +// named by its first differing field, and is never admitted as the marker. +type FabricStorageMarkerReading { + path: NonEmptyStr + content: String + mode: String + owner: String + group: String +} + +type FabricStorageMarkerVerdict + = FabricStorageMarkerExact + | FabricStorageMarkerMismatched { field: NonEmptyStr, expected: String, observed: String } + +fn fabric_storage_marker_mode_matches(spec: FileMode, observed: String) -> Bool { + match file_mode_of_octal_text(text: observed) { + Present { value: m } => file_mode_octal(mode: m) == file_mode_octal(mode: spec) + Absent => false + } +} + +fn fabric_storage_marker_verdict(spec: FabricStorageOwnershipMarkerSpec, reading: FabricStorageMarkerReading) -> FabricStorageMarkerVerdict { + if !((reading.path as String) == (spec.path as String)) { FabricStorageMarkerMismatched { field: "path", expected: spec.path as String, observed: reading.path as String } } + else if !(reading.content == (spec.content as String)) { FabricStorageMarkerMismatched { field: "content", expected: spec.content as String, observed: reading.content } } + else if !fabric_storage_marker_mode_matches(spec: spec.mode, observed: reading.mode) { FabricStorageMarkerMismatched { field: "mode", expected: file_mode_octal(mode: spec.mode), observed: reading.mode } } + else if !(trim(s: reading.owner) == (spec.owner as String)) { FabricStorageMarkerMismatched { field: "owner", expected: spec.owner as String, observed: reading.owner } } + else if !(trim(s: reading.group) == (spec.group as String)) { FabricStorageMarkerMismatched { field: "group", expected: spec.group as String, observed: reading.group } } + else { FabricStorageMarkerExact } +} + fn fabric_storage_endpoint_for(host: HostIdentity) -> NonEmptyStr? { match fleet_intent_network.tailnet_domain { Absent => none diff --git a/dag/gunbc/host/host_effect.dag b/dag/gunbc/host/host_effect.dag index 209103617c5..9b2262153d4 100644 --- a/dag/gunbc/host/host_effect.dag +++ b/dag/gunbc/host/host_effect.dag @@ -25,6 +25,7 @@ import gunbc.runner_lifecycle { RunnerReplacementCause } import gunbc.auth.github_apps { gunbai_ci_declared } import gunbc.live_deploy.candidate_identity { CandidateRelease } import gunbc.live_deploy.member_identity { ReleaseEffectPlan } +import gunbc.live_deploy.apply_warrant { AdmittedLiveDeployApply } import std.effects { EffectShape, KeySource, @@ -75,7 +76,7 @@ type HostEffect = | Srv3BootOnceReadBackSleep | Srv3ReceiptEmit { lines: List } | HostIdentityShortHostnameRead - | LiveDeployApply { spec: DeploymentSpec, candidate: CandidateRelease, plan: ReleaseEffectPlan } + | LiveDeployApply { warrant: AdmittedLiveDeployApply } | LiveDeployRetract { spec: DeploymentSpec } | LiveDeployApprovalBrokerDarkInstall { spec: DeploymentSpec, candidate: CandidateRelease } | LiveDeployMicrovmControllerInstall { candidate: CandidateRelease } diff --git a/dag/gunbc/host/host_effect_realize.dag b/dag/gunbc/host/host_effect_realize.dag index aacd1b90adc..aaea89eaebd 100644 --- a/dag/gunbc/host/host_effect_realize.dag +++ b/dag/gunbc/host/host_effect_realize.dag @@ -533,8 +533,8 @@ fn resolve_host_os_effect_cell_after_direct_arms(n: ComputeHost, effect: HostEff CodexSupervisedWorkerTurnOnHost { node: n, intent: i, lease_key: k } HostIdentityShortHostnameRead => HostnameReadOnHost { node: n } - LiveDeployApply { spec: s, candidate: c, plan: p } => - LiveDeployApplyOnHost { node: n, spec: s, candidate: c, plan: p } + LiveDeployApply { warrant: w } => + LiveDeployApplyOnHost { node: n, spec: w.spec, candidate: w.candidate, plan: w.plan } LiveDeployRetract { spec: s } => LiveDeployRetractOnHost { node: n, spec: s } LiveDeployApprovalBrokerDarkInstall { spec: s, candidate: c } => @@ -613,7 +613,7 @@ fn resolve_bmc_controller_effect_cell_after_direct_arms(n: ComputeHost, effect: IncompatibleCell { reason: "host_effect: SudoNopasswdGrantListProbe is in-band (typed sudo grant-list probe) and cannot target BmcController (out-of-band Redfish only); target HostOs instead. Typed mismatch via total fold, DESIGN section 5." } HostIdentityShortHostnameRead => IncompatibleCell { reason: "host_effect: HostIdentityShortHostnameRead is in-band (shell hostname -s) and cannot target BmcController (out-of-band Redfish only); target HostOs instead. Typed mismatch via total fold, DESIGN section 5." } - LiveDeployApply { spec: _, candidate: _, plan: _ } => + LiveDeployApply { warrant: _ } => IncompatibleCell { reason: "host_effect: LiveDeployApply is in-band (actuator host shell mutation) and cannot target BmcController (out-of-band Redfish only); target HostOs instead. Typed mismatch via total fold, DESIGN section 5." } LiveDeployRetract { spec: _ } => IncompatibleCell { reason: "host_effect: LiveDeployRetract is in-band (actuator host shell mutation) and cannot target BmcController (out-of-band Redfish only); target HostOs instead. Typed mismatch via total fold, DESIGN section 5." } @@ -675,7 +675,7 @@ fn resolve_host_effect_cell(target: NodeControlPlane, effect: HostEffect) -> Res Srv3BootOnceReadBackSleep => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } Srv3ReceiptEmit { lines: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } HostIdentityShortHostnameRead => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } - LiveDeployApply { spec: _, candidate: _, plan: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } + LiveDeployApply { warrant: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } LiveDeployRetract { spec: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } LiveDeployMicrovmControllerInstall { candidate: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } @@ -712,8 +712,8 @@ fn resolve_host_effect_cell(target: NodeControlPlane, effect: HostEffect) -> Res BmcwebSessionLoginOnHost { node: n } Srv3BmcwebTokenExtract => BmcwebTokenExtractOnHost { node: n } - LiveDeployApply { spec: s, candidate: c, plan: p } => - LiveDeployApplyOnHost { node: n, spec: s, candidate: c, plan: p } + LiveDeployApply { warrant: w } => + LiveDeployApplyOnHost { node: n, spec: w.spec, candidate: w.candidate, plan: w.plan } LiveDeployRetract { spec: s } => LiveDeployRetractOnHost { node: n, spec: s } LiveDeployApprovalBrokerDarkInstall { spec: s, candidate: c } => @@ -776,7 +776,7 @@ fn resolve_host_effect_cell(target: NodeControlPlane, effect: HostEffect) -> Res Srv3BootOnceReadBackSleep => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) Srv3ReceiptEmit { lines: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) HostIdentityShortHostnameRead => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) - LiveDeployApply { spec: _, candidate: _, plan: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) + LiveDeployApply { warrant: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) LiveDeployRetract { spec: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) LiveDeployMicrovmControllerInstall { candidate: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) @@ -1619,7 +1619,7 @@ fn realize_provision_codex_runtime( Srv3BootOnceReadBackSleep => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) Srv3ReceiptEmit { lines: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) HostIdentityShortHostnameRead => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) - LiveDeployApply { spec: _, candidate: _, plan: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) + LiveDeployApply { warrant: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) LiveDeployRetract { spec: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) LiveDeployMicrovmControllerInstall { candidate: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) @@ -3441,7 +3441,7 @@ fn realize_provision_build_cache( Srv3BootOnceReadBackSleep => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) Srv3ReceiptEmit { lines: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) HostIdentityShortHostnameRead => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) - LiveDeployApply { spec: _, candidate: _, plan: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) + LiveDeployApply { warrant: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) LiveDeployRetract { spec: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) LiveDeployMicrovmControllerInstall { candidate: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index cddefa9f762..e232ead0bb4 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -73,6 +73,7 @@ import gunbc.host_effect { import gunbc.host_effect_realize { host_effect_apply_gated } import gunbc.live_deploy.member_observe { deployment_effect_plan, deployment_identity_readback, IdentityReadbackConverged, IdentityReadbackDrift, IdentityReadbackRefused } import gunbc.live_deploy.member_identity { EffectsDerived, EffectsRefused, decision_label } +import gunbc.live_deploy.apply_warrant { warrant_live_deploy_apply } import gunbc.live_deploy.role_singleton_convergence { assess_and_admit, role_singleton_refusal_detail, admitted_spec, admitted_decision, RoleSingletonAdmission, RoleSingletonAdmitted, RoleSingletonNotAdmitted, @@ -156,7 +157,7 @@ import gunbc.running_release_identity { // gated step exists to install. Every other effect installs nothing and is gated on the full roster. fn live_deploy_pre_mutation_access(effect: HostEffect, access: DeployAccess) -> DeployAccess { match effect { - LiveDeployApply { spec: _, candidate: _, plan: _ } => deploy_access_sudoers_install_scope(access: access) + LiveDeployApply { warrant: _ } => deploy_access_sudoers_install_scope(access: access) LiveDeployRetract { spec: _ } => deploy_access_sudoers_install_scope(access: access) _ => access } @@ -302,7 +303,9 @@ fn live_deploy_apply_member_plan_via_transport( candidate: CandidateRelease, transport: HostEffectTransport, access: DeployAccess -) -> Reconciliation { +) -> Reconciliation admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_mutation_via_transport"), +] { match deployment_effect_plan(spec: spec, candidate: candidate, transport: transport) { EffectsRefused { refusals } => NotConverged { @@ -312,7 +315,7 @@ fn live_deploy_apply_member_plan_via_transport( EffectsDerived { plan } => live_deploy_fold( spec: spec, - effect: LiveDeployApply { spec: spec, candidate: candidate, plan: plan }, + effect: LiveDeployApply { warrant: warrant_live_deploy_apply(spec: spec, candidate: candidate, plan: plan) }, transport: transport, access: access, fixture_actor: Absent, diff --git a/dag/gunbc/live_deploy/apply_warrant.dag b/dag/gunbc/live_deploy/apply_warrant.dag new file mode 100644 index 00000000000..7085a9acc56 --- /dev/null +++ b/dag/gunbc/live_deploy/apply_warrant.dag @@ -0,0 +1,25 @@ +module gunbc.live_deploy.apply_warrant + +import std.decl_ref { decl_ref } +import gunbc.live_deploy.spec { DeploymentSpec } +import gunbc.live_deploy.candidate_identity { CandidateRelease } +import gunbc.live_deploy.member_identity { ReleaseEffectPlan } + +// THE ONLY WAY A DEPLOY REACHES THE HOST-EFFECT REALIZER. gunbc.host_effect LiveDeployApply carries this +// warrant rather than a raw (spec, candidate, plan), and the warrant is sole_constructor with one mint, +// sealed to the member-plan step of the admitted apply (gunbc.live_deploy.apply +// live_deploy_apply_member_plan_via_transport), which is itself sealed to the admitted mutation. So a +// spec the role-singleton assessment did not admit cannot be planned, warranted or realized: the +// realizer receives an apply only through the composition that assessed it. A leaf module, so the +// effect vocabulary (gunbc.host_effect) can name the warrant without importing the assessment. +type AdmittedLiveDeployApply sole_constructor { + spec: DeploymentSpec + candidate: CandidateRelease + plan: ReleaseEffectPlan +} + +fn warrant_live_deploy_apply(spec: DeploymentSpec, candidate: CandidateRelease, plan: ReleaseEffectPlan) -> AdmittedLiveDeployApply admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_member_plan_via_transport"), +] { + AdmittedLiveDeployApply { spec: spec, candidate: candidate, plan: plan } +} diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index 84d42359b68..6b033c78bb5 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -1,5 +1,6 @@ module gunbc.live_deploy.emit +import extdeps.access.posix { file_mode_octal } import gunbc.workspace_commissioning_dispatch_operations { workspace_dispatch_grant_path, workspace_dispatch_grant_content } import gunbc.live_deploy.release_environment { installed_release_environment, installed_release_unit } @@ -1437,9 +1438,9 @@ fn ensure_dependency_steps(dep: DeploymentDependencyStep, privileged: Bool) -> L EnsuredManagedHostDirectory { admission } => concat([deploy_raw(command: ensure_managed_directory_command(admission: admission))], map(managed_directory_entry_repair_commands(admission: admission), c => deploy_raw(command: c))) - EnsuredOwnershipMarker { path, owner: _, content } => [ - deploy_raw(command: deploy_stage_write_command(stage_name: "store-ownership-marker", content: content as String)), - deploy_raw(command: deploy_stage_install_command(stage_name: "store-ownership-marker", mode: install_file_shared_mode, dest: path as String)), + EnsuredOwnershipMarker { marker } => [ + deploy_raw(command: deploy_stage_write_command(stage_name: "store-ownership-marker", content: marker.content as String)), + deploy_raw(command: deploy_stage_install_owned_command(stage_name: "store-ownership-marker", mode: file_mode_octal(mode: marker.mode) as NonEmptyStr, owner: marker.owner, group: marker.group, dest: marker.path as String)), ] } } diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index c786a9097ce..07ae98bcc24 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -24,10 +24,21 @@ import gunbc.live_deploy.spec { deployment_fabric_storage_unit_path, gunbc_approval_broker_unit_path, } -import gunbc.fabric_storage_placement { fabric_storage_ownership_marker_path, fabric_storage_ownership_marker_content } +import gunbc.fabric_storage_placement { FabricStorageOwnershipMarkerSpec, FabricStorageMarkerReading, FabricStorageMarkerExact, FabricStorageMarkerMismatched, fabric_storage_ownership_marker_spec, fabric_storage_marker_verdict } +import extdeps.access.posix { file_mode_octal } +import extdeps.shell +import std.algebra { trim } +import std.types { FilePath } import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization } import gunbc.live_deploy.member_observe { ObservationArm, + OnTheHostItself, + OverTransport, + Leg, + LegOut, + LegRefused, + remote_leg, + unit_document_leg, observation_arm, entry_presence, EntryPresence, @@ -74,6 +85,7 @@ type RoleSingletonMember type RoleSingletonSighting = SightedDeploymentMember { host: NonEmptyStr, deployment: DeploymentId, member: RoleSingletonMember } | SightedBroker { host: NonEmptyStr } + | SightedMismatchedMarker { host: NonEmptyStr, deployment: DeploymentId, field: NonEmptyStr, expected: String, observed: String } type HostRoleSingletonReading = HostRoleSingletonsRead { host: NonEmptyStr, sightings: List } @@ -99,7 +111,8 @@ type RoleSingletonRefusal | RoleSingletonTwoOnOneHost { host: NonEmptyStr, deployments: List } | RoleSingletonBrokerWithoutStore { host: NonEmptyStr } | RoleSingletonPartial { deployment: DeploymentId, host: NonEmptyStr, missing: List } - | RoleSingletonUnmarked { deployment: DeploymentId, host: NonEmptyStr, marker_path: NonEmptyStr, marker_content: NonEmptyStr } + | RoleSingletonUnmarked { deployment: DeploymentId, host: NonEmptyStr, marker: FabricStorageOwnershipMarkerSpec } + | RoleSingletonMarkerMismatched { deployment: DeploymentId, host: NonEmptyStr, field: NonEmptyStr, expected: String, observed: String } | RoleSingletonStaleSpec { instance_id: NonEmptyStr, detail: String } // ── What is decided ───────────────────────────────────────────────────────────────────────────── @@ -140,6 +153,7 @@ fn deployment_sightings(readings: List) -> List sightings |> filter(s => match s { SightedDeploymentMember { host: _, deployment: _, member: _ } => true SightedBroker { host: _ } => false + SightedMismatchedMarker { host: _, deployment: _, field: _, expected: _, observed: _ } => false }) HostRoleSingletonsUnobservable { host: _, cause: _ } => [] }) @@ -149,6 +163,7 @@ fn sighting_host(s: RoleSingletonSighting) -> NonEmptyStr { match s { SightedDeploymentMember { host, deployment: _, member: _ } => host SightedBroker { host } => host + SightedMismatchedMarker { host, deployment: _, field: _, expected: _, observed: _ } => host } } @@ -156,6 +171,7 @@ fn sighting_deployment(s: RoleSingletonSighting) -> DeploymentId? { match s { SightedDeploymentMember { host: _, deployment, member: _ } => Present { value: deployment } SightedBroker { host: _ } => none + SightedMismatchedMarker { host: _, deployment, field: _, expected: _, observed: _ } => Present { value: deployment } } } @@ -163,6 +179,7 @@ fn sighting_member(s: RoleSingletonSighting) -> RoleSingletonMember { match s { SightedDeploymentMember { host: _, deployment: _, member } => member SightedBroker { host: _ } => BrokerUnitMember + SightedMismatchedMarker { host: _, deployment: _, field: _, expected: _, observed: _ } => StoreMarkerMember } } @@ -170,6 +187,7 @@ fn broker_hosts(readings: List) -> List { readings |> flat_map(r => match r { HostRoleSingletonsRead { host: _, sightings } => sightings |> flat_map(s => match s { SightedBroker { host } => [host] + SightedMismatchedMarker { host: _, deployment: _, field: _, expected: _, observed: _ } => [] SightedDeploymentMember { host: _, deployment: _, member: _ } => [] }) HostRoleSingletonsUnobservable { host: _, cause: _ } => [] @@ -201,6 +219,14 @@ fn role_singleton_observation_of(readings: List, rows: HostRoleSingletonsUnobservable { host, cause } => [RoleSingletonHostUnobservable { host: host, cause: cause }] HostRoleSingletonsRead { host: _, sightings: _ } => [] }) + let mismatched = readings |> flat_map(r => match r { + HostRoleSingletonsRead { host: _, sightings: ss } => ss |> flat_map(x => match x { + SightedMismatchedMarker { host, deployment, field, expected, observed } => [RoleSingletonMarkerMismatched { deployment: deployment, host: host, field: field, expected: expected, observed: observed }] + SightedDeploymentMember { host: _, deployment: _, member: _ } => [] + SightedBroker { host: _ } => [] + }) + HostRoleSingletonsUnobservable { host: _, cause: _ } => [] + }) let sightings = deployment_sightings(readings: readings) let ids = sighted_ids(sightings: sightings) let two_hosts = ids |> flat_map(d => if count(hosts_of(sightings: sightings, deployment: d)) > 1 { [RoleSingletonRealizedOnTwoHosts { deployment: d, hosts: hosts_of(sightings: sightings, deployment: d) }] } else { [] }) @@ -215,7 +241,7 @@ fn role_singleton_observation_of(readings: List, rows: }) let brokers = broker_hosts(readings: readings) let broker_alone = brokers |> flat_map(h => if any(store_hosts, s => (s as String) == (h as String)) { [] } else { [RoleSingletonBrokerWithoutStore { host: h }] }) - let first_refusal = concat(concat(concat(unobservable, two_hosts), concat(misplaced, two_on_one)), concat(if count(ids) > 1 { [RoleSingletonHoldersAmbiguous { observed: ids }] } else { [] }, broker_alone)) |> first + let first_refusal = concat(concat(concat(concat(unobservable, mismatched), two_hosts), concat(misplaced, two_on_one)), concat(if count(ids) > 1 { [RoleSingletonHoldersAmbiguous { observed: ids }] } else { [] }, broker_alone)) |> first match first_refusal { Present { value: c } => RoleSingletonObservationRefused { cause: c } Absent => RoleSingletonsObserved { realizations: ids |> flat_map(d => hosts_of(sightings: sightings, deployment: d) |> map(h => ObservedRealization { @@ -271,8 +297,7 @@ fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingle RoleSingletonRefused { cause: RoleSingletonUnmarked { deployment: r.deployment, host: r.host, - marker_path: fabric_storage_ownership_marker_path(instance: holder.instance), - marker_content: fabric_storage_ownership_marker_content(instance: holder.instance), + marker: fabric_storage_ownership_marker_spec(instance: holder.instance), } } } else { RoleSingletonRefused { cause: RoleSingletonPartial { deployment: r.deployment, host: r.host, missing: missing } } @@ -385,8 +410,10 @@ fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { join(["role singletons refused: the approval broker is present on ", host as String, " but no store is realized there"], "") RoleSingletonPartial { deployment, host, missing } => join(["role singletons refused: ", deployment.name as String, " on ", host as String, " is partial (missing ", to_string(count(missing)), " member(s)); an interrupted install or a store without its broker is not adopted"], "") - RoleSingletonUnmarked { deployment, host, marker_path, marker_content } => - join(["role singletons refused: ", deployment.name as String, " on ", host as String, " has its broker and fabric store unit but no store ownership marker, so whether that store is this holder's cannot be established. The operator's one-time disposition: on ", host as String, " write ", marker_path as String, " (mode 0644, owner briansrls) with exactly this content: ", marker_content as String], "") + RoleSingletonUnmarked { deployment, host, marker } => + join(["role singletons refused: ", deployment.name as String, " on ", host as String, " has its broker and fabric store unit but no store ownership marker, so whether that store is this holder's cannot be established. The operator's one-time disposition: on ", host as String, " write ", marker.path as String, " with mode ", file_mode_octal(mode: marker.mode), ", owner ", marker.owner as String, ", group ", marker.group as String, " and exactly this content: ", marker.content as String], "") + RoleSingletonMarkerMismatched { deployment, host, field, expected, observed } => + join(["role singletons refused: the store ownership marker for ", deployment.name as String, " on ", host as String, " does not match its row: ", field as String, " is '", observed, "', expected '", expected, "'; a marker that differs in any field is not the marker"], "") RoleSingletonStaleSpec { instance_id, detail } => join(["role singletons refused: spec ", instance_id as String, " is not admitted: ", detail], "") } @@ -420,22 +447,82 @@ fn presence_cause(p: EntryPresence) -> String? { } } +// THE MARKER IS READ BACK, NOT ONLY SEEN: its bytes, its mode, its owner and its group, each through +// the same observation arm, against the one marker row (gunbc.fabric_storage_placement +// fabric_storage_ownership_marker_spec). Only an exact match is the marker; any differing field is a +// distinct sighting the adjudication refuses, and a field that cannot be read makes the host +// unobservable rather than reading as either. +fn stat_leg(arm: ObservationArm, path: String, format: String, leg: String) -> Leg { + match arm { + OnTheHostItself => { + let r = if format == "%a" { shell.Stat.ModeOf(path: path as FilePath) } else if format == "%U" { shell.Stat.OwnerOf(path: path as FilePath) } else { shell.Stat.GroupOf(path: path as FilePath) } + if r.success { LegOut { stdout: r.stdout } } else { LegRefused { cause: join([leg, " failed: ", r.stderr], "") } } + } + OverTransport { transport } => remote_leg(transport: transport, argv: ["stat", "-c", format, "--", path], leg: leg) + } +} + +type MarkerRead + = MarkerReadBack { reading: FabricStorageMarkerReading } + | MarkerReadRefused { cause: String } + +fn read_marker(arm: ObservationArm, path: NonEmptyStr) -> MarkerRead { + match unit_document_leg(arm: arm, path: path as String) { + LegRefused { cause } => MarkerReadRefused { cause: cause } + LegOut { stdout: content } => match stat_leg(arm: arm, path: path as String, format: "%a", leg: "reading the marker's mode") { + LegRefused { cause } => MarkerReadRefused { cause: cause } + LegOut { stdout: mode } => match stat_leg(arm: arm, path: path as String, format: "%U", leg: "reading the marker's owner") { + LegRefused { cause } => MarkerReadRefused { cause: cause } + LegOut { stdout: owner } => match stat_leg(arm: arm, path: path as String, format: "%G", leg: "reading the marker's group") { + LegRefused { cause } => MarkerReadRefused { cause: cause } + LegOut { stdout: group } => MarkerReadBack { reading: FabricStorageMarkerReading { path: path, content: content, mode: trim(s: mode), owner: owner, group: group } } + } + } + } + } +} + +type MarkerSighting + = MarkerSightingAbsent + | MarkerSightingOf { sighting: RoleSingletonSighting } + | MarkerSightingUnobservable { cause: String } + +fn marker_sighting(host: NonEmptyStr, r: DesiredDeployment, arm: ObservationArm) -> MarkerSighting { + let spec = fabric_storage_ownership_marker_spec(instance: r.instance) + match entry_presence(arm: arm, path: spec.path as String) { + EntryPresenceIndeterminate { cause } => MarkerSightingUnobservable { cause: cause } + EntryAbsent => MarkerSightingAbsent + EntryPresent => match read_marker(arm: arm, path: spec.path) { + MarkerReadRefused { cause } => MarkerSightingUnobservable { cause: cause } + MarkerReadBack { reading } => match fabric_storage_marker_verdict(spec: spec, reading: reading) { + FabricStorageMarkerExact => MarkerSightingOf { sighting: SightedDeploymentMember { host: host, deployment: r.instance.deployment, member: StoreMarkerMember } } + FabricStorageMarkerMismatched { field, expected, observed } => MarkerSightingOf { sighting: SightedMismatchedMarker { host: host, deployment: r.instance.deployment, field: field, expected: expected, observed: observed } } + } + } + } +} + fn host_role_singleton_reading(host: NonEmptyStr, rows: List, arm: ObservationArm) -> HostRoleSingletonReading { let probes = concat( [MemberProbe { deployment: none, member: BrokerUnitMember, presence: entry_presence(arm: arm, path: gunbc_approval_broker_unit_path() as String) }], - rows |> flat_map(r => [ - MemberProbe { deployment: Present { value: r.instance.deployment }, member: FabricUnitMember, presence: entry_presence(arm: arm, path: deployment_fabric_storage_unit_path(names: r.names) as String) }, - MemberProbe { deployment: Present { value: r.instance.deployment }, member: StoreMarkerMember, presence: entry_presence(arm: arm, path: fabric_storage_ownership_marker_path(instance: r.instance) as String) }, - ]), + rows |> map(r => MemberProbe { deployment: Present { value: r.instance.deployment }, member: FabricUnitMember, presence: entry_presence(arm: arm, path: deployment_fabric_storage_unit_path(names: r.names) as String) }), ) - match probes |> flat_map(p => match presence_cause(p: p.presence) { Present { value: c } => [c] Absent => [] }) |> first { + let markers = rows |> map(r => marker_sighting(host: host, r: r, arm: arm)) + let causes = concat( + probes |> flat_map(p => match presence_cause(p: p.presence) { Present { value: c } => [c] Absent => [] }), + markers |> flat_map(m => match m { MarkerSightingUnobservable { cause } => [cause] MarkerSightingAbsent => [] MarkerSightingOf { sighting: _ } => [] }), + ) + match causes |> first { Present { value: cause } => HostRoleSingletonsUnobservable { host: host, cause: cause } Absent => HostRoleSingletonsRead { host: host, - sightings: probes |> filter(p => presence_seen(p: p.presence)) |> map(p => match p.deployment { - Present { value: d } => SightedDeploymentMember { host: host, deployment: d, member: p.member } - Absent => SightedBroker { host: host } - }), + sightings: concat( + probes |> filter(p => presence_seen(p: p.presence)) |> map(p => match p.deployment { + Present { value: d } => SightedDeploymentMember { host: host, deployment: d, member: p.member } + Absent => SightedBroker { host: host } + }), + markers |> flat_map(m => match m { MarkerSightingOf { sighting } => [sighting] MarkerSightingAbsent => [] MarkerSightingUnobservable { cause: _ } => [] }), + ), } } } diff --git a/dag/gunbc/live_deploy/spec.dag b/dag/gunbc/live_deploy/spec.dag index b804dbac6e4..66d5d767b47 100644 --- a/dag/gunbc/live_deploy/spec.dag +++ b/dag/gunbc/live_deploy/spec.dag @@ -41,7 +41,7 @@ import gunbc.host_layout { srv1_dispatch_attempt_state_root, srv1_devboot_artifact_store_root, } -import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, fabric_storage_ownership_marker_path, fabric_storage_ownership_marker_content, fabric_storage_https_port, fabric_storage_door_socket } +import gunbc.fabric_storage_placement { fabric_storage_store_directories_for, FabricStorageOwnershipMarkerSpec, fabric_storage_ownership_marker_spec, fabric_storage_https_port, fabric_storage_door_socket } import gunbc.managed_directory { ManagedDirectoryAdmission, managed_directory_admission_path } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, @@ -146,14 +146,14 @@ type EnsuredSubject = EnsuredPackage { package: NonEmptyStr } | EnsuredHostDirectory { path: NonEmptyStr, owner: NonEmptyStr } | EnsuredManagedHostDirectory { admission: ManagedDirectoryAdmission } - | EnsuredOwnershipMarker { path: NonEmptyStr, owner: NonEmptyStr, content: NonEmptyStr } + | EnsuredOwnershipMarker { marker: FabricStorageOwnershipMarkerSpec } fn ensured_subject_identity(subject: EnsuredSubject) -> NonEmptyStr { match subject { EnsuredPackage { package } => package EnsuredHostDirectory { path, owner } => path EnsuredManagedHostDirectory { admission } => managed_directory_admission_path(a: admission) as String as NonEmptyStr - EnsuredOwnershipMarker { path, owner: _, content: _ } => path + EnsuredOwnershipMarker { marker } => marker.path } } @@ -1134,7 +1134,7 @@ fn deployment_role_singleton_directories(holding: RoleSingletonHolding) -> List< }), [ DeploymentDependencyStep { kind: FabricStorageOwnershipMarker, - subject: EnsuredOwnershipMarker { path: fabric_storage_ownership_marker_path(instance: holder.instance), owner: gunbc_service_user, content: fabric_storage_ownership_marker_content(instance: holder.instance) }, + subject: EnsuredOwnershipMarker { marker: fabric_storage_ownership_marker_spec(instance: holder.instance) }, }, ]) HoldsNoRoleSingleton => [] diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 6b6dbf2ed0e..af16dac26c4 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -82,6 +82,8 @@ import gunbc.live_deploy.role_singleton_convergence { RoleSingletonBrokerWithoutStore, RoleSingletonPartial, RoleSingletonUnmarked, + RoleSingletonMarkerMismatched, + SightedMismatchedMarker, role_singleton_refusal_detail, RoleSingletonStaleSpec, RoleSingletonSighting, @@ -111,6 +113,12 @@ import gunbc.fabric_storage_placement { fabric_storage_door_socket, fabric_storage_ownership_marker_path, fabric_storage_ownership_marker_content, + fabric_storage_ownership_marker_spec, + fabric_storage_marker_verdict, + FabricStorageOwnershipMarkerSpec, + FabricStorageMarkerReading, + FabricStorageMarkerExact, + FabricStorageMarkerMismatched, } import gunbc.auth.approval_request_client { ApprovalLoopbackOriginResolved, @@ -349,7 +357,8 @@ fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { RoleSingletonTwoOnOneHost { host: _, deployments: _ } => label == "two-on-one" RoleSingletonBrokerWithoutStore { host: _ } => label == "broker-alone" RoleSingletonPartial { deployment: _, host: _, missing: _ } => label == "partial" - RoleSingletonUnmarked { deployment: _, host: _, marker_path: _, marker_content: _ } => label == "unmarked" + RoleSingletonUnmarked { deployment: _, host: _, marker: _ } => label == "unmarked" + RoleSingletonMarkerMismatched { deployment: _, host: _, field: _, expected: _, observed: _ } => label == "marker-mismatch" RoleSingletonStaleSpec { instance_id: _, detail: _ } => label == "stale" } RoleSingletonNoop => false @@ -582,7 +591,7 @@ fn units_without_marker_on(host: String, d: DeploymentId) -> List List { spec.steps |> flat_map(s => match s { Dependency { step } => match step.subject { - EnsuredOwnershipMarker { path: _, owner: _, content } => [content as String] + EnsuredOwnershipMarker { marker } => [marker.content as String] _ => [] } ArtifactStep { step: _ } => [] @@ -597,9 +606,10 @@ test fn an_unmarked_legacy_store_refuses_naming_the_operators_disposition() -> B let decision = decide(desired: real, readings: [read_on(host: "srv1", sightings: units_without_marker_on(host: "srv1", d: srv1_daily_workspace_deployment)), read_on(host: "srv2", sightings: [])]) let names_it = match decision { RoleSingletonRefused { cause } => match cause { - RoleSingletonUnmarked { deployment, host, marker_path, marker_content } => + RoleSingletonUnmarked { deployment, host, marker } => deployment == srv1_daily_workspace_deployment && (host as String) == "srv1" - && (marker_path as String) == want_path && (marker_content as String) == want_content + && (marker.path as String) == want_path && (marker.content as String) == want_content + && (marker.owner as String) == "briansrls" && (marker.group as String) == "briansrls" && string_contains(s: role_singleton_refusal_detail(cause: cause), pattern: want_path) && string_contains(s: role_singleton_refusal_detail(cause: cause), pattern: want_content) _ => false @@ -622,6 +632,14 @@ data forged_admission_source: String = "module probe_forged_role_singleton_admis data obtained_admission_source: String = "module probe_obtained_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn obtain() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), own: LocalShell) }\n" +data outside_member_plan_source: String = "module probe_outside_member_plan\nimport gunbc.live_deploy.apply { live_deploy_apply_member_plan_via_transport }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.host_effect { LocalShell }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn bypass(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_member_plan_via_transport(spec: deployment_spec_srv1(), candidate: c, transport: LocalShell, access: a)\n true }\n" + +data outside_warrant_mint_source: String = "module probe_outside_warrant_mint\nimport gunbc.live_deploy.apply_warrant { warrant_live_deploy_apply, AdmittedLiveDeployApply }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.live_deploy.member_identity { ReleaseEffectPlan }\nfn forge(c: CandidateRelease, p: ReleaseEffectPlan) -> AdmittedLiveDeployApply { warrant_live_deploy_apply(spec: deployment_spec_srv1(), candidate: c, plan: p) }\n" + +data outside_warrant_literal_source: String = "module probe_outside_warrant_literal\nimport gunbc.live_deploy.apply_warrant { AdmittedLiveDeployApply }\nimport gunbc.host_effect { HostEffect, LiveDeployApply }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.live_deploy.member_identity { ReleaseEffectPlan }\nfn forge(c: CandidateRelease, p: ReleaseEffectPlan) -> HostEffect { LiveDeployApply { warrant: AdmittedLiveDeployApply { spec: deployment_spec_srv1(), candidate: c, plan: p } } }\n" + +data canonical_apply_source: String = "module probe_canonical_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn act(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(spec: deployment_spec_srv1(), access: a, candidate: c)\n true }\n" + data replayed_admission_source: String = "module probe_replayed_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { RoleSingletonAdmission, admitted_spec }\nimport gunbc.live_deploy.spec { DeploymentSpec }\nfn replay(a: RoleSingletonAdmission) -> DeploymentSpec { admitted_spec(admission: a) }\n" fn admission_refusals_in(source: String) -> Int { @@ -636,3 +654,53 @@ test fn no_outside_module_can_mint_obtain_or_replay_an_admission() -> Bool { && admission_refusals_in(source: obtained_admission_source) >= 1 && admission_refusals_in(source: replayed_admission_source) >= 1 } + +fn sole_constructor_refusals_in(source: String) -> Int { + match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: "SoleConstructorViolation") { + Present { value: n } => n + Absent => 0 + } +} + +// THE EFFECT DESTINATION IS CLOSED. (a) an outside call to the member-plan step and (b) an outside +// warrant -- minted or written as a literal inside a LiveDeployApply -- are each refused at compile +// time; (c) the canonical assess-and-act entry still compiles with no admission refusal. +test fn the_realizer_receives_an_apply_only_through_the_assessed_composition() -> Bool { + admission_refusals_in(source: outside_member_plan_source) >= 1 + && admission_refusals_in(source: outside_warrant_mint_source) >= 1 + && sole_constructor_refusals_in(source: outside_warrant_literal_source) >= 1 + && admission_refusals_in(source: canonical_apply_source) == 0 +} + +// ── The marker has one authority, and only an exact readback is the marker ───────────────────── +// Each field mutated alone -- path, content, mode, owner, group -- is a mismatch named by that field; +// the unmutated reading is exact; and a mismatched marker sighted on the holder's host refuses as +// RoleSingletonMarkerMismatched, distinct from an absent one (RoleSingletonUnmarked). +fn exact_reading(m: FabricStorageOwnershipMarkerSpec) -> FabricStorageMarkerReading { + FabricStorageMarkerReading { path: m.path, content: m.content as String, mode: "644", owner: m.owner as String, group: m.group as String } +} + +fn mismatched_on(m: FabricStorageOwnershipMarkerSpec, r: FabricStorageMarkerReading, field: String) -> Bool { + match fabric_storage_marker_verdict(spec: m, reading: r) { + FabricStorageMarkerMismatched { field: f, expected: _, observed: _ } => (f as String) == field + FabricStorageMarkerExact => false + } +} + +test fn every_marker_field_is_read_back_and_any_mismatch_refuses() -> Bool { + let m = fabric_storage_ownership_marker_spec(instance: srv1_live_dashboard_instance()) + let r = exact_reading(m: m) + let exact = match fabric_storage_marker_verdict(spec: m, reading: r) { FabricStorageMarkerExact => true _ => false } + let mismatch_world = [read_on(host: "srv1", sightings: [ + SightedDeploymentMember { host: "srv1" as NonEmptyStr, deployment: srv1_daily_workspace_deployment, member: FabricUnitMember }, + SightedBroker { host: "srv1" as NonEmptyStr }, + SightedMismatchedMarker { host: "srv1" as NonEmptyStr, deployment: srv1_daily_workspace_deployment, field: "owner", expected: "briansrls", observed: "root" }, + ]), read_on(host: "srv2", sightings: [])] + exact + && mismatched_on(m: m, r: FabricStorageMarkerReading { path: "/opt/gunbc/fabric-storage/elsewhere" as NonEmptyStr, content: r.content, mode: r.mode, owner: r.owner, group: r.group }, field: "path") + && mismatched_on(m: m, r: FabricStorageMarkerReading { path: r.path, content: "deployment=someone-else\nhost=srv1\n", mode: r.mode, owner: r.owner, group: r.group }, field: "content") + && mismatched_on(m: m, r: FabricStorageMarkerReading { path: r.path, content: r.content, mode: "600", owner: r.owner, group: r.group }, field: "mode") + && mismatched_on(m: m, r: FabricStorageMarkerReading { path: r.path, content: r.content, mode: r.mode, owner: "root", group: r.group }, field: "owner") + && mismatched_on(m: m, r: FabricStorageMarkerReading { path: r.path, content: r.content, mode: r.mode, owner: r.owner, group: "root" }, field: "group") + && refused_with(c: decide(desired: prod_role_actuated_realization(), readings: mismatch_world), label: "marker-mismatch") +} diff --git a/dag/test/claim/devboot_subject_identity_witness_test.dag b/dag/test/claim/devboot_subject_identity_witness_test.dag index b71228b14d7..79aab004393 100644 --- a/dag/test/claim/devboot_subject_identity_witness_test.dag +++ b/dag/test/claim/devboot_subject_identity_witness_test.dag @@ -730,7 +730,7 @@ fn devboot_ensured_step_projects_layout(acc: Int, step: DeploymentDependencyStep if (path as String) == (srv1_devboot_artifact_store_root as String) { acc + 1 } else { acc } EnsuredPackage { package } => acc EnsuredManagedHostDirectory { admission: _ } => acc - EnsuredOwnershipMarker { path: _, owner: _, content: _ } => acc + EnsuredOwnershipMarker { marker: _ } => acc } } From eba4db2edb9f30275e6c94b17d088571c175f85b Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sun, 4 Oct 2026 23:03:46 +0000 Subject: [PATCH 35/48] D2 round 9: production apply takes a DesiredDeployment and builds its spec from the bound roster row (no caller spec reaches the assessor); the store marker moves to a control directory whose derived mode admits the deploy job principal, with three readback outcomes; the warrant never leaves the sealed member-plan step (LiveDeployApplyReported), re-wrap residual recorded on modifier_accepted_on_a_shape_its_check_cannot_see Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/fabric/fabric_storage_placement.dag | 51 ++++++-- dag/gunbc/host/host_effect.dag | 1 + dag/gunbc/host/host_effect_realize.dag | 11 ++ dag/gunbc/live_deploy/apply.dag | 113 ++++++++++++------ .../role_singleton_convergence.dag | 50 ++++++-- dag/gunbc/managed_directory.dag | 12 ++ ...ent_acts_on_the_prod_deployments_state.dag | 2 +- ...cepted_on_a_shape_its_check_cannot_see.dag | 2 + .../roadmap/roadmap_dashboard_instance.dag | 2 +- .../claim/deployment_risk_witness_test.dag | 88 +++++++++++++- ...tory_transition_admission_witness_test.dag | 8 +- docs/plans/deployment-risk-conformance.md | 10 +- 12 files changed, 283 insertions(+), 67 deletions(-) diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index 056c5ec1670..05498f3cdd2 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -18,7 +18,7 @@ import gunbc.managed_directory { } import gunbc.fleet_posix_accounts { fleet_posix_ci_runner_user } import gunbc.fabric_storage_file_store { FabricStorageFileRoot, fabric_storage_file_root, fabric_storage_file_root_with } -import extdeps.access.posix { FileMode, PermissionBits, file_mode_bits, file_mode_octal, file_mode_of_octal_text } +import extdeps.access.posix { PosixUser, FileMode, PermissionBits, file_mode_bits, file_mode_octal, file_mode_of_octal_text } import std.algebra { trim } // WHERE THE FABRIC DB IS: one host holds the store's files and answers the endpoint. This is the @@ -80,12 +80,48 @@ fn fabric_storage_store_root(instance: HostDashboardInstance) -> NonEmptyStr { } // THE STORE'S OWNERSHIP MARKER, by the instance ownership-marker rule -// (gunbc.roadmap_dashboard_instance dashboard_ownership_marker_in) applied to the store root. The -// store root, auth state and door directories are Ensured and no retract removes them, so a unit file -// alone cannot say whether a host holds a store; the marker can, and the role-singleton observation -// reads it (gunbc.live_deploy.role_singleton_convergence). +// (gunbc.roadmap_dashboard_instance dashboard_ownership_marker_in) applied to the store's CONTROL +// directory, not to the store root. The store root, auth state and door directories are Ensured and no +// retract removes them, so a unit file alone cannot say whether a host holds a store; the marker can, +// and the role-singleton observation reads it (gunbc.live_deploy.role_singleton_convergence) as the +// deploy job principal. The store root's mode is derived from its one dependent, the service, and +// grants nobody else traversal, so a marker there is unreadable to that observer and every assessment +// would refuse as unobservable; widening the store root to admit the observer would hand it the store. fn fabric_storage_ownership_marker_path(instance: HostDashboardInstance) -> NonEmptyStr { - dashboard_ownership_marker_in(root: dashboard_instance_fabric_storage_root(instance: instance), instance_id: instance.instance_id) as String as NonEmptyStr + dashboard_ownership_marker_in(root: fabric_storage_control_directory_path(instance: instance) as String as FilePath, instance_id: instance.instance_id) as String as NonEmptyStr +} + +fn fabric_storage_control_directory_path(instance: HostDashboardInstance) -> NonEmptyStr { + dashboard_instance_child(root: instance.instance_root, child: "fabric-storage-control") as String as NonEmptyStr +} + +// WHO READS THE MARKER: the principal the role-singleton observation runs as, the deploy job principal +// (gunbc.fleet_posix_accounts srv1_ci_deploy_runner_principal job_user). It already traverses the +// instance root to reach the door (fabric_storage_door_dependents), so the control directory is the +// only new grant, and it is read and traverse, never write. +data fabric_storage_marker_observer: PosixUser = fleet_posix_ci_runner_user + +// THE CONTROL DIRECTORY holds only the marker. The service writes it; the observer is its group and +// reads it. Its mode is derived from those two dependents (gunbc.managed_directory +// managed_directory_permissions), so the observer's access is a declared dependency, not a number. +fn fabric_storage_control_directory(instance: HostDashboardInstance) -> ManagedDirectory { + let service = srv1_dashboard_service_principal() + ManagedDirectory { + member: "fabric-storage-control", + path: fabric_storage_control_directory_path(instance: instance) as String as FilePath, + owner: service, + group_principal: fabric_storage_marker_observer, + dependents: [ + DirectoryDependent { who: service, needs: [Read, Write, Execute] }, + DirectoryDependent { who: fabric_storage_marker_observer, needs: [Read, Execute] }, + ], + ownership: Ensured, + entries: EntriesUnmanaged, + } +} + +fn fabric_storage_store_root_directory(instance: HostDashboardInstance) -> ManagedDirectory { + fabric_storage_directory(member: "fabric-storage-root", path: fabric_storage_store_root(instance: instance), writes: false) } // THE MARKER'S CONTENT names whose store it is: the deployment and the host. @@ -309,13 +345,14 @@ fn fabric_storage_store_directories_for(instance: HostDashboardInstance) -> List let root = fabric_storage_store_root(instance: instance) let areas = fabric_storage_file_root(root: root) [ - managed_directory_admit(d: fabric_storage_directory(member: "fabric-storage-root", path: root, writes: false)), + managed_directory_admit(d: fabric_storage_store_root_directory(instance: instance)), managed_directory_admit(d: fabric_storage_directory(member: "fabric-storage-objects", path: areas.objects, writes: true)), managed_directory_admit(d: fabric_storage_directory(member: "fabric-storage-heads", path: areas.heads, writes: true)), managed_directory_admit(d: fabric_storage_state_directory(member: "fabric-state-root", path: fabric_storage_state_root(store_root: root))), managed_directory_admit(d: fabric_storage_state_directory(member: "fabric-state-objects", path: fabric_storage_state_file_root(store_root: root).objects)), managed_directory_admit(d: fabric_storage_state_directory(member: "fabric-state-heads", path: fabric_storage_state_file_root(store_root: root).heads)), managed_directory_admit(d: fabric_storage_door_directory(instance: instance)), + managed_directory_admit(d: fabric_storage_control_directory(instance: instance)), ] } diff --git a/dag/gunbc/host/host_effect.dag b/dag/gunbc/host/host_effect.dag index 9b2262153d4..e2cf0bc749e 100644 --- a/dag/gunbc/host/host_effect.dag +++ b/dag/gunbc/host/host_effect.dag @@ -77,6 +77,7 @@ type HostEffect = | Srv3ReceiptEmit { lines: List } | HostIdentityShortHostnameRead | LiveDeployApply { warrant: AdmittedLiveDeployApply } + | LiveDeployApplyReported { spec: DeploymentSpec, candidate: CandidateRelease } | LiveDeployRetract { spec: DeploymentSpec } | LiveDeployApprovalBrokerDarkInstall { spec: DeploymentSpec, candidate: CandidateRelease } | LiveDeployMicrovmControllerInstall { candidate: CandidateRelease } diff --git a/dag/gunbc/host/host_effect_realize.dag b/dag/gunbc/host/host_effect_realize.dag index aaea89eaebd..947395d7df8 100644 --- a/dag/gunbc/host/host_effect_realize.dag +++ b/dag/gunbc/host/host_effect_realize.dag @@ -27,6 +27,7 @@ import gunbc.host_effect { Srv3ReceiptEmit, HostIdentityShortHostnameRead, LiveDeployApply, + LiveDeployApplyReported, LiveDeployRetract, LiveDeployApprovalBrokerDarkInstall, LiveDeployMicrovmControllerInstall, @@ -535,6 +536,8 @@ fn resolve_host_os_effect_cell_after_direct_arms(n: ComputeHost, effect: HostEff HostnameReadOnHost { node: n } LiveDeployApply { warrant: w } => LiveDeployApplyOnHost { node: n, spec: w.spec, candidate: w.candidate, plan: w.plan } + LiveDeployApplyReported { spec: _, candidate: _ } => + IncompatibleCell { reason: "host_effect: LiveDeployApplyReported is the warrant-free report of a live-deploy apply that already ran; it is not an effect and realizes nowhere." } LiveDeployRetract { spec: s } => LiveDeployRetractOnHost { node: n, spec: s } LiveDeployApprovalBrokerDarkInstall { spec: s, candidate: c } => @@ -613,6 +616,8 @@ fn resolve_bmc_controller_effect_cell_after_direct_arms(n: ComputeHost, effect: IncompatibleCell { reason: "host_effect: SudoNopasswdGrantListProbe is in-band (typed sudo grant-list probe) and cannot target BmcController (out-of-band Redfish only); target HostOs instead. Typed mismatch via total fold, DESIGN section 5." } HostIdentityShortHostnameRead => IncompatibleCell { reason: "host_effect: HostIdentityShortHostnameRead is in-band (shell hostname -s) and cannot target BmcController (out-of-band Redfish only); target HostOs instead. Typed mismatch via total fold, DESIGN section 5." } + LiveDeployApplyReported { spec: _, candidate: _ } => + IncompatibleCell { reason: "host_effect: LiveDeployApplyReported is the warrant-free report of a live-deploy apply that already ran; it is not an effect and realizes nowhere." } LiveDeployApply { warrant: _ } => IncompatibleCell { reason: "host_effect: LiveDeployApply is in-band (actuator host shell mutation) and cannot target BmcController (out-of-band Redfish only); target HostOs instead. Typed mismatch via total fold, DESIGN section 5." } LiveDeployRetract { spec: _ } => @@ -676,6 +681,7 @@ fn resolve_host_effect_cell(target: NodeControlPlane, effect: HostEffect) -> Res Srv3ReceiptEmit { lines: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } HostIdentityShortHostnameRead => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } LiveDeployApply { warrant: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } + LiveDeployApplyReported { spec: _, candidate: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } LiveDeployRetract { spec: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } LiveDeployMicrovmControllerInstall { candidate: _ } => IncompatibleCell { reason: "host_effect: RunnerLocalHost admits only ProbeToolchainHomeInterference; fleet-addressed effects require HostOs or BmcController." } @@ -714,6 +720,8 @@ fn resolve_host_effect_cell(target: NodeControlPlane, effect: HostEffect) -> Res BmcwebTokenExtractOnHost { node: n } LiveDeployApply { warrant: w } => LiveDeployApplyOnHost { node: n, spec: w.spec, candidate: w.candidate, plan: w.plan } + LiveDeployApplyReported { spec: _, candidate: _ } => + IncompatibleCell { reason: "host_effect: LiveDeployApplyReported is the warrant-free report of a live-deploy apply that already ran; it is not an effect and realizes nowhere." } LiveDeployRetract { spec: s } => LiveDeployRetractOnHost { node: n, spec: s } LiveDeployApprovalBrokerDarkInstall { spec: s, candidate: c } => @@ -777,6 +785,7 @@ fn resolve_host_effect_cell(target: NodeControlPlane, effect: HostEffect) -> Res Srv3ReceiptEmit { lines: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) HostIdentityShortHostnameRead => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) LiveDeployApply { warrant: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) + LiveDeployApplyReported { spec: _, candidate: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) LiveDeployRetract { spec: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) LiveDeployMicrovmControllerInstall { candidate: _ } => resolve_bmc_controller_effect_cell_after_direct_arms(n: n, effect: effect) @@ -1620,6 +1629,7 @@ fn realize_provision_codex_runtime( Srv3ReceiptEmit { lines: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) HostIdentityShortHostnameRead => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) LiveDeployApply { warrant: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) + LiveDeployApplyReported { spec: _, candidate: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) LiveDeployRetract { spec: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) LiveDeployMicrovmControllerInstall { candidate: _ } => realize_provision_codex_runtime_wrong_effect(effect_intent: effect_intent) @@ -3442,6 +3452,7 @@ fn realize_provision_build_cache( Srv3ReceiptEmit { lines: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) HostIdentityShortHostnameRead => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) LiveDeployApply { warrant: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) + LiveDeployApplyReported { spec: _, candidate: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) LiveDeployRetract { spec: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) LiveDeployApprovalBrokerDarkInstall { spec: _, candidate: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) LiveDeployMicrovmControllerInstall { candidate: _ } => realize_provision_build_cache_wrong_effect(effect_intent: effect_intent) diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index e232ead0bb4..e5f1bc5e2d6 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -23,7 +23,7 @@ import gunbc.live_deploy.spec { deployment_plan_listen_port, deployment_plan_host_identity, } -import gunbc.live_deploy.desired { deployment_spec_srv1 } +import gunbc.live_deploy.desired { deployment_spec_srv1, DesiredDeployment, srv1_production_desired_deployment, selected_deployment_spec } import std.types { String, CommitSha, NonEmptyStr } import gunbc.live_deploy.candidate { CandidateRelease, @@ -55,6 +55,7 @@ import v2.std.collection { Present, Absent } import std.access { AccessDecision, Permit, Deny } import gunbc.host_effect { HostEffect, + LiveDeployApplyReported, HostOs, NodeControlPlane, host_effect_identity_evidence, @@ -280,7 +281,7 @@ fn live_deploy_ensure_dependency( ) } -// THE MUTATION ACTS ON THE ADMITTED SPEC ONLY. live_deploy_apply_via_transport admits the caller's +// THE MUTATION ACTS ON THE ADMITTED SPEC ONLY. live_deploy_apply_via_transport admits the roster-built // spec against the role-singleton assessment first (gunbc.live_deploy.role_singleton_convergence // assess_and_admit); this takes the admission token, so there is no route by which a spec the // assessment did not admit -- one built before the role moved -- reaches member planning. @@ -313,17 +314,52 @@ fn live_deploy_apply_member_plan_via_transport( applied: live_deploy_preflight_intent(access: access), } EffectsDerived { plan } => - live_deploy_fold( + live_deploy_apply_reported( + r: live_deploy_fold( + spec: spec, + effect: LiveDeployApply { warrant: warrant_live_deploy_apply(spec: spec, candidate: candidate, plan: plan) }, + transport: transport, + access: access, + fixture_actor: Absent, + fixture_host: Absent + ), spec: spec, - effect: LiveDeployApply { warrant: warrant_live_deploy_apply(spec: spec, candidate: candidate, plan: plan) }, - transport: transport, - access: access, - fixture_actor: Absent, - fixture_host: Absent + candidate: candidate, ) } } +// THE WARRANT DOES NOT LEAVE THE OPERATION THAT MINTED IT. The fold's intent carries the +// LiveDeployApply effect it realized, so returning it would hand every caller an +// AdmittedLiveDeployApply to re-wrap; the reported intent replaces that effect with the +// warrant-free LiveDeployApplyReported, which realizes nowhere. The re-wrap itself stays +// expressible until coproduct variant construction can be sealed (gunbc.recurring_failure_mode +// modifier_accepted_on_a_shape_its_check_cannot_see); this makes the warrant unobtainable. +fn live_deploy_apply_reported( + r: Reconciliation, + spec: DeploymentSpec, + candidate: CandidateRelease, +) -> Reconciliation admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_member_plan_via_transport"), +] { + match r { + Converged { evidence: e, applied: i } => + Converged { evidence: e, applied: live_deploy_reported_intent(i: i, spec: spec, candidate: candidate) } + NotConverged { reason: why, applied: i } => + NotConverged { reason: why, applied: live_deploy_reported_intent(i: i, spec: spec, candidate: candidate) } + } +} + +fn live_deploy_reported_intent(i: HostEffectIntent, spec: DeploymentSpec, candidate: CandidateRelease) -> HostEffectIntent { + HostEffectIntent { + target: i.target, + effect: LiveDeployApplyReported { spec: spec, candidate: candidate }, + evidence: i.evidence, + transport: i.transport, + privilege_denial: i.privilege_denial, + } +} + fn live_deploy_apply_readiness_gate( spec: DeploymentSpec, candidate: CandidateRelease, @@ -420,12 +456,12 @@ fn live_deploy_apply_identity_readback_gate( } fn live_deploy_apply_via_transport( - spec: DeploymentSpec, + deployment: DesiredDeployment, candidate: CandidateRelease, transport: HostEffectTransport, access: DeployAccess ) -> Reconciliation { - match assess_and_admit(spec: spec, own: transport) { + match assess_and_admit(deployment: deployment, own: transport) { RoleSingletonNotAdmitted { cause } => NotConverged { reason: join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], ""), @@ -436,10 +472,10 @@ fn live_deploy_apply_via_transport( } // THE ROLE SINGLETONS ARE ASSESSED BEFORE ANYTHING ELSE: the desired (actuated) prod-role realization -// against an observation of every roster host, and the caller's spec is admitted only when its holding -// agrees. A move, a removal, an unobservable host, a multiplicity, a partial realization or a stale +// against an observation of every roster host, and the spec built from the deployment's bound roster +// row is admitted only when its holding agrees. A move, a removal, an unobservable host, a multiplicity, a partial realization or a stale // spec stops the line above, before any member is planned. From here on every step reads the ADMITTED -// spec, never the caller's. +// spec, which no caller supplied. fn live_deploy_apply_admitted_via_transport( admission: RoleSingletonAdmission, candidate: CandidateRelease, @@ -547,12 +583,12 @@ fn live_deploy_access_preflight() -> Reconciliation Reconciliation { live_deploy_apply_via_transport( - spec: spec, + deployment: deployment, candidate: candidate, transport: access.transport, access: access, @@ -564,7 +600,7 @@ fn live_deploy_apply_srv1_with_access( candidate: CandidateRelease, ) -> Reconciliation { live_deploy_apply_with_access( - spec: deployment_spec_srv1(), + deployment: srv1_production_desired_deployment(), access: access, candidate: candidate, ) @@ -637,19 +673,19 @@ fn live_deploy_reconciliation_exit(r: Reconciliation ProcessExit { match repository_transition_admission(realization: realization) { LegacyGitFileSyncNotAdmitted { reason: why } => exit_failure(reason: why) - RepositoryTransitionAdmitted => live_deploy_admitted_wet_with_access(spec: spec, access: access) + RepositoryTransitionAdmitted => live_deploy_admitted_wet_with_access(deployment: deployment, access: access) } } -fn live_deploy_wet_with_access(spec: DeploymentSpec, access: DeployAccess) -> ProcessExit { +fn live_deploy_wet_with_access(deployment: DesiredDeployment, access: DeployAccess) -> ProcessExit { live_deploy_wet_with_access_for_realization( - spec: spec, + deployment: deployment, access: access, realization: deployed_tree_repository_transition, ) @@ -662,12 +698,12 @@ fn live_deploy_wet_with_access(spec: DeploymentSpec, access: DeployAccess) -> Pr // ancestry, on the same grounds the repository-transition admission above states for itself: a // wall that also blocked teardown would strand an operator with a host they could neither // converge nor take down. -fn live_deploy_admitted_wet_with_access(spec: DeploymentSpec, access: DeployAccess) -> ProcessExit { +fn live_deploy_admitted_wet_with_access(deployment: DesiredDeployment, access: DeployAccess) -> ProcessExit { match observe_candidate_release() { CandidateUnobservable { cause: why } => exit_failure(reason: candidate_unobservable_detail(cause: why)) CandidateObserved { candidate: candidate } => - live_deploy_candidate_wet_with_access(spec: spec, access: access, candidate: candidate) + live_deploy_candidate_wet_with_access(deployment: deployment, access: access, candidate: candidate) } } @@ -679,7 +715,7 @@ fn live_deploy_admitted_wet_with_access(spec: DeploymentSpec, access: DeployAcce // zero-mutation plan rather than a short circuit, and a host whose tree drifted beneath an // unchanged announcement is repaired rather than skipped. fn live_deploy_candidate_wet_with_access( - spec: DeploymentSpec, + deployment: DesiredDeployment, access: DeployAccess, candidate: CandidateRelease, ) -> ProcessExit { @@ -688,7 +724,7 @@ fn live_deploy_candidate_wet_with_access( live_deploy_reconciliation_exit(r: preflight) } else { live_deploy_reconciliation_exit( - r: live_deploy_apply_with_access(spec: spec, access: access, candidate: candidate) + r: live_deploy_apply_with_access(deployment: deployment, access: access, candidate: candidate) ) } } @@ -1015,18 +1051,18 @@ fn approval_broker_dark_install_srv1_wet() -> ProcessExit { } fn live_deploy_apply_srv1_wet() -> ProcessExit { - live_deploy_wet_with_access(spec: deployment_spec_srv1(), access: ci_deploy_srv1_access) + live_deploy_wet_with_access(deployment: srv1_production_desired_deployment(), access: ci_deploy_srv1_access) } fn live_deploy_apply_srv1_operator_wet() -> ProcessExit { - live_deploy_wet_with_access(spec: deployment_spec_srv1(), access: ci_deploy_srv1_operator_access) + live_deploy_wet_with_access(deployment: srv1_production_desired_deployment(), access: ci_deploy_srv1_operator_access) } // The converge verb's deploy mode: the same admitted path the CI entry runs, under the operator // principal on the host itself. Candidate observation, preflight, mutation and the readiness // gate are all shared; nothing here is a second deploy. fn live_deploy_apply_srv1_operator_local_wet() -> ProcessExit { - live_deploy_wet_with_access(spec: deployment_spec_srv1(), access: ci_deploy_srv1_operator_local_access) + live_deploy_wet_with_access(deployment: srv1_production_desired_deployment(), access: ci_deploy_srv1_operator_local_access) } fn live_deploy_retract_srv1_wet() -> ProcessExit { @@ -1123,7 +1159,7 @@ fn live_deploy_transaction_receipt_for( } fn live_deploy_transaction_decided( - spec: DeploymentSpec, + deployment: DesiredDeployment, access: DeployAccess, run: ActionsRunBinding, expected: String, @@ -1131,6 +1167,7 @@ fn live_deploy_transaction_decided( candidate_tree: String, provenance: LiveDeployFleetProvenance, ) -> ProcessExit { + let spec = selected_deployment_spec(row: deployment) let preflight = live_deploy_access_preflight_for(access: access) if !reconciliation_converged(r: preflight) { live_deploy_write_transaction_receipt( @@ -1139,7 +1176,7 @@ fn live_deploy_transaction_decided( exit: live_deploy_reconciliation_exit(r: preflight), ) } else { - match assess_and_admit(spec: spec, own: access.transport) { + match assess_and_admit(deployment: deployment, own: access.transport) { RoleSingletonNotAdmitted { cause } => { let why = join(["live_deploy: ", role_singleton_refusal_detail(cause: cause)], "") live_deploy_write_transaction_receipt( @@ -1165,7 +1202,7 @@ fn live_deploy_transaction_decided( } } -fn live_deploy_transaction_admitted(spec: DeploymentSpec, access: DeployAccess, run: ActionsRunBinding, expected: String, provenance: LiveDeployFleetProvenance) -> ProcessExit { +fn live_deploy_transaction_admitted(deployment: DesiredDeployment, access: DeployAccess, run: ActionsRunBinding, expected: String, provenance: LiveDeployFleetProvenance) -> ProcessExit { match observe_candidate_release() { CandidateUnobservable { cause: why } => exit_failure(reason: candidate_unobservable_detail(cause: why)) @@ -1178,7 +1215,7 @@ fn live_deploy_transaction_admitted(spec: DeploymentSpec, access: DeployAccess, exit_failure(reason: join(["live_deploy transaction: CandidateTreeUnobservable — ", d], "")) CandidateTreeOidObserved { oid_hex: candidate_tree } => live_deploy_transaction_decided( - spec: spec, + deployment: deployment, access: access, run: run, expected: expected, @@ -1191,7 +1228,7 @@ fn live_deploy_transaction_admitted(spec: DeploymentSpec, access: DeployAccess, } } -fn live_deploy_transaction_admitted_wet_with_access(spec: DeploymentSpec, access: DeployAccess) -> ProcessExit { +fn live_deploy_transaction_admitted_wet_with_access(deployment: DesiredDeployment, access: DeployAccess) -> ProcessExit { match observe_actions_run_binding() { ActionsRunUnbound { variable: v, detail: d } => exit_failure(reason: join(["live_deploy transaction: RunUnbound — ", v, " ", d, "; the transaction entry runs only inside the fleet-converge workflow"], "")) @@ -1223,7 +1260,7 @@ fn live_deploy_transaction_admitted_wet_with_access(spec: DeploymentSpec, access plan_receipt_identity: fleet_converge_plan_receipt_identity_hex(r: plan_receipt), apply_receipt_identity: fleet_converge_apply_receipt_identity_hex(r: fleet_apply_receipt), } - live_deploy_transaction_admitted(spec: spec, access: access, run: run, expected: expected, provenance: provenance) + live_deploy_transaction_admitted(deployment: deployment, access: access, run: run, expected: expected, provenance: provenance) } } } @@ -1237,24 +1274,24 @@ fn live_deploy_transaction_admitted_wet_with_access(spec: DeploymentSpec, access // Keeping realization injectable at this internal seam makes the refusing arm executable in a // hermetic witness while the production entry below remains constructed from the one bound row. fn live_deploy_transaction_wet_with_access_for_realization( - spec: DeploymentSpec, + deployment: DesiredDeployment, access: DeployAccess, realization: RepositoryTransitionRealization, ) -> ProcessExit { match repository_transition_admission(realization: realization) { LegacyGitFileSyncNotAdmitted { reason: why } => exit_failure(reason: why) - RepositoryTransitionAdmitted => live_deploy_transaction_admitted_wet_with_access(spec: spec, access: access) + RepositoryTransitionAdmitted => live_deploy_transaction_admitted_wet_with_access(deployment: deployment, access: access) } } -fn live_deploy_transaction_wet_with_access(spec: DeploymentSpec, access: DeployAccess) -> ProcessExit { +fn live_deploy_transaction_wet_with_access(deployment: DesiredDeployment, access: DeployAccess) -> ProcessExit { live_deploy_transaction_wet_with_access_for_realization( - spec: spec, + deployment: deployment, access: access, realization: deployed_tree_repository_transition, ) } fn live_deploy_apply_srv1_transaction_wet() -> ProcessExit { - live_deploy_transaction_wet_with_access(spec: deployment_spec_srv1(), access: ci_deploy_srv1_access) + live_deploy_transaction_wet_with_access(deployment: srv1_production_desired_deployment(), access: ci_deploy_srv1_access) } diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index 07ae98bcc24..e22fe1bc187 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -29,7 +29,7 @@ import extdeps.access.posix { file_mode_octal } import extdeps.shell import std.algebra { trim } import std.types { FilePath } -import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization } +import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization, selected_deployment_spec, bind_desired_selected_identity, DesiredSelectedIdentityBound, DesiredSelectedIdentityUnknownInstance, DesiredSelectedIdentityRosterRefused, DesiredSelectedIdentityMismatch, instance_id_text } import gunbc.live_deploy.member_observe { ObservationArm, OnTheHostItself, @@ -72,7 +72,8 @@ import gunbc.live_deploy.member_observe { // ── What can be seen ──────────────────────────────────────────────────────────────────────────── // THE MEMBERS a realization is made of. The broker unit is host-global and names no deployment; the // fabric store unit and the store's ownership marker each name exactly one roster deployment. The -// marker is read because the store root, auth state and door directories are Ensured and no retract +// marker (in the store's control directory, read by the deploy job principal) is read because the +// store root, auth state and door directories are Ensured and no retract // removes them: an old store with no unit is still a store. type RoleSingletonMember = BrokerUnitMember @@ -114,6 +115,7 @@ type RoleSingletonRefusal | RoleSingletonUnmarked { deployment: DeploymentId, host: NonEmptyStr, marker: FabricStorageOwnershipMarkerSpec } | RoleSingletonMarkerMismatched { deployment: DeploymentId, host: NonEmptyStr, field: NonEmptyStr, expected: String, observed: String } | RoleSingletonStaleSpec { instance_id: NonEmptyStr, detail: String } + | RoleSingletonDeploymentUnrostered { instance_id: String, detail: String } // ── What is decided ───────────────────────────────────────────────────────────────────────────── type RoleSingletonConvergence @@ -310,7 +312,7 @@ fn role_singleton_convergence(desired: ProdRoleRealization, observed: RoleSingle // ── The admission: the decision bound to the spec that acts ───────────────────────────────────── // THE APPLY CONSUMES THIS TOKEN, NEVER THE CALLER'S SPEC. sole_constructor, and its one mint -// (mint_role_singleton_admission) is sealed to the observing assessor assess_and_admit. The spec inside is the one the assessment was made against, and it is admitted only when +// (mint_role_singleton_admission) is sealed to assess_and_admit_rostered, reached only from assess_and_admit over a bound roster row. The spec inside is the one the assessment was made against, and it is admitted only when // its own holding agrees with the desired realization: a spec built before the role moved, a holding // spec under NoProdRole, or a non-holder spec while the holder's first install is pending all refuse // before any member is planned. @@ -383,7 +385,7 @@ fn classify_role_singleton_admission(spec: DeploymentSpec, desired: ProdRoleReal // THE ONLY MINT, sealed to the observing assessor: no caller can hand it a fabricated decision or a // noncanonical desired realization, because no other caller may reach it. fn mint_role_singleton_admission(spec: DeploymentSpec, decision: RoleSingletonConvergence) -> RoleSingletonAdmission admit_callers: [ - decl_ref(module_path: "gunbc.live_deploy.role_singleton_convergence", decl_name: "assess_and_admit"), + decl_ref(module_path: "gunbc.live_deploy.role_singleton_convergence", decl_name: "assess_and_admit_rostered"), ] { RoleSingletonAdmission { spec: spec, decision: decision } } @@ -411,11 +413,13 @@ fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { RoleSingletonPartial { deployment, host, missing } => join(["role singletons refused: ", deployment.name as String, " on ", host as String, " is partial (missing ", to_string(count(missing)), " member(s)); an interrupted install or a store without its broker is not adopted"], "") RoleSingletonUnmarked { deployment, host, marker } => - join(["role singletons refused: ", deployment.name as String, " on ", host as String, " has its broker and fabric store unit but no store ownership marker, so whether that store is this holder's cannot be established. The operator's one-time disposition: on ", host as String, " write ", marker.path as String, " with mode ", file_mode_octal(mode: marker.mode), ", owner ", marker.owner as String, ", group ", marker.group as String, " and exactly this content: ", marker.content as String], "") + join(["role singletons refused: ", deployment.name as String, " on ", host as String, " has its broker and fabric store unit but no store ownership marker, so whether that store is this holder's cannot be established. The operator's one-time disposition: on ", host as String, ", in the store's control directory (created first with the mode, owner and group gunbc.fabric_storage_placement fabric_storage_control_directory derives), write ", marker.path as String, " with mode ", file_mode_octal(mode: marker.mode), ", owner ", marker.owner as String, ", group ", marker.group as String, " and exactly this content: ", marker.content as String], "") RoleSingletonMarkerMismatched { deployment, host, field, expected, observed } => join(["role singletons refused: the store ownership marker for ", deployment.name as String, " on ", host as String, " does not match its row: ", field as String, " is '", observed, "', expected '", expected, "'; a marker that differs in any field is not the marker"], "") RoleSingletonStaleSpec { instance_id, detail } => join(["role singletons refused: spec ", instance_id as String, " is not admitted: ", detail], "") + RoleSingletonDeploymentUnrostered { instance_id, detail } => + join(["role singletons refused: deployment ", instance_id, " does not bind to its desired_deployment_roster row: ", detail], "") } } @@ -489,10 +493,24 @@ type MarkerSighting fn marker_sighting(host: NonEmptyStr, r: DesiredDeployment, arm: ObservationArm) -> MarkerSighting { let spec = fabric_storage_ownership_marker_spec(instance: r.instance) - match entry_presence(arm: arm, path: spec.path as String) { + marker_sighting_of( + host: host, + r: r, + presence: entry_presence(arm: arm, path: spec.path as String), + read_back: path => read_marker(arm: arm, path: path), + ) +} + +// THE THREE OUTCOMES, decided from what the observer's own legs returned: the marker exactly as its row +// (a sighting), no marker (a successful listing of the control directory without it), or a marker the +// observer cannot read (a listing or readback that failed -- permission denied included), which makes +// the host unobservable and is never read as absent. +fn marker_sighting_of(host: NonEmptyStr, r: DesiredDeployment, presence: EntryPresence, read_back: fn(NonEmptyStr) -> MarkerRead) -> MarkerSighting { + let spec = fabric_storage_ownership_marker_spec(instance: r.instance) + match presence { EntryPresenceIndeterminate { cause } => MarkerSightingUnobservable { cause: cause } EntryAbsent => MarkerSightingAbsent - EntryPresent => match read_marker(arm: arm, path: spec.path) { + EntryPresent => match read_back(spec.path) { MarkerReadRefused { cause } => MarkerSightingUnobservable { cause: cause } MarkerReadBack { reading } => match fabric_storage_marker_verdict(spec: spec, reading: reading) { FabricStorageMarkerExact => MarkerSightingOf { sighting: SightedDeploymentMember { host: host, deployment: r.instance.deployment, member: StoreMarkerMember } } @@ -549,9 +567,25 @@ fn observe_role_singletons_with(source: RoleSingletonReadingSource, rows: List RoleSingletonAdmissionResult admit_callers: [ +fn assess_and_admit(deployment: DesiredDeployment, own: HostEffectTransport) -> RoleSingletonAdmissionResult admit_callers: [ decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_apply_via_transport"), decl_ref(module_path: "gunbc.live_deploy.apply", decl_name: "live_deploy_transaction_decided"), +] { + match bind_desired_selected_identity(desired: deployment) { + DesiredSelectedIdentityUnknownInstance { instance_id } => + RoleSingletonNotAdmitted { cause: RoleSingletonDeploymentUnrostered { instance_id: instance_id, detail: "no roster row carries this instance id" } } + DesiredSelectedIdentityRosterRefused { refusal: _ } => + RoleSingletonNotAdmitted { cause: RoleSingletonDeploymentUnrostered { instance_id: instance_id_text(id: deployment.instance.instance_id), detail: "the desired roster itself refused" } } + DesiredSelectedIdentityMismatch { carried: _, rostered: _ } => + RoleSingletonNotAdmitted { cause: RoleSingletonDeploymentUnrostered { instance_id: instance_id_text(id: deployment.instance.instance_id), detail: "the carried selected identity is not the rostered one" } } + DesiredSelectedIdentityBound { row } => assess_and_admit_rostered(spec: selected_deployment_spec(row: row), own: own) + } +} + +// THE SPEC IS BUILT, NEVER SUPPLIED: the only caller is assess_and_admit above, which derives it from +// the bound roster row, so an altered DeploymentSpec has no route to the observation or the mint. +fn assess_and_admit_rostered(spec: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonAdmissionResult admit_callers: [ + decl_ref(module_path: "gunbc.live_deploy.role_singleton_convergence", decl_name: "assess_and_admit"), ] { let rows = desired_deployment_roster() let desired = prod_role_actuated_realization() diff --git a/dag/gunbc/managed_directory.dag b/dag/gunbc/managed_directory.dag index 0e0738d9efb..7b49481e0dd 100644 --- a/dag/gunbc/managed_directory.dag +++ b/dag/gunbc/managed_directory.dag @@ -217,6 +217,18 @@ fn managed_directory_permissions(d: ManagedDirectory) -> FilePermissions { } } +// WHETHER A DIRECTORY'S DERIVED MODE LETS A PRINCIPAL LIST AND TRAVERSE IT, by the class that principal +// falls in. The fabric store's marker witness asks it of the store's control directory and of its store root with the real observer. +fn managed_directory_admits_reader(d: ManagedDirectory, who: PosixUser) -> Bool { + let mode = managed_directory_permissions(d: d).mode + let bits = match dependent_class_of(owner: d.owner, group_gid: d.group_principal.gid, who: who) { + OwnerClass => mode.owner + GroupClass => mode.group + OtherClass => mode.other + } + bits.read && bits.execute +} + // ── THE ENTRIES' MODE, DERIVED FROM THE SAME DEPENDENTS ────────────────────────────────────────── // // An entry is owned by the dependent that CREATED it, not by the directory's owner, and its group is diff --git a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag index 6810a47fb78..0c4a81cc7d8 100644 --- a/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag +++ b/dag/gunbc/recurring_failure_mode/a_test_deployment_acts_on_the_prod_deployments_state.dag @@ -24,7 +24,7 @@ data a_test_deployment_acts_on_the_prod_deployments_state: RecurringFailureMode "PINS REPOINTED BY DELIVERABLE 2: the role-following singletons follow gunbc.roadmap_dashboard_instance prod_role_realization -- one lookup, the instance roster filtered by its own deployment field -- which REFUSES with a typed ProdRoleRealizationRefusal under NoProdRole, an unrealized holder or an ambiguous one, and mints the only holder token (ProdRoleHolder, sole_constructor). A spec carries that token as gunbc.live_deploy.spec RoleSingletonHolding and every singleton member -- the approval broker unit and front door, the fabric store unit, route, socket, root and directories -- is built from it, never from a caller-spelled instance_id, so they move between targets as one subject. The apply first assesses the desired realization against an observation of every roster host (gunbc.live_deploy.role_singleton_convergence): Noop, Install only when nothing is observed anywhere, and a typed refusal for a move, a removal, an unobservable host, two holders or an ownerless broker. The approval client origin, the GCP IAM converge job's runner (gunbc.fleet_converge_workflow fleet_converge_gcp_iam_converge_job_under, refused under NoProdRole), the approvals app's host (gunbc.auth.approval_broker_placement) and the apply's protected siblings (dashboard_protected_siblings) follow the same realization; broker-side modules receive their host at serve time rather than resolving the role. The REDs are in test.claim.deployment_risk_witness_test at the spec, convergence and emitted-job boundaries.", - "OPERATOR DISPOSITION OWED, ONE-TIME: today's prod-role holder (srv1-daily-workspace, realized on srv1) predates the fabric store ownership marker, so the role-singleton assessment refuses it as RoleSingletonUnmarked until the operator writes, on srv1, /opt/gunbc/fabric-storage/.gunbc-dashboard-instance-srv1-live (mode 0644, owner briansrls) containing exactly 'deployment=srv1-daily-workspace' newline 'host=srv1' newline -- the content gunbc.fabric_storage_placement fabric_storage_ownership_marker_content derives and the refusal text quotes. No automated adoption exists (gunbc#13124: manual resume).", + "OPERATOR DISPOSITION OWED, ONE-TIME: today's prod-role holder (srv1-daily-workspace, realized on srv1) predates the fabric store ownership marker, so the role-singleton assessment refuses it as RoleSingletonUnmarked until the operator creates, on srv1, the control directory /opt/gunbc/fabric-storage-control (mode 2750, owner briansrls, group ghrunner, as gunbc.fabric_storage_placement fabric_storage_control_directory derives) and writes in it /opt/gunbc/fabric-storage-control/.gunbc-dashboard-instance-srv1-live (mode 0644, owner briansrls) containing exactly 'deployment=srv1-daily-workspace' newline 'host=srv1' newline -- the content gunbc.fabric_storage_placement fabric_storage_ownership_marker_content derives and the refusal text quotes. No automated adoption exists (gunbc#13124: manual resume).", "HONEST NON-PINS, not remaining debt: (a) the nullary serve, belt, dispatch and launch entry points that call srv1_live_dashboard_instance are srv1-live's OWN entry points (HostDashboardInstance.serve_function names roadmap_serve_handle for srv1-live and roadmap_serve_handle_ for every other instance, a binding test.claim.serve_function_binding_witness_test now checks roster-wide), so they must NOT follow the role -- following it would make srv1-live act on the new holder's state; (b) gunbc.fleet_reach_endpoint fleet_probe_remote_user_for's srv1/srv2 test is which hosts carry the gunbc-fleet account, a host-shared fact. NOT CLAIMED: that an unbound test deployment refuses everywhere -- roadmap_belt_actuate belt_dispatch_instance_cli still defaults an absent instance env to srv1-live, which stays with the belt owner's lane.", diff --git a/dag/gunbc/recurring_failure_mode/modifier_accepted_on_a_shape_its_check_cannot_see.dag b/dag/gunbc/recurring_failure_mode/modifier_accepted_on_a_shape_its_check_cannot_see.dag index ec8ccd0319d..5face44b334 100644 --- a/dag/gunbc/recurring_failure_mode/modifier_accepted_on_a_shape_its_check_cannot_see.dag +++ b/dag/gunbc/recurring_failure_mode/modifier_accepted_on_a_shape_its_check_cannot_see.dag @@ -22,6 +22,8 @@ data modifier_accepted_on_a_shape_its_check_cannot_see: RecurringFailureMode = R "**THE NEIGHBOUR IS `prose_asserts_an_enforcement_no_operation_can_produce` and the boundary is the carrier:** there the false claim is in prose a machine cannot read, here it is in SYNTAX THE COMPILER ACCEPTED, which is strictly more convincing and therefore strictly more dangerous. Against `discriminating_arm_built_but_never_enrolled`: there a real check exists and nothing calls it; here the check is called, runs, and answers the wrong question because its key does not match the shape.", "**RUNG AND CEILING FOR THE CLASS AS A CLASS**, which is all this row may speak to: the general mismatch between a modifier's accepted shapes and its enforcing pass's covered shapes is decidable per modifier and undecidable as a blanket property, so it stays a per-modifier obligation discharged where the modifier is minted. The specimen's own rung, ceiling and trigger are the parse site's to state and are not restated here.)", + + "**A CONSUMER THAT PAYS FOR THE SPECIMEN, not a second specimen (deployment-risk D2).** gunbc.host_effect `LiveDeployApply { warrant: AdmittedLiveDeployApply }` is a coproduct variant, so a warrant extracted from a HostEffect could be re-wrapped into a fresh LiveDeployApply anywhere and no compile refusal is authorable for it. D2 therefore makes the warrant UNOBTAINABLE rather than un-rewrappable: it is minted and realized inside one sealed operation and never returned or stored in an observable value, and the outside call of that operation is the compile RED. The residual re-wrap is this specimen, owned by the parse-site declaration above `v1.compiler.parse` `alias_rhs_is_anonymous_record`; its trigger is that declaration's capability -- coproduct variant construction wired through sole_constructor_construction_diags -- and when it lands, AdmittedLiveDeployApply's variant is sealed and the re-wrap probe becomes enrollable.", ], evidence: [], diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index 0ae3775ac76..a74ed8a1fc6 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -379,7 +379,7 @@ fn dashboard_instance_ownership_marker_path(instance: HostDashboardInstance) -> // THE OWNERSHIP MARKER'S ONE NAMING RULE: an empty file under a root, named for the instance that // owns the root. The instance root carries it (above), and so does the prod-role holder's fabric -// store root (gunbc.fabric_storage_placement fabric_storage_ownership_marker_path), so an old store +// store control directory (gunbc.fabric_storage_placement fabric_storage_ownership_marker_path), so an old store // with no unit is still observably someone's state rather than reading as nothing. fn dashboard_ownership_marker_in(root: FilePath, instance_id: NonEmptyStr) -> FilePath { dashboard_instance_child( diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index af16dac26c4..2a4fa687702 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -64,7 +64,7 @@ import gunbc.live_deploy.spec { deployment_owns_the_host_approval_broker, deployment_fabric_storage_door_socket, } -import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } +import gunbc.live_deploy.desired { srv1_production_desired_deployment, DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } import gunbc.live_deploy.role_singleton_convergence { RoleSingletonConvergence, @@ -86,6 +86,7 @@ import gunbc.live_deploy.role_singleton_convergence { SightedMismatchedMarker, role_singleton_refusal_detail, RoleSingletonStaleSpec, + RoleSingletonDeploymentUnrostered, RoleSingletonSighting, SightedDeploymentMember, SightedBroker, @@ -100,7 +101,18 @@ import gunbc.live_deploy.role_singleton_convergence { classify_role_singleton_admission, role_singleton_convergence, role_singleton_observation_of, -} + marker_sighting_of, + MarkerSighting, + MarkerSightingAbsent, + MarkerSightingOf, + MarkerSightingUnobservable, + MarkerRead, + MarkerReadBack, + MarkerReadRefused, +} +import gunbc.live_deploy.member_observe { EntryPresent, EntryAbsent, EntryPresenceIndeterminate } +import gunbc.managed_directory { managed_directory_admits_reader } +import gunbc.fleet_posix_accounts { srv1_ci_deploy_runner_principal } import gunbc.fleet_converge_workflow { fleet_converge_gcp_iam_converge_job_under } import gunbc.live_deploy.emit { approval_broker_base_url_environment, approval_broker_base_url_unbound_marker } import gunbc.auth.approval_broker_endpoint { approval_broker_base_url_env_name, approval_broker_serving_base_url_of, ApprovalBrokerAddressResolved, ApprovalBrokerAddressRefused } @@ -119,6 +131,9 @@ import gunbc.fabric_storage_placement { FabricStorageMarkerReading, FabricStorageMarkerExact, FabricStorageMarkerMismatched, + fabric_storage_control_directory, + fabric_storage_store_root_directory, + fabric_storage_marker_observer, } import gunbc.auth.approval_request_client { ApprovalLoopbackOriginResolved, @@ -359,6 +374,7 @@ fn refused_with(c: RoleSingletonConvergence, label: String) -> Bool { RoleSingletonPartial { deployment: _, host: _, missing: _ } => label == "partial" RoleSingletonUnmarked { deployment: _, host: _, marker: _ } => label == "unmarked" RoleSingletonMarkerMismatched { deployment: _, host: _, field: _, expected: _, observed: _ } => label == "marker-mismatch" + RoleSingletonDeploymentUnrostered { instance_id: _, detail: _ } => label == "unrostered" RoleSingletonStaleSpec { instance_id: _, detail: _ } => label == "stale" } RoleSingletonNoop => false @@ -630,7 +646,7 @@ test fn an_unmarked_legacy_store_refuses_naming_the_operators_disposition() -> B // carry one into an apply step later -- each refused at compile time as a located diagnostic. data forged_admission_source: String = "module probe_forged_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { mint_role_singleton_admission, RoleSingletonNoop, RoleSingletonAdmission }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nfn forge() -> RoleSingletonAdmission { mint_role_singleton_admission(spec: deployment_spec_srv1(), decision: RoleSingletonNoop) }\n" -data obtained_admission_source: String = "module probe_obtained_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn obtain() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), own: LocalShell) }\n" +data obtained_admission_source: String = "module probe_obtained_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { srv1_production_desired_deployment }\nimport gunbc.host_effect { LocalShell }\nfn obtain() -> RoleSingletonAdmissionResult { assess_and_admit(deployment: srv1_production_desired_deployment(), own: LocalShell) }\n" data outside_member_plan_source: String = "module probe_outside_member_plan\nimport gunbc.live_deploy.apply { live_deploy_apply_member_plan_via_transport }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.host_effect { LocalShell }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn bypass(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_member_plan_via_transport(spec: deployment_spec_srv1(), candidate: c, transport: LocalShell, access: a)\n true }\n" @@ -638,7 +654,11 @@ data outside_warrant_mint_source: String = "module probe_outside_warrant_mint\ni data outside_warrant_literal_source: String = "module probe_outside_warrant_literal\nimport gunbc.live_deploy.apply_warrant { AdmittedLiveDeployApply }\nimport gunbc.host_effect { HostEffect, LiveDeployApply }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.live_deploy.member_identity { ReleaseEffectPlan }\nfn forge(c: CandidateRelease, p: ReleaseEffectPlan) -> HostEffect { LiveDeployApply { warrant: AdmittedLiveDeployApply { spec: deployment_spec_srv1(), candidate: c, plan: p } } }\n" -data canonical_apply_source: String = "module probe_canonical_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn act(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(spec: deployment_spec_srv1(), access: a, candidate: c)\n true }\n" +data canonical_apply_source: String = "module probe_canonical_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { srv1_production_desired_deployment }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn act(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(deployment: srv1_production_desired_deployment(), access: a, candidate: c)\n true }\n" + +data altered_spec_to_assessor_source: String = "module probe_altered_spec_to_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit_rostered, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn alter() -> RoleSingletonAdmissionResult { let s = deployment_spec_srv1()\n assess_and_admit_rostered(spec: s, own: LocalShell) }\n" + +data spec_to_production_apply_source: String = "module probe_spec_to_production_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn act(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(spec: deployment_spec_srv1(), access: a, candidate: c)\n true }\n" data replayed_admission_source: String = "module probe_replayed_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { RoleSingletonAdmission, admitted_spec }\nimport gunbc.live_deploy.spec { DeploymentSpec }\nfn replay(a: RoleSingletonAdmission) -> DeploymentSpec { admitted_spec(admission: a) }\n" @@ -649,6 +669,23 @@ fn admission_refusals_in(source: String) -> Int { } } +fn class_refusals_in(source: String, wanted: String) -> Int { + match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: wanted) { + Present { value: n } => n + Absent => 0 + } +} + +// NO CALLER-SUPPLIED SPEC REACHES THE ASSESSOR. The production apply takes a DesiredDeployment and +// the spec is built from its bound roster row inside assess_and_admit; (a) handing a spec to the +// production entry does not type (it has no spec parameter), and (b) handing one to the +// spec-taking assessor step is refused at its seal, while (c) the canonical row-taking call compiles. +test fn no_caller_supplied_spec_reaches_the_role_singleton_assessment() -> Bool { + class_refusals_in(source: spec_to_production_apply_source, wanted: "CallArgumentNameUnknown") >= 1 + && admission_refusals_in(source: altered_spec_to_assessor_source) >= 1 + && admission_refusals_in(source: canonical_apply_source) == 0 +} + test fn no_outside_module_can_mint_obtain_or_replay_an_admission() -> Bool { admission_refusals_in(source: forged_admission_source) >= 1 && admission_refusals_in(source: obtained_admission_source) >= 1 @@ -704,3 +741,46 @@ test fn every_marker_field_is_read_back_and_any_mismatch_refuses() -> Bool { && mismatched_on(m: m, r: FabricStorageMarkerReading { path: r.path, content: r.content, mode: r.mode, owner: r.owner, group: "root" }, field: "group") && refused_with(c: decide(desired: prod_role_actuated_realization(), readings: mismatch_world), label: "marker-mismatch") } + +// ── The marker is readable by the principal that observes it, from a directory that is not the store ─ +// THE READ AUTHORITY, with the real observer and the derived modes: the observer is the deploy job +// principal (srv1_ci_deploy_runner_principal job_user), the control directory's derived mode admits it +// to list and traverse, and the store root's derived mode does not -- which is why the marker is not in +// the store root, and why the store root was not widened to admit it. +test fn the_marker_observer_reads_the_control_directory_and_not_the_store_root() -> Bool { + let i = srv1_live_dashboard_instance() + (fabric_storage_marker_observer.name as String) == (srv1_ci_deploy_runner_principal.job_user as String) + && managed_directory_admits_reader(d: fabric_storage_control_directory(instance: i), who: fabric_storage_marker_observer) + && !managed_directory_admits_reader(d: fabric_storage_store_root_directory(instance: i), who: fabric_storage_marker_observer) +} + +// THREE OUTCOMES, NEVER TWO: the observer's exact readback is the holder's marker, a listing without it +// is absent, and a listing or readback the observer is denied is unobservable -- never absent. +fn exact_marker_read(path: NonEmptyStr) -> MarkerRead { + MarkerReadBack { reading: exact_reading(m: fabric_storage_ownership_marker_spec(instance: srv1_live_dashboard_instance())) } +} + +fn denied_marker_read(path: NonEmptyStr) -> MarkerRead { + MarkerReadRefused { cause: join([path as String, ": Permission denied"], "") } +} + +test fn the_marker_readback_has_three_distinct_outcomes() -> Bool { + let row = srv1_production_desired_deployment() + let exact = match marker_sighting_of(host: "srv1" as NonEmptyStr, r: row, presence: EntryPresent, read_back: exact_marker_read) { + MarkerSightingOf { sighting: SightedDeploymentMember { host: _, deployment: d, member: StoreMarkerMember } } => d == srv1_daily_workspace_deployment + _ => false + } + let absent = match marker_sighting_of(host: "srv1" as NonEmptyStr, r: row, presence: EntryAbsent, read_back: exact_marker_read) { + MarkerSightingAbsent => true + _ => false + } + let unlistable = match marker_sighting_of(host: "srv1" as NonEmptyStr, r: row, presence: EntryPresenceIndeterminate { cause: "listing /opt/gunbc/fabric-storage-control: Permission denied" }, read_back: exact_marker_read) { + MarkerSightingUnobservable { cause: _ } => true + _ => false + } + let unreadable = match marker_sighting_of(host: "srv1" as NonEmptyStr, r: row, presence: EntryPresent, read_back: denied_marker_read) { + MarkerSightingUnobservable { cause: _ } => true + _ => false + } + exact && absent && unlistable && unreadable +} diff --git a/dag/test/claim/repository_transition_admission_witness_test.dag b/dag/test/claim/repository_transition_admission_witness_test.dag index 908f4b80e27..4c9317d103a 100644 --- a/dag/test/claim/repository_transition_admission_witness_test.dag +++ b/dag/test/claim/repository_transition_admission_witness_test.dag @@ -6,7 +6,7 @@ import gunbc.live_deploy.apply { live_deploy_wet_with_access_for_realization, live_deploy_transaction_wet_with_access_for_realization, } -import gunbc.live_deploy.desired { deployment_spec_srv1 } +import gunbc.live_deploy.desired { srv1_production_desired_deployment } import gunbc.ci_deploy_access { ci_deploy_srv1_access, ci_deploy_srv1_operator_access } import gunbc.live_deploy.deployed_tree_scope { RepositoryTransitionRealization, @@ -84,12 +84,12 @@ fn exit_refusal_reason(e: ProcessExit) -> String { test fn both_direct_apply_principals_consult_repository_transition_admission() -> Bool { let expected = refusal_reason(a: repository_transition_admission(realization: LegacyGitFileSync)) exit_refusal_reason(e: live_deploy_wet_with_access_for_realization( - spec: deployment_spec_srv1(), + deployment: srv1_production_desired_deployment(), access: ci_deploy_srv1_access, realization: LegacyGitFileSync, )) == expected && exit_refusal_reason(e: live_deploy_wet_with_access_for_realization( - spec: deployment_spec_srv1(), + deployment: srv1_production_desired_deployment(), access: ci_deploy_srv1_operator_access, realization: LegacyGitFileSync, )) == expected @@ -97,7 +97,7 @@ test fn both_direct_apply_principals_consult_repository_transition_admission() - test fn receipted_transaction_ingress_consults_repository_transition_admission() -> Bool { exit_refusal_reason(e: live_deploy_transaction_wet_with_access_for_realization( - spec: deployment_spec_srv1(), + deployment: srv1_production_desired_deployment(), access: ci_deploy_srv1_access, realization: LegacyGitFileSync, )) == refusal_reason(a: repository_transition_admission(realization: LegacyGitFileSync)) diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index eefaf908bdf..7fac1db3226 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -23,7 +23,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - Declared frontier (review 74937; merry-tern-58 ruling 2026-10-04): `DeploymentRiskInterface`, `DeploymentHarmMagnitudeBet` and the operator-disposition gate are **deferred until an operator policy differs by class**. Today's policy (gunbc#13124: automatic repair is "no" for every deployment) is class-independent, so a gate consulting the class would decide nothing (§3c decoration), and a class-dependent policy is the operator's call. No carrier is minted before then. Design note for when the bet lands: its magnitude is an order of magnitude over `Duration` (today `std.types` `Duration`; std has no order-of-magnitude carrier yet), e.g. a decade exponent beside its unit, typed with `extdeps.external_authority` `CitedFigureStanding`, and it never decides the class. 1b. **Machine qualification migration**: one motion over machine_intake (re-census on main first; coordinate warm-crane-577's mtcollins1 untangle lane). eager-gull-22 handed it to this lane. 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. - - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous` | `ProdRoleHolderUnactuatable`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). **Actuatability join:** `gunbc.live_deploy.desired` `prod_role_actuated_realization` joins the holder to the one `desired_deployment_roster`; a holder with no row (MacBook, srv2-preview, srv2's lab) refuses as `ProdRoleHolderUnactuatable`. Every role follower reads the joined realization. A spec carries the token as `gunbc.live_deploy.spec` `RoleSingletonHolding`, and the singleton members -- broker unit and front door, plus fabric store unit, route, socket, root, directories and **ownership marker** -- are built from it and move between targets as one subject. The marker is the dashboard ownership-marker rule (`dashboard_ownership_marker_in`) applied to the store root, so an old store with no unit stays observable. The broker is GIVEN its address (`GUNBC_APPROVAL_BROKER_BASE_URL`, from `gunbc.auth.approval_broker_placement`); broker-side modules never resolve the role (#13132's closure). Also following the realization: approval client origin, the GCP IAM job's runner, the approvals app host, `DashboardProtectedSibling`. + - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous` | `ProdRoleHolderUnactuatable`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). **Actuatability join:** `gunbc.live_deploy.desired` `prod_role_actuated_realization` joins the holder to the one `desired_deployment_roster`; a holder with no row (MacBook, srv2-preview, srv2's lab) refuses as `ProdRoleHolderUnactuatable`. Every role follower reads the joined realization. A spec carries the token as `gunbc.live_deploy.spec` `RoleSingletonHolding`, and the singleton members -- broker unit and front door, plus fabric store unit, route, socket, root, directories and **ownership marker** -- are built from it and move between targets as one subject. The marker is the dashboard ownership-marker rule (`dashboard_ownership_marker_in`) applied to the store's control directory `fabric-storage-control` (not the store root, whose derived mode admits only the service), whose derived mode admits the observing deploy job principal to read it, so an old store with no unit stays observable. The broker is GIVEN its address (`GUNBC_APPROVAL_BROKER_BASE_URL`, from `gunbc.auth.approval_broker_placement`); broker-side modules never resolve the role (#13132's closure). Also following the realization: approval client origin, the GCP IAM job's runner, the approvals app host, `DashboardProtectedSibling`. - Role-singleton convergence (`gunbc.live_deploy.role_singleton_convergence`): - **Observation.** It sits behind `RoleSingletonReadingSource`: an ssh probe today, host self-reports later, and which one production uses is still owed by the operator. Sightings are kept at (host, deployment, member) until multiplicity is adjudicated, and every roster deployment's broker, fabric unit and marker are probed on every roster host. - **Decision.** Possible outcomes: @@ -32,16 +32,18 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli - `RoleSingletonRefused`, with one of these causes: move, removal, `RoleSingletonDesiredRealizationRefused`, unmarked, unobservable, two-hosts, misplaced, two-on-one, ambiguous, broker-without-store, partial, stale-spec. Only `ProdRoleUnheld` means no prod. - - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec. Its mint is sealed (`admit_callers`) to the canonical assessor `assess_and_admit(spec, own)`, which reads the actuated desired realization and the ssh observation itself. The assessor, the token's readers (`admitted_spec`, `admitted_decision`) and every admitted apply step are in turn sealed to the two canonical assess-and-act compositions (`live_deploy_apply_via_transport`, `live_deploy_transaction_decided`). So a token can be neither forged, obtained elsewhere nor replayed later. Fixtures use the pure `classify_role_singleton_admission`. The apply receipt records `role_singleton_disposition` (Noop, Installed or RefusedBeforeApply). + - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec. Its mint is sealed (`admit_callers`) to the canonical assessor `assess_and_admit(deployment, own)`, which takes a `DesiredDeployment`, binds it to its `desired_deployment_roster` row (refusing `RoleSingletonDeploymentUnrostered` otherwise), builds the spec from that row itself -- no caller-supplied `DeploymentSpec` reaches it -- and reads the actuated desired realization and the ssh observation itself. The assessor, the token's readers (`admitted_spec`, `admitted_decision`) and every admitted apply step are in turn sealed to the two canonical assess-and-act compositions (`live_deploy_apply_via_transport`, `live_deploy_transaction_decided`). So a token can be neither forged, obtained elsewhere nor replayed later. Fixtures use the pure `classify_role_singleton_admission`. The apply receipt records `role_singleton_disposition` (Noop, Installed or RefusedBeforeApply). - **Unmarked legacy store (no automated adoption).** A realization with its broker and fabric unit but no store ownership marker refuses as `RoleSingletonUnmarked`. Today's srv1 holder predates the marker, so it is the expected case. Under the manual-resume ruling (#13124) it waits for the operator, and the refusal text names exactly what to write. **Operator's one-time disposition:** - - on `srv1`, write `/opt/gunbc/fabric-storage/.gunbc-dashboard-instance-srv1-live`; + - on `srv1`, create the control directory `/opt/gunbc/fabric-storage-control` with the mode, owner and group derived by `gunbc.fabric_storage_placement` `fabric_storage_control_directory` (2750, owner briansrls, group ghrunner); + - in it, write `.gunbc-dashboard-instance-srv1-live`; - mode 0644, owner briansrls; - content exactly `deployment=srv1-daily-workspace` + newline + `host=srv1` + newline. This is the same path and content the holder spec's own marker ensure writes. After it, the next apply observes a complete realization and converges as Noop. - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. + - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. **Reach is not sufficient: the read authority is named.** The observer is the deploy job principal (`gunbc.fabric_storage_placement` `fabric_storage_marker_observer` = `srv1_ci_deploy_runner_principal` job_user, ghrunner), and the marker lives in the control directory whose derived mode admits it to list and traverse; the store root's derived mode does not, and is not widened. A cross-host ssh login must land as that principal; one that lands as another account and is denied reads as unobservable and refuses, never as absent. + - **Warrant.** The member-plan step mints `AdmittedLiveDeployApply`, realizes it, and returns the intent with the warrant replaced by `LiveDeployApplyReported`, so no value returned or stored outside that sealed step carries a warrant. The residual -- re-wrapping an extracted warrant into a fresh `LiveDeployApply` -- is not authorable as a compile RED because coproduct variant construction cannot be sealed; it is recorded on `gunbc.recurring_failure_mode` `modifier_accepted_on_a_shape_its_check_cannot_see`, trigger: coproduct variant construction wired through `sole_constructor_construction_diags` (declared at `v1.compiler.parse` `alias_rhs_is_anonymous_record`). - Honest non-pins (not debt): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`, now checked roster-wide by `test.claim.serve_function_binding_witness_test`), so they must not follow the role; `fleet_reach_endpoint` is a host-shared account fact. Not claimed: that an unbound test deployment refuses everywhere — `belt_dispatch_instance_cli`'s absent-env default stays with the belt owner's lane. 3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. 4. **One spec per deployment**; retire the `live_deploy.desired` rung drop by its own trigger. Renames: `srv1_production_desired_deployment → srv1_primary_desired_deployment`, `roadmap_belt_production_spawn_mode → roadmap_belt_operational_spawn_mode`. From 947702b09e2b32e4cbe97b69f407fe94c2d06bd2 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 00:01:33 +0000 Subject: [PATCH 36/48] deployment_risk witness: the control directory admits the marker observer as its group and grants the other class nothing Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/test/claim/deployment_risk_witness_test.dag | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 2a4fa687702..08f82245d8f 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -111,7 +111,7 @@ import gunbc.live_deploy.role_singleton_convergence { MarkerReadRefused, } import gunbc.live_deploy.member_observe { EntryPresent, EntryAbsent, EntryPresenceIndeterminate } -import gunbc.managed_directory { managed_directory_admits_reader } +import gunbc.managed_directory { managed_directory_admits_reader, managed_directory_permissions } import gunbc.fleet_posix_accounts { srv1_ci_deploy_runner_principal } import gunbc.fleet_converge_workflow { fleet_converge_gcp_iam_converge_job_under } import gunbc.live_deploy.emit { approval_broker_base_url_environment, approval_broker_base_url_unbound_marker } @@ -746,11 +746,15 @@ test fn every_marker_field_is_read_back_and_any_mismatch_refuses() -> Bool { // THE READ AUTHORITY, with the real observer and the derived modes: the observer is the deploy job // principal (srv1_ci_deploy_runner_principal job_user), the control directory's derived mode admits it // to list and traverse, and the store root's derived mode does not -- which is why the marker is not in -// the store root, and why the store root was not widened to admit it. +// the store root, and why the store root was not widened to admit it. The observer is admitted as the +// directory's GROUP, so the derived mode grants the other class nothing: the marker is not published +// to every account on the host. test fn the_marker_observer_reads_the_control_directory_and_not_the_store_root() -> Bool { let i = srv1_live_dashboard_instance() (fabric_storage_marker_observer.name as String) == (srv1_ci_deploy_runner_principal.job_user as String) && managed_directory_admits_reader(d: fabric_storage_control_directory(instance: i), who: fabric_storage_marker_observer) + && !managed_directory_permissions(d: fabric_storage_control_directory(instance: i)).mode.other.read + && !managed_directory_permissions(d: fabric_storage_control_directory(instance: i)).mode.other.execute && !managed_directory_admits_reader(d: fabric_storage_store_root_directory(instance: i), who: fabric_storage_marker_observer) } From c07cf7edfa80cdb70d21a2c6d226ca67f8af5d1c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 02:09:53 +0000 Subject: [PATCH 37/48] D2: cross-host observation is host self-report (operator ruling 2026-10-05); the ssh probe is documented as a refusing fallback that never reads as absent Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/live_deploy/role_singleton_convergence.dag | 4 +++- docs/plans/deployment-risk-conformance.md | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index e22fe1bc187..f18db73844c 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -424,7 +424,9 @@ fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { } // ── The ssh probe reading source ──────────────────────────────────────────────────────────────── -// Each roster host is read over the transport that reaches it from the applying host: the apply's own +// A REFUSING FALLBACK, not the ruled realization: cross-host observation is host self-report (operator, +// 2026-10-05) and no cross-host ssh is granted, so a remote leg refuses and the host is unobservable -- +// absence is only ever a successful listing, so an ungranted probe never reads as absent. Each roster host is read over the transport that reaches it from the applying host: the apply's own // transport for the host it is applying to, ssh to the target's declared ssh_host for every other // roster host. EVERY roster deployment's members are probed on EVERY host -- probing only where a // deployment is targeted is what made a realization on the wrong host invisible. A host whose legs diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index 7fac1db3226..abaad565908 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -25,7 +25,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous` | `ProdRoleHolderUnactuatable`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). **Actuatability join:** `gunbc.live_deploy.desired` `prod_role_actuated_realization` joins the holder to the one `desired_deployment_roster`; a holder with no row (MacBook, srv2-preview, srv2's lab) refuses as `ProdRoleHolderUnactuatable`. Every role follower reads the joined realization. A spec carries the token as `gunbc.live_deploy.spec` `RoleSingletonHolding`, and the singleton members -- broker unit and front door, plus fabric store unit, route, socket, root, directories and **ownership marker** -- are built from it and move between targets as one subject. The marker is the dashboard ownership-marker rule (`dashboard_ownership_marker_in`) applied to the store's control directory `fabric-storage-control` (not the store root, whose derived mode admits only the service), whose derived mode admits the observing deploy job principal to read it, so an old store with no unit stays observable. The broker is GIVEN its address (`GUNBC_APPROVAL_BROKER_BASE_URL`, from `gunbc.auth.approval_broker_placement`); broker-side modules never resolve the role (#13132's closure). Also following the realization: approval client origin, the GCP IAM job's runner, the approvals app host, `DashboardProtectedSibling`. - Role-singleton convergence (`gunbc.live_deploy.role_singleton_convergence`): - - **Observation.** It sits behind `RoleSingletonReadingSource`: an ssh probe today, host self-reports later, and which one production uses is still owed by the operator. Sightings are kept at (host, deployment, member) until multiplicity is adjudicated, and every roster deployment's broker, fabric unit and marker are probed on every roster host. + - **Observation.** It sits behind `RoleSingletonReadingSource`: host self-report is the ruled realization (operator, 2026-10-05: each host's services report their own role-singleton state; no srv1→srv2 ssh will be granted) and is the immediate follow-up to D2. Until it lands the only realization is the ssh probe, kept as a refusing fallback: on the applying host it reads locally, and a roster host it cannot reach refuses as `RoleSingletonHostUnobservable` -- absence is established only by a successful listing, so an ungranted probe never reads as absent. Sightings are kept at (host, deployment, member) until multiplicity is adjudicated, and every roster deployment's broker, fabric unit and marker are probed on every roster host. - **Decision.** Possible outcomes: - `RoleSingletonNoop`: exactly one complete realization of the desired holder on its target. - `RoleSingletonInstall`: nothing observed anywhere. @@ -42,7 +42,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli This is the same path and content the holder spec's own marker ensure writes. After it, the next apply observes a complete realization and converges as Noop. - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - - Operational dependency: an apply observes every roster host, so the applying runner needs ssh reach to every other roster target (srv1 ↔ srv2 today); an unreachable host refuses the apply. **Reach is not sufficient: the read authority is named.** The observer is the deploy job principal (`gunbc.fabric_storage_placement` `fabric_storage_marker_observer` = `srv1_ci_deploy_runner_principal` job_user, ghrunner), and the marker lives in the control directory whose derived mode admits it to list and traverse; the store root's derived mode does not, and is not widened. A cross-host ssh login must land as that principal; one that lands as another account and is denied reads as unobservable and refuses, never as absent. + - Consequence until self-report lands: an apply observes every roster host, and with no cross-host reach granted every other roster host is unobservable, so the apply refuses before planning (fail-closed, the manual-resume posture of #13124). **The read authority is named,** for the local read and for any future reader: The observer is the deploy job principal (`gunbc.fabric_storage_placement` `fabric_storage_marker_observer` = `srv1_ci_deploy_runner_principal` job_user, ghrunner), and the marker lives in the control directory whose derived mode admits it to list and traverse; the store root's derived mode does not, and is not widened. A reader that lands as another account and is denied reads as unobservable and refuses, never as absent. - **Warrant.** The member-plan step mints `AdmittedLiveDeployApply`, realizes it, and returns the intent with the warrant replaced by `LiveDeployApplyReported`, so no value returned or stored outside that sealed step carries a warrant. The residual -- re-wrapping an extracted warrant into a fresh `LiveDeployApply` -- is not authorable as a compile RED because coproduct variant construction cannot be sealed; it is recorded on `gunbc.recurring_failure_mode` `modifier_accepted_on_a_shape_its_check_cannot_see`, trigger: coproduct variant construction wired through `sole_constructor_construction_diags` (declared at `v1.compiler.parse` `alias_rhs_is_anonymous_record`). - Honest non-pins (not debt): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`, now checked roster-wide by `test.claim.serve_function_binding_witness_test`), so they must not follow the role; `fleet_reach_endpoint` is a host-shared account fact. Not claimed: that an unbound test deployment refuses everywhere — `belt_dispatch_instance_cli`'s absent-env default stays with the belt owner's lane. 3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. From 8e4a0eefd929932da4837fc48fd8e18b5656cda2 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 03:01:40 +0000 Subject: [PATCH 38/48] role_singleton_convergence: dedup through std.list distinct_by_key (no quadratic hand fold); the marker stat leg takes a closed MarkerStatField with no fallthrough arm (review 76087) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../role_singleton_convergence.dag | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index f18db73844c..e333ed6ffe4 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -28,6 +28,7 @@ import gunbc.fabric_storage_placement { FabricStorageOwnershipMarkerSpec, Fabric import extdeps.access.posix { file_mode_octal } import extdeps.shell import std.algebra { trim } +import std.list { distinct_by_key } import std.types { FilePath } import gunbc.live_deploy.desired { DesiredDeployment, desired_deployment_roster, prod_role_actuated_realization, selected_deployment_spec, bind_desired_selected_identity, DesiredSelectedIdentityBound, DesiredSelectedIdentityUnknownInstance, DesiredSelectedIdentityRosterRefused, DesiredSelectedIdentityMismatch, instance_id_text } import gunbc.live_deploy.member_observe { @@ -143,11 +144,11 @@ fn member_eq(a: RoleSingletonMember, b: RoleSingletonMember) -> Bool { } fn distinct_ids(ids: List) -> List { - fold(ids, init: [], f: (acc, d) => if any(acc, a => a == d) { acc } else { concat(acc, [d]) }) + distinct_by_key(xs: ids, key: d => d.name as String) } fn distinct_hosts(hosts: List) -> List { - fold(hosts, init: [], f: (acc, h) => if any(acc, a => (a as String) == (h as String)) { acc } else { concat(acc, [h]) }) + distinct_by_key(xs: hosts, key: h => h as String) } fn deployment_sightings(readings: List) -> List { @@ -458,13 +459,29 @@ fn presence_cause(p: EntryPresence) -> String? { // fabric_storage_ownership_marker_spec). Only an exact match is the marker; any differing field is a // distinct sighting the adjudication refuses, and a field that cannot be read makes the host // unobservable rather than reading as either. -fn stat_leg(arm: ObservationArm, path: String, format: String, leg: String) -> Leg { +// WHICH STAT FIELD A LEG READS, as a closed choice: each arm names its own shell.Stat operation and its +// own format, so there is no string to mistype and no arm that a wrong value falls through to. +type MarkerStatField = MarkerStatMode | MarkerStatOwner | MarkerStatGroup + +fn marker_stat_format(field: MarkerStatField) -> String { + match field { + MarkerStatMode => "%a" + MarkerStatOwner => "%U" + MarkerStatGroup => "%G" + } +} + +fn stat_leg(arm: ObservationArm, path: String, field: MarkerStatField, leg: String) -> Leg { match arm { OnTheHostItself => { - let r = if format == "%a" { shell.Stat.ModeOf(path: path as FilePath) } else if format == "%U" { shell.Stat.OwnerOf(path: path as FilePath) } else { shell.Stat.GroupOf(path: path as FilePath) } + let r = match field { + MarkerStatMode => shell.Stat.ModeOf(path: path as FilePath) + MarkerStatOwner => shell.Stat.OwnerOf(path: path as FilePath) + MarkerStatGroup => shell.Stat.GroupOf(path: path as FilePath) + } if r.success { LegOut { stdout: r.stdout } } else { LegRefused { cause: join([leg, " failed: ", r.stderr], "") } } } - OverTransport { transport } => remote_leg(transport: transport, argv: ["stat", "-c", format, "--", path], leg: leg) + OverTransport { transport } => remote_leg(transport: transport, argv: ["stat", "-c", marker_stat_format(field: field), "--", path], leg: leg) } } @@ -475,11 +492,11 @@ type MarkerRead fn read_marker(arm: ObservationArm, path: NonEmptyStr) -> MarkerRead { match unit_document_leg(arm: arm, path: path as String) { LegRefused { cause } => MarkerReadRefused { cause: cause } - LegOut { stdout: content } => match stat_leg(arm: arm, path: path as String, format: "%a", leg: "reading the marker's mode") { + LegOut { stdout: content } => match stat_leg(arm: arm, path: path as String, field: MarkerStatMode, leg: "reading the marker's mode") { LegRefused { cause } => MarkerReadRefused { cause: cause } - LegOut { stdout: mode } => match stat_leg(arm: arm, path: path as String, format: "%U", leg: "reading the marker's owner") { + LegOut { stdout: mode } => match stat_leg(arm: arm, path: path as String, field: MarkerStatOwner, leg: "reading the marker's owner") { LegRefused { cause } => MarkerReadRefused { cause: cause } - LegOut { stdout: owner } => match stat_leg(arm: arm, path: path as String, format: "%G", leg: "reading the marker's group") { + LegOut { stdout: owner } => match stat_leg(arm: arm, path: path as String, field: MarkerStatGroup, leg: "reading the marker's group") { LegRefused { cause } => MarkerReadRefused { cause: cause } LegOut { stdout: group } => MarkerReadBack { reading: FabricStorageMarkerReading { path: path, content: content, mode: trim(s: mode), owner: owner, group: group } } } From a514620c3c0b3f7ef18cf245daa4d6e0117d8e93 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 03:34:40 +0000 Subject: [PATCH 39/48] deployment_risk witness: the spec-route probes compile only gunbc.live_deploy.role_singleton_convergence, one compile per claim; the effect-boundary probes are one claim each, with the single canonical-path control Co-Authored-By: Claude Opus 5.5 (1M context) --- .../claim/deployment_risk_witness_test.dag | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 08f82245d8f..7536cf6709a 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -658,7 +658,7 @@ data canonical_apply_source: String = "module probe_canonical_apply\nimport gunb data altered_spec_to_assessor_source: String = "module probe_altered_spec_to_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit_rostered, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn alter() -> RoleSingletonAdmissionResult { let s = deployment_spec_srv1()\n assess_and_admit_rostered(spec: s, own: LocalShell) }\n" -data spec_to_production_apply_source: String = "module probe_spec_to_production_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn act(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(spec: deployment_spec_srv1(), access: a, candidate: c)\n true }\n" +data spec_to_assessor_source: String = "module probe_spec_to_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn hand() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), own: LocalShell) }\n" data replayed_admission_source: String = "module probe_replayed_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { RoleSingletonAdmission, admitted_spec }\nimport gunbc.live_deploy.spec { DeploymentSpec }\nfn replay(a: RoleSingletonAdmission) -> DeploymentSpec { admitted_spec(admission: a) }\n" @@ -680,10 +680,15 @@ fn class_refusals_in(source: String, wanted: String) -> Int { // the spec is built from its bound roster row inside assess_and_admit; (a) handing a spec to the // production entry does not type (it has no spec parameter), and (b) handing one to the // spec-taking assessor step is refused at its seal, while (c) the canonical row-taking call compiles. +// AT ONE INTERFACE: each probe imports only gunbc.live_deploy.role_singleton_convergence, the module +// that declares the assessor and its sealed spec-taking step, never the apply tree, and compiles once +// per claim. The canonical apply route's control is the one in the effect-boundary claims below. test fn no_caller_supplied_spec_reaches_the_role_singleton_assessment() -> Bool { - class_refusals_in(source: spec_to_production_apply_source, wanted: "CallArgumentNameUnknown") >= 1 - && admission_refusals_in(source: altered_spec_to_assessor_source) >= 1 - && admission_refusals_in(source: canonical_apply_source) == 0 + class_refusals_in(source: spec_to_assessor_source, wanted: "CallArgumentNameUnknown") >= 1 +} + +test fn an_altered_spec_is_refused_at_the_assessor_seal() -> Bool { + admission_refusals_in(source: altered_spec_to_assessor_source) >= 1 } test fn no_outside_module_can_mint_obtain_or_replay_an_admission() -> Bool { @@ -704,9 +709,20 @@ fn sole_constructor_refusals_in(source: String) -> Int { // time; (c) the canonical assess-and-act entry still compiles with no admission refusal. test fn the_realizer_receives_an_apply_only_through_the_assessed_composition() -> Bool { admission_refusals_in(source: outside_member_plan_source) >= 1 - && admission_refusals_in(source: outside_warrant_mint_source) >= 1 - && sole_constructor_refusals_in(source: outside_warrant_literal_source) >= 1 - && admission_refusals_in(source: canonical_apply_source) == 0 +} + +test fn no_outside_module_can_mint_a_warrant() -> Bool { + admission_refusals_in(source: outside_warrant_mint_source) >= 1 +} + +test fn no_outside_module_can_write_a_warrant_literal() -> Bool { + sole_constructor_refusals_in(source: outside_warrant_literal_source) >= 1 +} + +// THE ONE CANONICAL-PATH CONTROL: the row-taking production apply compiles with no admission refusal, +// so the REDs above are not a compiler that refuses every call into the apply module. +test fn the_canonical_row_taking_apply_compiles_without_admission_refusal() -> Bool { + admission_refusals_in(source: canonical_apply_source) == 0 } // ── The marker has one authority, and only an exact readback is the marker ───────────────────── From 7ec01bbe497b3b24f24555b8f9c7c1f35ad1c784 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 06:32:15 +0000 Subject: [PATCH 40/48] deployment_risk witness: the spec-route probe targets the unsealed production entry so only the label wall can refuse, and asserts zero seal refusals (the probe at the sealed assessor drew only ConstructorCallAdmissionRefused); census cost receipt on entry_scoped_typecheck_rss_tracks_the_name_census Co-Authored-By: Claude Opus 5.5 (1M context) --- ...scoped_typecheck_rss_tracks_the_name_census.dag | 2 ++ dag/test/claim/deployment_risk_witness_test.dag | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag b/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag index d749dfc0acf..683fcce8b9e 100644 --- a/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag +++ b/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag @@ -17,6 +17,8 @@ data entry_scoped_typecheck_rss_tracks_the_name_census: RecurringFailureMode = R "BOUNDED AGAINST THREE NEIGHBOURS. It is NOT `absent_reads_identically_to_never_looked`: that class is that census-only modules are never judged; this class is that they are still paid in RSS. It is NOT a hub re-export of `required_floor` by floor2_prepared_subject — that import is absent; the wire that failed is the hub, and splitting it would not drop an isolated `--entry` of floor2 below the census cost. It is NOT the 2026-08-17 per-claim CPU shrink-only ruling: memory admission is a different subject, and a raise still needs measured srv4 headroom beside this peak. Isolated 9.73 GiB already consumes most of the 16 GiB class; there is no srv4 RSS sample before this typecheck on which to claim headroom. Raising is therefore the pattern that ruling names, with the cost-shape unfixed.", + "RECEIPT, THE SAME CLASS INSIDE A CLAIM (deployment-risk D2, #13217, 2026-10-05): every `compile_dag_diagnostic_census` probe in test.claim.deployment_risk_witness_test peaked at 17.6-20.9 GiB VmHWM run alone under a 20 GiB cgroup -- the outside-warrant-mint probe PASSED at 17.6 GiB with 607 eval_steps, the warrant-literal probe at 18.6 GiB with 914, and five others were OOM-killed at the bind -- and the peak did not move with the fixture: a probe importing only gunbc.live_deploy.role_singleton_convergence cost what a probe importing the whole apply tree cost. The census rows name v2.compiler.* modules the fixture never reaches, so the probe pays the source roots' census, not its closure. The eval steps are the claim's subject and are trivial; the compile is the whole cost. Measured by the per-claim VmHWM sampling run in that PR; the class's trigger above is the remedy, and narrowing a fixture's imports is not.", + "RUNG FOUND AT: mitigatable. The stall refuses typed and located. The invalid cost-shape remains writable.", "ATTAINABLE CEILING: mechanically preventable. Whether typecheck RSS is charged to the closure or to the census is a fact of `compile_emission` / `tree_bare_census_for_root`; a check can refuse an `--entry` whose M/N ratio and peak disagree with the closure grain, or the construction can stop forcing the whole-root census on an Entry subject. It is not structurally impossible: indexing the pool for bare-name parity is a legitimate construction; the defect is charging its resident set to a 16-module typecheck as if it were that module's work.", diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 7536cf6709a..23203847114 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -658,7 +658,7 @@ data canonical_apply_source: String = "module probe_canonical_apply\nimport gunb data altered_spec_to_assessor_source: String = "module probe_altered_spec_to_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit_rostered, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn alter() -> RoleSingletonAdmissionResult { let s = deployment_spec_srv1()\n assess_and_admit_rostered(spec: s, own: LocalShell) }\n" -data spec_to_assessor_source: String = "module probe_spec_to_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn hand() -> RoleSingletonAdmissionResult { assess_and_admit(spec: deployment_spec_srv1(), own: LocalShell) }\n" +data spec_to_production_apply_source: String = "module probe_spec_to_production_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn hand(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(spec: deployment_spec_srv1(), access: a, candidate: c)\n true }\n" data replayed_admission_source: String = "module probe_replayed_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { RoleSingletonAdmission, admitted_spec }\nimport gunbc.live_deploy.spec { DeploymentSpec }\nfn replay(a: RoleSingletonAdmission) -> DeploymentSpec { admitted_spec(admission: a) }\n" @@ -680,11 +680,15 @@ fn class_refusals_in(source: String, wanted: String) -> Int { // the spec is built from its bound roster row inside assess_and_admit; (a) handing a spec to the // production entry does not type (it has no spec parameter), and (b) handing one to the // spec-taking assessor step is refused at its seal, while (c) the canonical row-taking call compiles. -// AT ONE INTERFACE: each probe imports only gunbc.live_deploy.role_singleton_convergence, the module -// that declares the assessor and its sealed spec-taking step, never the apply tree, and compiles once -// per claim. The canonical apply route's control is the one in the effect-boundary claims below. +// THE ROUTE, NOT ONLY A REFUSAL. The probe hands a spec to the UNSEALED production entry +// (live_deploy_apply_with_access), so no seal can refuse it: the only refusal it can draw is the label +// wall's, because the entry has no spec parameter. Zero seal refusals is asserted beside it, so the +// claim cannot pass on a ConstructorCallAdmissionRefused standing in for the missing parameter -- which +// is what a probe aimed at the sealed assessor itself drew (measured: only ConstructorCallAdmissionRefused +// on assess_and_admit, never the label wall). One compile per claim. test fn no_caller_supplied_spec_reaches_the_role_singleton_assessment() -> Bool { - class_refusals_in(source: spec_to_assessor_source, wanted: "CallArgumentNameUnknown") >= 1 + class_refusals_in(source: spec_to_production_apply_source, wanted: "CallArgumentNameUnknown") >= 1 + && admission_refusals_in(source: spec_to_production_apply_source) == 0 } test fn an_altered_spec_is_refused_at_the_assessor_seal() -> Bool { From ffdd800ed7ad74c9bb8e2d9007f22624537eb49f Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 06:55:30 +0000 Subject: [PATCH 41/48] role_singleton_convergence: the D2 interim reading source is local-only; every nonlocal roster host is unobservable (self-report has not landed, #13339) with no transport built; the ssh probe source is deleted and the module names no ssh transport Co-Authored-By: Claude Opus 5.5 (1M context) --- .../role_singleton_convergence.dag | 42 ++++++++++--------- .../claim/deployment_risk_witness_test.dag | 22 +++++++++- docs/plans/deployment-risk-conformance.md | 6 +-- 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/dag/gunbc/live_deploy/role_singleton_convergence.dag b/dag/gunbc/live_deploy/role_singleton_convergence.dag index e333ed6ffe4..2db9416237a 100644 --- a/dag/gunbc/live_deploy/role_singleton_convergence.dag +++ b/dag/gunbc/live_deploy/role_singleton_convergence.dag @@ -16,7 +16,7 @@ import gunbc.roadmap_dashboard_instance { ProdRoleHolderUnactuatable, prod_role_realization_refusal_detail, } -import gunbc.host_effect { HostEffectTransport, SshShell } +import gunbc.host_effect { HostEffectTransport } import gunbc.live_deploy.spec { DeploymentSpec, HoldsProdRoleSingletons, @@ -93,10 +93,10 @@ type HostRoleSingletonReading = HostRoleSingletonsRead { host: NonEmptyStr, sightings: List } | HostRoleSingletonsUnobservable { host: NonEmptyStr, cause: String } -// THE READING SOURCE IS AN INTERFACE. Today it is an ssh probe from the applying host -// (ssh_probe_reading_source); host self-reports are a second realization of the same signature. Which -// one production uses is an operator decision still owed (docs/plans/deployment-risk-conformance.md), -// and the adjudication below is indifferent to it. +// THE READING SOURCE IS AN INTERFACE. Cross-host observation is host self-report (operator ruling +// 2026-10-05; gunbc#13339 realizes it). Until that lands the production source is +// local_only_reading_source: the applying host is read over the apply's own route, and every other +// roster host is unobservable by construction. The adjudication below is indifferent to which. type RoleSingletonReadingSource { read: fn(NonEmptyStr, List) -> HostRoleSingletonReading } @@ -424,14 +424,10 @@ fn role_singleton_refusal_detail(cause: RoleSingletonRefusal) -> String { } } -// ── The ssh probe reading source ──────────────────────────────────────────────────────────────── -// A REFUSING FALLBACK, not the ruled realization: cross-host observation is host self-report (operator, -// 2026-10-05) and no cross-host ssh is granted, so a remote leg refuses and the host is unobservable -- -// absence is only ever a successful listing, so an ungranted probe never reads as absent. Each roster host is read over the transport that reaches it from the applying host: the apply's own -// transport for the host it is applying to, ssh to the target's declared ssh_host for every other -// roster host. EVERY roster deployment's members are probed on EVERY host -- probing only where a -// deployment is targeted is what made a realization on the wrong host invisible. A host whose legs -// refuse is unobservable and stops the line. +// ── Reading one host ──────────────────────────────────────────────────────────────────────────── +// EVERY roster deployment's members are probed on the host read -- probing only where a deployment is +// targeted is what made a realization on the wrong host invisible. A host whose legs refuse is +// unobservable and stops the line; absence is only ever a successful listing. type MemberProbe { deployment: DeploymentId? member: RoleSingletonMember @@ -568,13 +564,21 @@ fn roster_hosts(rows: List) -> List { distinct_hosts(hosts: rows |> map(r => r.target.ssh_host)) } -fn transport_to(host: NonEmptyStr, applying: DeploymentSpec, own: HostEffectTransport) -> HostEffectTransport { - if (host as String) == (applying.target.ssh_host as String) { own } else { SshShell { ssh_host: host } } -} +// THE D2 INTERIM SOURCE FAILS CLOSED BY CONSTRUCTION. The applying host is read over the apply's own +// route -- the route the apply already holds to its target, not a cross-host grant. Every other roster +// host is HostRoleSingletonsUnobservable without any transport being built, so no credential, granted +// or not, can make a nonlocal host observable here: this module names no ssh transport at all. gunbc#13339 +// replaces this source with host self-report. +data nonlocal_host_unobservable_cause: String = "self-report has not landed (gunbc#13339): a roster host other than the applying host is not observed by the applying host" -fn ssh_probe_reading_source(applying: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonReadingSource { +fn local_only_reading_source(applying: DeploymentSpec, own: HostEffectTransport) -> RoleSingletonReadingSource { RoleSingletonReadingSource { - read: (host, rows) => host_role_singleton_reading(host: host, rows: rows, arm: observation_arm(transport: transport_to(host: host, applying: applying, own: own))), + read: (host, rows) => + if (host as String) == (applying.target.ssh_host as String) { + host_role_singleton_reading(host: host, rows: rows, arm: observation_arm(transport: own)) + } else { + HostRoleSingletonsUnobservable { host: host, cause: nonlocal_host_unobservable_cause } + }, } } @@ -610,7 +614,7 @@ fn assess_and_admit_rostered(spec: DeploymentSpec, own: HostEffectTransport) -> let desired = prod_role_actuated_realization() let decision = role_singleton_convergence( desired: desired, - observed: observe_role_singletons_with(source: ssh_probe_reading_source(applying: spec, own: own), rows: rows), + observed: observe_role_singletons_with(source: local_only_reading_source(applying: spec, own: own), rows: rows), rows: rows, ) match classify_role_singleton_admission(spec: spec, desired: desired, decision: decision) { diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 23203847114..78fc9cc5116 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -64,7 +64,7 @@ import gunbc.live_deploy.spec { deployment_owns_the_host_approval_broker, deployment_fabric_storage_door_socket, } -import gunbc.live_deploy.desired { srv1_production_desired_deployment, DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } +import gunbc.live_deploy.desired { deployment_spec_srv1, srv1_production_desired_deployment, DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } import gunbc.live_deploy.role_singleton_convergence { RoleSingletonConvergence, @@ -101,6 +101,8 @@ import gunbc.live_deploy.role_singleton_convergence { classify_role_singleton_admission, role_singleton_convergence, role_singleton_observation_of, + local_only_reading_source, + nonlocal_host_unobservable_cause, marker_sighting_of, MarkerSighting, MarkerSightingAbsent, @@ -111,6 +113,7 @@ import gunbc.live_deploy.role_singleton_convergence { MarkerReadRefused, } import gunbc.live_deploy.member_observe { EntryPresent, EntryAbsent, EntryPresenceIndeterminate } +import gunbc.host_effect { SshShell } import gunbc.managed_directory { managed_directory_admits_reader, managed_directory_permissions } import gunbc.fleet_posix_accounts { srv1_ci_deploy_runner_principal } import gunbc.fleet_converge_workflow { fleet_converge_gcp_iam_converge_job_under } @@ -328,7 +331,7 @@ test fn a_prod_role_holder_with_no_instance_refuses_as_unrealized() -> Bool { // ── The convergence transitions, over supplied per-host sightings ────────────────────────────── // Sightings are supplied at the adjudication boundary (DESIGN 3: a witness discriminates at one -// interface); the ssh probe that produces them in production is ssh_probe_reading_source, and the +// interface); the production source that produces them is local_only_reading_source, and the // positive control below runs the real decision over the real realization. fn full_on(host: String, d: DeploymentId) -> List { [ @@ -808,3 +811,18 @@ test fn the_marker_readback_has_three_distinct_outcomes() -> Bool { } exact && absent && unlistable && unreadable } + +// ── No cross-host transport, whatever credential exists ─────────────────────────────────────── +// THE D2 INTERIM IS FAIL-CLOSED BY CONSTRUCTION, not by an ungranted grant. The apply's own transport is +// handed a VALID-LOOKING ssh credential to srv2 itself, and srv2 -- a roster host that is not the +// applying host -- is still unobservable with the self-report cause: the source builds no transport for +// a nonlocal host, so no credential reaches it. Reading the nonlocal host is pure, so this claim touches +// no host. gunbc#13339 swaps this refusal for the self-report source. +test fn a_valid_ssh_credential_cannot_make_a_nonlocal_roster_host_observable() -> Bool { + let source = local_only_reading_source(applying: deployment_spec_srv1(), own: SshShell { ssh_host: "srv2" as NonEmptyStr }) + let read = source.read + match read("srv2" as NonEmptyStr, desired_deployment_roster()) { + HostRoleSingletonsUnobservable { host, cause } => (host as String) == "srv2" && cause == nonlocal_host_unobservable_cause + HostRoleSingletonsRead { host: _, sightings: _ } => false + } +} diff --git a/docs/plans/deployment-risk-conformance.md b/docs/plans/deployment-risk-conformance.md index abaad565908..ff3b1aea8e3 100644 --- a/docs/plans/deployment-risk-conformance.md +++ b/docs/plans/deployment-risk-conformance.md @@ -25,14 +25,14 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli 2. **Repoint the prod pins** through `prod_role_selection` (broker owner, fabric storage placement, approval client/store/trust, serve/dispatch entry points, apply's prod peer → `DashboardProtectedSibling`, `fleet_reach_endpoint`). RED: a fixture selection moves the broker owner and storage placement. - Landed: one resolver, `gunbc.roadmap_dashboard_instance` `prod_role_realization` (the instance roster filtered by `deployment`; no second table), refusing with `ProdRoleRealizationRefusal` (`ProdRoleUnheld` | `ProdRoleHolderUnrealized` | `ProdRoleHolderAmbiguous` | `ProdRoleHolderUnactuatable`) and minting the only holder token, `ProdRoleHolder` (`sole_constructor`). **Actuatability join:** `gunbc.live_deploy.desired` `prod_role_actuated_realization` joins the holder to the one `desired_deployment_roster`; a holder with no row (MacBook, srv2-preview, srv2's lab) refuses as `ProdRoleHolderUnactuatable`. Every role follower reads the joined realization. A spec carries the token as `gunbc.live_deploy.spec` `RoleSingletonHolding`, and the singleton members -- broker unit and front door, plus fabric store unit, route, socket, root, directories and **ownership marker** -- are built from it and move between targets as one subject. The marker is the dashboard ownership-marker rule (`dashboard_ownership_marker_in`) applied to the store's control directory `fabric-storage-control` (not the store root, whose derived mode admits only the service), whose derived mode admits the observing deploy job principal to read it, so an old store with no unit stays observable. The broker is GIVEN its address (`GUNBC_APPROVAL_BROKER_BASE_URL`, from `gunbc.auth.approval_broker_placement`); broker-side modules never resolve the role (#13132's closure). Also following the realization: approval client origin, the GCP IAM job's runner, the approvals app host, `DashboardProtectedSibling`. - Role-singleton convergence (`gunbc.live_deploy.role_singleton_convergence`): - - **Observation.** It sits behind `RoleSingletonReadingSource`: host self-report is the ruled realization (operator, 2026-10-05: each host's services report their own role-singleton state; no srv1→srv2 ssh will be granted) and is the immediate follow-up to D2. Until it lands the only realization is the ssh probe, kept as a refusing fallback: on the applying host it reads locally, and a roster host it cannot reach refuses as `RoleSingletonHostUnobservable` -- absence is established only by a successful listing, so an ungranted probe never reads as absent. Sightings are kept at (host, deployment, member) until multiplicity is adjudicated, and every roster deployment's broker, fabric unit and marker are probed on every roster host. + - **Observation.** It sits behind `RoleSingletonReadingSource`: host self-report is the ruled realization (operator, 2026-10-05: each host's services report their own role-singleton state; no srv1→srv2 ssh will be granted) and is the immediate follow-up to D2. Until it lands the production source is `local_only_reading_source`: the applying host is read over the apply's own route, and every other roster host is `HostRoleSingletonsUnobservable` (cause: self-report has not landed) without any transport being built -- the assessor module names no ssh transport, so no credential can make a nonlocal host observable (`a_valid_ssh_credential_cannot_make_a_nonlocal_roster_host_observable`). Sightings are kept at (host, deployment, member) until multiplicity is adjudicated, and every roster deployment's broker, fabric unit and marker are probed on every roster host. - **Decision.** Possible outcomes: - `RoleSingletonNoop`: exactly one complete realization of the desired holder on its target. - `RoleSingletonInstall`: nothing observed anywhere. - `RoleSingletonRefused`, with one of these causes: move, removal, `RoleSingletonDesiredRealizationRefused`, unmarked, unobservable, two-hosts, misplaced, two-on-one, ambiguous, broker-without-store, partial, stale-spec. Only `ProdRoleUnheld` means no prod. - - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec. Its mint is sealed (`admit_callers`) to the canonical assessor `assess_and_admit(deployment, own)`, which takes a `DesiredDeployment`, binds it to its `desired_deployment_roster` row (refusing `RoleSingletonDeploymentUnrostered` otherwise), builds the spec from that row itself -- no caller-supplied `DeploymentSpec` reaches it -- and reads the actuated desired realization and the ssh observation itself. The assessor, the token's readers (`admitted_spec`, `admitted_decision`) and every admitted apply step are in turn sealed to the two canonical assess-and-act compositions (`live_deploy_apply_via_transport`, `live_deploy_transaction_decided`). So a token can be neither forged, obtained elsewhere nor replayed later. Fixtures use the pure `classify_role_singleton_admission`. The apply receipt records `role_singleton_disposition` (Noop, Installed or RefusedBeforeApply). + - **Admission.** `RoleSingletonAdmission` (`sole_constructor`) binds the exact spec. Its mint is sealed (`admit_callers`) to the canonical assessor `assess_and_admit(deployment, own)`, which takes a `DesiredDeployment`, binds it to its `desired_deployment_roster` row (refusing `RoleSingletonDeploymentUnrostered` otherwise), builds the spec from that row itself -- no caller-supplied `DeploymentSpec` reaches it -- and reads the actuated desired realization and the observation through `RoleSingletonReadingSource` itself. The assessor, the token's readers (`admitted_spec`, `admitted_decision`) and every admitted apply step are in turn sealed to the two canonical assess-and-act compositions (`live_deploy_apply_via_transport`, `live_deploy_transaction_decided`). So a token can be neither forged, obtained elsewhere nor replayed later. Fixtures use the pure `classify_role_singleton_admission`. The apply receipt records `role_singleton_disposition` (Noop, Installed or RefusedBeforeApply). - **Unmarked legacy store (no automated adoption).** A realization with its broker and fabric unit but no store ownership marker refuses as `RoleSingletonUnmarked`. Today's srv1 holder predates the marker, so it is the expected case. Under the manual-resume ruling (#13124) it waits for the operator, and the refusal text names exactly what to write. **Operator's one-time disposition:** - on `srv1`, create the control directory `/opt/gunbc/fabric-storage-control` with the mode, owner and group derived by `gunbc.fabric_storage_placement` `fabric_storage_control_directory` (2750, owner briansrls, group ghrunner); - in it, write `.gunbc-dashboard-instance-srv1-live`; @@ -42,7 +42,7 @@ Operator direction 2026-10-03; vocabulary fixed the same day by a side-chat ruli This is the same path and content the holder spec's own marker ensure writes. After it, the next apply observes a complete realization and converges as Noop. - **Next trigger.** Move and Remove actuations become arms beside these. - Bounded residual: the host universe is the roster's targets (`desired_deployment_roster`), so a singleton on a host outside the roster is invisible to the observation. Trigger: a roster row for that host. - - Consequence until self-report lands: an apply observes every roster host, and with no cross-host reach granted every other roster host is unobservable, so the apply refuses before planning (fail-closed, the manual-resume posture of #13124). **The read authority is named,** for the local read and for any future reader: The observer is the deploy job principal (`gunbc.fabric_storage_placement` `fabric_storage_marker_observer` = `srv1_ci_deploy_runner_principal` job_user, ghrunner), and the marker lives in the control directory whose derived mode admits it to list and traverse; the store root's derived mode does not, and is not widened. A reader that lands as another account and is denied reads as unobservable and refuses, never as absent. + - Consequence until self-report lands: an apply assesses every roster host, and every roster host other than the applying host is unobservable, so the apply refuses before planning (fail-closed, the manual-resume posture of #13124). **The read authority is named,** for the local read and for any future reader: The observer is the deploy job principal (`gunbc.fabric_storage_placement` `fabric_storage_marker_observer` = `srv1_ci_deploy_runner_principal` job_user, ghrunner), and the marker lives in the control directory whose derived mode admits it to list and traverse; the store root's derived mode does not, and is not widened. A reader that lands as another account and is denied reads as unobservable and refuses, never as absent. - **Warrant.** The member-plan step mints `AdmittedLiveDeployApply`, realizes it, and returns the intent with the warrant replaced by `LiveDeployApplyReported`, so no value returned or stored outside that sealed step carries a warrant. The residual -- re-wrapping an extracted warrant into a fresh `LiveDeployApply` -- is not authorable as a compile RED because coproduct variant construction cannot be sealed; it is recorded on `gunbc.recurring_failure_mode` `modifier_accepted_on_a_shape_its_check_cannot_see`, trigger: coproduct variant construction wired through `sole_constructor_construction_diags` (declared at `v1.compiler.parse` `alias_rhs_is_anonymous_record`). - Honest non-pins (not debt): the nullary serve/belt/dispatch/launch entry points are srv1-live's *own* entry points (`serve_function`, now checked roster-wide by `test.claim.serve_function_binding_witness_test`), so they must not follow the role; `fleet_reach_endpoint` is a host-shared account fact. Not claimed: that an unbound test deployment refuses everywhere — `belt_dispatch_instance_cli`'s absent-env default stays with the belt owner's lane. 3. **Split shared bindings**, one PR each: ntfy → Codex account/home → admitted ref → publication identity/GitHub App → R2/GCP → OIDC. Each adds a field to `DeploymentExternalBindingSet` and either a TestRisk resource (operator approval first) or a standing refusal. From 67cc529edc06e535f21e9b7c0c9c69a0aa57e1f5 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 09:56:59 +0000 Subject: [PATCH 42/48] approval origin carries the holder's host; approval_gate dials only through a sealed DialableApprovalOrigin minted when this executor's short hostname is that host; pair-serving D0 derives its executor host from the approval writer's (refusing under NoProdRole); REDs 1-5 Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/auth/approval_gate.dag | 31 +++-- dag/gunbc/auth/approval_request_client.dag | 67 +++++++++- dag/gunbc/spark/pair_serving_d0_door.dag | 33 +++-- .../approval_request_client_witness_test.dag | 125 +++++++++++++----- ..._serving_d0_authorization_witness_test.dag | 33 ++++- 5 files changed, 222 insertions(+), 67 deletions(-) diff --git a/dag/gunbc/auth/approval_gate.dag b/dag/gunbc/auth/approval_gate.dag index c183a612a45..00412171970 100644 --- a/dag/gunbc/auth/approval_gate.dag +++ b/dag/gunbc/auth/approval_gate.dag @@ -25,7 +25,8 @@ import gunbc.auth.approval_decision_store { grant_from_approved_decision, GrantD import gunbc.auth.approval_keyring_pins { approval_submission_mac_key_path_env } import gunbc.auth.approval_request_client { sign_stored_request, approval_submit_url, approval_status_url, approval_poll_standing, - approval_loopback_origin, ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused, approval_loopback_origin_refusal_detail, + approval_origin_admission_here, approval_origin_admission_refusal_detail, DialableApprovalOrigin, + ApprovalOriginDialable, ApprovalOriginUnresolved, ApprovalExecutorNotTheOriginHost, ApprovalExecutorHostUnobserved, SubmissionMacReady, SubmissionMacKeyNotHex, ApprovalPollStanding, PollApproved, PollDenied, PollExpired, PollStillPending, PollNotFiled, PollUnreadable, } @@ -192,11 +193,16 @@ fn approval_standing_read(escalation_id: NonEmptyStr, expires_at: Timestamp) -> match approval_instant_now() { Absent => StandingClockUnreadable Present { value: observed } => { - match approval_loopback_origin() { - ApprovalLoopbackOriginRefused { cause } => - StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_loopback_origin_refusal_detail(cause: cause) }, observed_at: observed, expires_at: expires_at), observed_at: observed } - ApprovalLoopbackOriginResolved { origin } => { - let got = http.Client.GetLocalhostBounded(url: approval_status_url(origin: origin, escalation_id: escalation_id)) + let admission = approval_origin_admission_here() + match admission { + ApprovalOriginUnresolved { cause: _ } => + StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_origin_admission_refusal_detail(a: admission) }, observed_at: observed, expires_at: expires_at), observed_at: observed } + ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => + StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_origin_admission_refusal_detail(a: admission) }, observed_at: observed, expires_at: expires_at), observed_at: observed } + ApprovalExecutorHostUnobserved { reason: _ } => + StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_origin_admission_refusal_detail(a: admission) }, observed_at: observed, expires_at: expires_at), observed_at: observed } + ApprovalOriginDialable { dial } => { + let got = http.Client.GetLocalhostBounded(url: approval_status_url(dial: dial, escalation_id: escalation_id)) StandingReadAt { standing: approval_poll_standing(status: approval_status_from_get(success: got.success, body: got.body), observed_at: observed, expires_at: expires_at), observed_at: observed } } } @@ -260,13 +266,16 @@ type ApprovalFiling fn approval_file_stored_request(stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling { - match approval_loopback_origin() { - ApprovalLoopbackOriginRefused { cause } => ApprovalFilingRefused { reason: approval_loopback_origin_refusal_detail(cause: cause) as String } - ApprovalLoopbackOriginResolved { origin } => approval_file_stored_request_at(origin: origin, stored: stored, body_file_name: body_file_name, submit_timeout: submit_timeout) + let admission = approval_origin_admission_here() + match admission { + ApprovalOriginDialable { dial } => approval_file_stored_request_at(dial: dial, stored: stored, body_file_name: body_file_name, submit_timeout: submit_timeout) + ApprovalOriginUnresolved { cause: _ } => ApprovalFilingRefused { reason: approval_origin_admission_refusal_detail(a: admission) as String } + ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => ApprovalFilingRefused { reason: approval_origin_admission_refusal_detail(a: admission) as String } + ApprovalExecutorHostUnobserved { reason: _ } => ApprovalFilingRefused { reason: approval_origin_admission_refusal_detail(a: admission) as String } } } -fn approval_file_stored_request_at(origin: NonEmptyStr, stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling +fn approval_file_stored_request_at(dial: DialableApprovalOrigin, stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling { let sub_path = actions_variable_trimmed(name: approval_submission_mac_key_path_env) if sub_path == "" { @@ -289,7 +298,7 @@ fn approval_file_stored_request_at(origin: NonEmptyStr, stored: StoredApprovalRe if !written.success { ApprovalFilingRefused { reason: join(["could not write the submit body: ", written.error], "") } } else { - let posted = http.Client.PostJsonFromFile(url: approval_submit_url(origin: origin), request_body_file: body_path as NonEmptyStr, max_seconds: to_string(second_count(s: submit_timeout)) as NonEmptyStr) + let posted = http.Client.PostJsonFromFile(url: approval_submit_url(dial: dial), request_body_file: body_path as NonEmptyStr, max_seconds: to_string(second_count(s: submit_timeout)) as NonEmptyStr) let _wiped = Filesystem.Delete(path: body_path) if submission_body_admits_poll(body: posted.body) { ApprovalFiled } else { ApprovalFilingRefused { reason: join(["POST /approvals refused: ", posted.body], "") } } } diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag index 54a483ac4fe..fbbd15c3be7 100644 --- a/dag/gunbc/auth/approval_request_client.dag +++ b/dag/gunbc/auth/approval_request_client.dag @@ -17,6 +17,8 @@ import gunbc.auth.approval_request_submission { approval_submission_mac_key_id, sign_submission, } import gunbc.fleet_posix_accounts { fleet_operator_email } +import gunbc.hostname_read { hostname_short_read, HostnameShortCapture, HostnameShortCaptured, HostnameShortCaptureRefused } +import gunbc.host_effect { LocalShell } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, ProdRoleRealization, ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleRealizationRefusal, prod_role_realization, prod_role_realization_refusal_detail, @@ -43,8 +45,11 @@ data approval_request_submit_path: NonEmptyStr = approval_broker_submit_path // arms resolve through gunbc.roadmap_dashboard_instance prod_role_realization. Under NoProdRole neither // is installed anywhere and the origin REFUSES with the realization's typed cause; it never falls back // to srv1. +// THE ORIGIN CARRIES ITS HOST. A loopback origin means something only on the host it is loopback ON, +// so the resolved arm names the holder instance's host beside the endpoint; dropping it would let an +// executor on another host dial a stale writer there instead of refusing. type ApprovalLoopbackOrigin - = ApprovalLoopbackOriginResolved { origin: NonEmptyStr } + = ApprovalLoopbackOriginResolved { host: NonEmptyStr, origin: NonEmptyStr } | ApprovalLoopbackOriginRefused { cause: ProdRoleRealizationRefusal } fn approval_loopback_origin_of(instance: HostDashboardInstance) -> NonEmptyStr { @@ -59,8 +64,8 @@ fn approval_loopback_origin_for(writer: ApprovalServingProcess, realization: Pro ProdRoleRealizationRefused { cause } => ApprovalLoopbackOriginRefused { cause: cause } ProdRoleRealizedBy { holder } => match writer { - RoadmapProcess => ApprovalLoopbackOriginResolved { origin: approval_loopback_origin_of(instance: holder.instance) } - BrokerProcess => ApprovalLoopbackOriginResolved { origin: approval_broker_loopback_base_url(port: approval_broker_listen_port) } + RoadmapProcess => ApprovalLoopbackOriginResolved { host: holder.instance.host_identity, origin: approval_loopback_origin_of(instance: holder.instance) } + BrokerProcess => ApprovalLoopbackOriginResolved { host: holder.instance.host_identity, origin: approval_broker_loopback_base_url(port: approval_broker_listen_port) } } } } @@ -77,13 +82,61 @@ fn approval_loopback_origin_refusal_detail(cause: ProdRoleRealizationRefusal) -> join(["approval broker origin unresolved: ", prod_role_realization_refusal_detail(cause: cause)], "") as NonEmptyStr } -fn approval_submit_url(origin: NonEmptyStr) -> NonEmptyStr { - concat(origin as String, approval_request_submit_path as String) as NonEmptyStr +// ── THE EXECUTOR MUST BE THE ORIGIN'S HOST, BEFORE ANY LOCAL HTTP ───────────────────────────── +// The only way to obtain a URL is from a DialableApprovalOrigin, and its one mint is the admission +// below, which admits an origin only when the executor's own short hostname (gunbc.hostname_read, the +// established local-host read) IS the origin's host. So an executor that is not the prod-role +// holder's host -- or cannot read its own hostname -- refuses before any request is built. +type DialableApprovalOrigin sole_constructor { + host: NonEmptyStr + origin: NonEmptyStr } -fn approval_status_url(origin: NonEmptyStr, escalation_id: NonEmptyStr) -> NonEmptyStr { +type ApprovalOriginAdmission + = ApprovalOriginDialable { dial: DialableApprovalOrigin } + | ApprovalOriginUnresolved { cause: ProdRoleRealizationRefusal } + | ApprovalExecutorNotTheOriginHost { origin_host: NonEmptyStr, executor_host: String } + | ApprovalExecutorHostUnobserved { reason: String } + +fn approval_origin_admission(origin: ApprovalLoopbackOrigin, executor: HostnameShortCapture) -> ApprovalOriginAdmission { + match origin { + ApprovalLoopbackOriginRefused { cause } => ApprovalOriginUnresolved { cause: cause } + ApprovalLoopbackOriginResolved { host, origin: o } => + match executor { + HostnameShortCaptureRefused { reason } => ApprovalExecutorHostUnobserved { reason: reason } + HostnameShortCaptured { value } => + if value == (host as String) { + ApprovalOriginDialable { dial: DialableApprovalOrigin { host: host, origin: o } } + } else { + ApprovalExecutorNotTheOriginHost { origin_host: host, executor_host: value } + } + } + } +} + +// The live admission: the actuated origin against this process's own host. +fn approval_origin_admission_here() -> ApprovalOriginAdmission { + approval_origin_admission(origin: approval_loopback_origin(), executor: hostname_short_read(transport: LocalShell)) +} + +fn approval_origin_admission_refusal_detail(a: ApprovalOriginAdmission) -> NonEmptyStr { + match a { + ApprovalOriginDialable { dial: _ } => "approval origin admitted" as NonEmptyStr + ApprovalOriginUnresolved { cause } => approval_loopback_origin_refusal_detail(cause: cause) + ApprovalExecutorNotTheOriginHost { origin_host, executor_host } => + join(["approval writer is loopback on ", origin_host as String, " (the prod-role holder's host) but this executor is ", executor_host, "; refusing before any request"], "") as NonEmptyStr + ApprovalExecutorHostUnobserved { reason } => + join(["this executor's host could not be read (", reason, "); refusing before any request to the approval writer"], "") as NonEmptyStr + } +} + +fn approval_submit_url(dial: DialableApprovalOrigin) -> NonEmptyStr { + concat(dial.origin as String, approval_request_submit_path as String) as NonEmptyStr +} + +fn approval_status_url(dial: DialableApprovalOrigin, escalation_id: NonEmptyStr) -> NonEmptyStr { concat( - concat(origin as String, concat(approval_request_submit_path as String, "/")), + concat(dial.origin as String, concat(approval_request_submit_path as String, "/")), escalation_id as String, ) as NonEmptyStr } diff --git a/dag/gunbc/spark/pair_serving_d0_door.dag b/dag/gunbc/spark/pair_serving_d0_door.dag index 0d94722f359..84d48b5e732 100644 --- a/dag/gunbc/spark/pair_serving_d0_door.dag +++ b/dag/gunbc/spark/pair_serving_d0_door.dag @@ -17,10 +17,9 @@ import gunbc.auth.approval_gate { approval_instant_now, approval_expires_at, approval_standing_read, approval_poll_ticks, ApprovalStandingRead, StandingReadAt, StandingClockUnreadable, approval_file_stored_request, ApprovalFiled, ApprovalFilingRefused, } -import gunbc.auth.approval_request_client { PollStillPending, PollNotFiled, stored_request_from_authorization } +import gunbc.auth.approval_request_client { PollStillPending, PollNotFiled, stored_request_from_authorization, approval_loopback_origin, ApprovalLoopbackOrigin, ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused, approval_loopback_origin_refusal_detail } import gunbc.fabric_storage_client { FabricStorageBinding } import gunbc.actions_run_binding { actions_variable_read, ActionsVariablePresent, ActionsVariableAbsent, actions_variable_trimmed, fleet_converge_expected_host_env_name, read_expected_revision, ExpectedRevisionPresent, ExpectedRevisionAbsent } -import gunbc.fleet_host_identity { operator_host_srv1 } import extdeps.git import extdeps.git.inspect import extdeps.git.plumbing @@ -453,9 +452,10 @@ fn pair_serving_d0_wet(group: String, transaction: String, authorization: String // REVISION is the workflow's expected_revision (the ref the job checked out), frozen with the first // filing; a rerun that executes another revision refuses before polling or mutating anything. // -// THE EXECUTOR IS srv1, PROVED TWICE AND BEFORE ANY CREDENTIAL: the broker is loopback on the host -// that serves the dashboard. The dispatch's host input (the runner label the job was scheduled on) -// and the kernel's own hostname must both be srv1, and pair_serving_d0_admit_executor runs that +// THE EXECUTOR IS THE APPROVAL WRITER'S HOST, PROVED TWICE AND BEFORE ANY CREDENTIAL: the writer is +// loopback on the prod-role holder's host (gunbc.auth.approval_request_client approval_loopback_origin +// carries it). The dispatch's host input (the runner label the job was scheduled on) and the kernel's +// own hostname must both be that host -- under NoProdRole there is none and the mode refuses -- and pair_serving_d0_admit_executor runs that // admission as a credential-free step BEFORE the job acquires its WIF token, loads the fleet ssh key // or materializes the submission MAC key -- so a dispatch to another host refuses with no secret on // that host. The door's own entry re-runs the same admission after the credentials, from the same @@ -469,11 +469,20 @@ type D0ModeAdmission = D0ModeAdmitted { group: FabricGroup, transaction: NonEmptyStr, authorization: NonEmptyStr, execution_revision: NonEmptyStr } | D0ModeRefused { reason: String } -fn pair_serving_d0_mode_admission(declared_host: String, kernel_host: String, consent: String, execution_revision: String, checked_out_revision: String) -> D0ModeAdmission { - if declared_host != (operator_host_srv1 as String) { - D0ModeRefused { reason: join(["the dispatch names host ", declared_host, ": POST/GET /approvals is loopback on the machine that serves the dashboard, which is ", operator_host_srv1 as String], "") } - } else if kernel_host != (operator_host_srv1 as String) { - D0ModeRefused { reason: join(["the dispatch names ", operator_host_srv1 as String, " but this job runs on ", kernel_host, "; the broker is loopback on ", operator_host_srv1 as String], "") } +fn pair_serving_d0_mode_admission(writer: ApprovalLoopbackOrigin, declared_host: String, kernel_host: String, consent: String, execution_revision: String, checked_out_revision: String) -> D0ModeAdmission { + match writer { + ApprovalLoopbackOriginRefused { cause } => + D0ModeRefused { reason: approval_loopback_origin_refusal_detail(cause: cause) as String } + ApprovalLoopbackOriginResolved { host: writer_host, origin: _ } => + pair_serving_d0_mode_admission_on(writer_host: writer_host as String, declared_host: declared_host, kernel_host: kernel_host, consent: consent, execution_revision: execution_revision, checked_out_revision: checked_out_revision) + } +} + +fn pair_serving_d0_mode_admission_on(writer_host: String, declared_host: String, kernel_host: String, consent: String, execution_revision: String, checked_out_revision: String) -> D0ModeAdmission { + if declared_host != writer_host { + D0ModeRefused { reason: join(["the dispatch names host ", declared_host, ": POST/GET /approvals is loopback on the approval writer's host, the prod-role holder's, which is ", writer_host], "") } + } else if kernel_host != writer_host { + D0ModeRefused { reason: join(["the dispatch names ", writer_host, " but this job runs on ", kernel_host, "; the approval writer is loopback on ", writer_host], "") } } else if consent == "" { D0ModeRefused { reason: join([pair_serving_d0_consent_env as String, " is unset; the mode names no consent (escalation id), and a run must name the same one on every rerun"], "") } } else if execution_revision == "" { @@ -516,7 +525,7 @@ fn pair_serving_d0_kernel_host() -> String // holds any secret. fn pair_serving_d0_admit_executor() -> ProcessExit { - match pair_serving_d0_mode_admission(declared_host: pair_serving_d0_declared_host(), kernel_host: pair_serving_d0_kernel_host(), consent: actions_variable_trimmed(name: pair_serving_d0_consent_env), execution_revision: pair_serving_d0_execution_revision(), checked_out_revision: pair_serving_d0_checked_out_revision()) { + match pair_serving_d0_mode_admission(writer: approval_loopback_origin(), declared_host: pair_serving_d0_declared_host(), kernel_host: pair_serving_d0_kernel_host(), consent: actions_variable_trimmed(name: pair_serving_d0_consent_env), execution_revision: pair_serving_d0_execution_revision(), checked_out_revision: pair_serving_d0_checked_out_revision()) { D0ModeRefused { reason: r } => refuse(reason: r) D0ModeAdmitted { group: _, transaction: _, authorization: _, execution_revision: _ } => ExitSuccess } @@ -524,7 +533,7 @@ fn pair_serving_d0_admit_executor() -> ProcessExit fn pair_serving_d0_ci_wet() -> ProcessExit { - match pair_serving_d0_mode_admission(declared_host: pair_serving_d0_declared_host(), kernel_host: pair_serving_d0_kernel_host(), consent: actions_variable_trimmed(name: pair_serving_d0_consent_env), execution_revision: pair_serving_d0_execution_revision(), checked_out_revision: pair_serving_d0_checked_out_revision()) { + match pair_serving_d0_mode_admission(writer: approval_loopback_origin(), declared_host: pair_serving_d0_declared_host(), kernel_host: pair_serving_d0_kernel_host(), consent: actions_variable_trimmed(name: pair_serving_d0_consent_env), execution_revision: pair_serving_d0_execution_revision(), checked_out_revision: pair_serving_d0_checked_out_revision()) { D0ModeRefused { reason: r } => refuse(reason: r) D0ModeAdmitted { group: g, transaction: tx, authorization: auth, execution_revision: rev } => pair_serving_d0_wet(group: fabric_group_wire(g: g) as String, transaction: tx as String, authorization: auth as String, execution_revision: rev as String) diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag index e58b8855c2c..aa81caf9deb 100644 --- a/dag/test/claim/approval_request_client_witness_test.dag +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -1,10 +1,11 @@ module test.claim.approval_request_client_witness_test import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } -import std.types { Bool, NonEmptyStr, String } +import std.types { Bool, NonEmptyStr, String, List } import gunbc.auth.approval_request_client { - approval_submit_url, approval_status_url, approval_loopback_origin, approval_loopback_origin_for, approval_request_submit_path, + approval_submit_url, approval_status_url, approval_loopback_origin, approval_loopback_origin_for, approval_loopback_origin_of, approval_request_submit_path, ApprovalLoopbackOrigin, ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused, + approval_origin_admission, ApprovalOriginAdmission, ApprovalOriginDialable, ApprovalOriginUnresolved, ApprovalExecutorNotTheOriginHost, ApprovalExecutorHostUnobserved, approval_poll_standing, PollStillPending, PollApproved, PollExpired, PollUnreadable, StoredApprovalRequest, } @@ -12,9 +13,11 @@ import gunbc.auth.approval_status_wire { parse_filed_approval_status, filed_approval_status_text, submission_filed_text, submission_body_admits_poll, StatusNotFiled, StatusPending, StatusApproved, StatusDenied, StatusUnreadable, } -import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } -import gunbc.live_deploy.desired { prod_role_actuated_realization } -import gunbc.auth.approval_writer_authority { approval_writer_authority, approval_writer_process, RoadmapProcess, BrokerProcess } +import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance, srv2_deploy_dashboard_instance, ProdRoleRealization, ProdRoleUnheld } +import gunbc.deployment_risk { ProdRoleHeldBy, NoProdRole, srv2_deploy_deployment } +import gunbc.hostname_read { HostnameShortCapture, HostnameShortCaptured, HostnameShortCaptureRefused } +import gunbc.live_deploy.desired { prod_role_actuated_realization, prod_role_actuated_realization_in } +import gunbc.auth.approval_writer_authority { approval_writer_authority, approval_writer_process, ApprovalServingProcess, RoadmapProcess, BrokerProcess } import gunbc.auth.approval_broker_endpoint { approval_broker_loopback_base_url, approval_broker_listen_port } import gunbc.auth.approval_request_submission { submission_envelope_json } import gunbc.auth.approval_decision_store { approval_mac_key_id } @@ -36,40 +39,100 @@ fn fixture_stored() -> StoredApprovalRequest { } } -// The roadmap writer's origin is the prod-role holder's listen bind -- srv1-live under today's selection. -test fn submit_and_status_urls_follow_the_prod_role_holders_listen_bind_while_the_roadmap_writes() -> Bool { - let instance = srv1_live_dashboard_instance() - match approval_loopback_origin_for(writer: RoadmapProcess, realization: prod_role_actuated_realization()) { - ApprovalLoopbackOriginRefused { cause: _ } => false - ApprovalLoopbackOriginResolved { origin: o } => { - let origin = o as String - origin == concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) - && approval_submit_url(origin: o) as String == concat(origin, approval_request_submit_path as String) - && approval_status_url(origin: o, escalation_id: "esc-1") as String == concat(concat(origin, concat(approval_request_submit_path as String, "/")), "esc-1") +fn on_host(h: String) -> HostnameShortCapture { + HostnameShortCaptured { value: h } +} + +fn srv2_holds() -> ProdRoleRealization { + prod_role_actuated_realization_in(selection: ProdRoleHeldBy { deployment: srv2_deploy_deployment }) +} + +fn no_prod_role() -> ProdRoleRealization { + prod_role_actuated_realization_in(selection: NoProdRole) +} + +fn resolved_host_and_origin(o: ApprovalLoopbackOrigin) -> List { + match o { + ApprovalLoopbackOriginResolved { host, origin } => [host as String, origin as String] + ApprovalLoopbackOriginRefused { cause: _ } => [] + } +} + +fn dialable_url(a: ApprovalOriginAdmission) -> String { + match a { + ApprovalOriginDialable { dial } => approval_submit_url(dial: dial) as String + ApprovalOriginUnresolved { cause: _ } => "" + ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => "" + ApprovalExecutorHostUnobserved { reason: _ } => "" + } +} + +// (5) POSITIVE CONTROL: today's selection, the broker as writer, an executor on srv1 -- dialable, at +// the broker's loopback, and the URLs are that origin's. +test fn todays_broker_writer_on_the_srv1_holder_is_dialable_from_srv1() -> Bool { + let broker_origin = approval_broker_loopback_base_url(port: approval_broker_listen_port) as String + match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: prod_role_actuated_realization()), executor: on_host(h: "srv1")) { + ApprovalOriginDialable { dial } => + (dial.host as String) == "srv1" + && approval_submit_url(dial: dial) as String == concat(broker_origin, approval_request_submit_path as String) + && approval_status_url(dial: dial, escalation_id: "esc-1") as String == concat(concat(broker_origin, concat(approval_request_submit_path as String, "/")), "esc-1") + _ => false + } +} + +// (1) The role on srv2-deploy, the broker as writer, an executor on srv2: resolves and may dial. +test fn a_broker_writer_on_a_srv2_holder_is_dialable_from_srv2() -> Bool { + match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: srv2_holds()), executor: on_host(h: "srv2")) { + ApprovalOriginDialable { dial } => (dial.host as String) == "srv2" + _ => false + } +} + +// (2) The role on srv2-deploy, the broker as writer, an executor on srv1: refuses before any request, +// naming both hosts -- it does not dial srv1's loopback, where a stale broker may still answer. +test fn a_broker_writer_on_a_srv2_holder_refuses_a_srv1_executor_before_any_request() -> Bool { + match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: srv2_holds()), executor: on_host(h: "srv1")) { + ApprovalExecutorNotTheOriginHost { origin_host, executor_host } => (origin_host as String) == "srv2" && executor_host == "srv1" + _ => false + } +} + +// (3) Switching the writer to the roadmap changes the endpoint and keeps the holder's host. +test fn the_roadmap_writer_changes_the_endpoint_and_keeps_the_holders_host() -> Bool { + let r = srv2_holds() + let broker = resolved_host_and_origin(o: approval_loopback_origin_for(writer: BrokerProcess, realization: r)) + let roadmap = resolved_host_and_origin(o: approval_loopback_origin_for(writer: RoadmapProcess, realization: r)) + count(broker) == 2 && count(roadmap) == 2 + && broker == ["srv2", approval_broker_loopback_base_url(port: approval_broker_listen_port) as String] + && roadmap == ["srv2", approval_loopback_origin_of(instance: srv2_deploy_dashboard_instance()) as String] + && dialable_url(a: approval_origin_admission(origin: approval_loopback_origin_for(writer: RoadmapProcess, realization: r), executor: on_host(h: "srv2"))) != dialable_url(a: approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: r), executor: on_host(h: "srv2"))) +} + +// (4) NoProdRole refuses on both writer arms, whatever the executor is. +test fn no_prod_role_refuses_on_both_writer_arms() -> Bool { + let unresolved = fn(w: ApprovalServingProcess) -> Bool { + match approval_origin_admission(origin: approval_loopback_origin_for(writer: w, realization: no_prod_role()), executor: on_host(h: "srv1")) { + ApprovalOriginUnresolved { cause: ProdRoleUnheld } => true + _ => false } } + unresolved(RoadmapProcess) && unresolved(BrokerProcess) } -fn origin_text(o: ApprovalLoopbackOrigin) -> String { - match o { - ApprovalLoopbackOriginResolved { origin } => origin as String - ApprovalLoopbackOriginRefused { cause: _ } => "" +// An executor that cannot read its own hostname does not dial either. +test fn an_executor_whose_host_is_unobserved_refuses() -> Bool { + match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: prod_role_actuated_realization()), executor: HostnameShortCaptureRefused { reason: "hostname -s exited unsuccessfully" }) { + ApprovalExecutorHostUnobserved { reason: _ } => true + _ => false } } -// THE REGRESSION run 37265259209 hit: with the broker as writer the client must dial the broker's -// listen port, never the roadmap's -- the two ports differ, so a client still on the roadmap is red. -test fn submit_and_status_urls_dial_the_declared_writer() -> Bool { +// THE REGRESSION run 37265259209 hit: the live origin is the declared writer's, the broker's loopback +// when the broker writes, never the roadmap's port. +test fn the_live_origin_is_the_declared_writers() -> Bool { let r = prod_role_actuated_realization() - let origin = origin_text(o: approval_loopback_origin()) - let writer_origin = origin_text(o: approval_loopback_origin_for(writer: approval_writer_process(authority: approval_writer_authority), realization: r)) - let broker_origin = approval_broker_loopback_base_url(port: approval_broker_listen_port) as String - origin != "" - && origin == writer_origin - && origin_text(o: approval_loopback_origin_for(writer: BrokerProcess, realization: r)) == broker_origin - && broker_origin != origin_text(o: approval_loopback_origin_for(writer: RoadmapProcess, realization: r)) - && approval_submit_url(origin: origin as NonEmptyStr) as String == concat(origin, "/approvals") - && approval_status_url(origin: origin as NonEmptyStr, escalation_id: "esc-1") as String == concat(origin, "/approvals/esc-1") + resolved_host_and_origin(o: approval_loopback_origin()) == resolved_host_and_origin(o: approval_loopback_origin_for(writer: approval_writer_process(authority: approval_writer_authority), realization: r)) + && resolved_host_and_origin(o: approval_loopback_origin_for(writer: BrokerProcess, realization: r)) != resolved_host_and_origin(o: approval_loopback_origin_for(writer: RoadmapProcess, realization: r)) } test fn submit_body_nests_the_stored_request_as_a_json_string() -> Bool { diff --git a/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag b/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag index e44e73f09ce..6cea96a98d6 100644 --- a/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag +++ b/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag @@ -9,6 +9,8 @@ import std.scoped_authorization { AuthorizationRequest, AuthorizationGranted } import gunbc.auth.approval_decision_store { request_revision_of } import gunbc.auth.approval_request_client { PollApproved, PollNotFiled, PollStillPending, PollDenied } import gunbc.auth.approval_gate { StandingReadAt, StandingClockUnreadable, ApprovalDenied } +import gunbc.auth.approval_request_client { ApprovalLoopbackOrigin, ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused } +import gunbc.roadmap_dashboard_instance { ProdRoleUnheld } import gunbc.spark.pair_serving_d0_door { d0_absent_claim_step, D0AbsentClaimClockUnreadable, D0AbsentClaimFileFrozen, D0AbsentClaimEnterPoll, D0AbsentClaimDecided, pair_serving_d0_mode_admission, D0ModeAdmitted, D0ModeRefused } import gunbc.spark.fabric_switch_observed { fabric_group_wire } import gunbc.spark.fabric_switch_observed { FabricGroupA } @@ -170,13 +172,32 @@ test fn a_frozen_filing_with_no_claim_files_polls_or_decides_live_by_the_read() // revision that is not the checked-out tree's HEAD each refuse. test fn the_mode_admits_only_srv1_group_a_and_a_named_consent_and_revision() -> Bool { let rev = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - (match pair_serving_d0_mode_admission(declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { + (match pair_serving_d0_mode_admission(writer: srv1_writer(), declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeAdmitted { group: g, transaction: tx, authorization: a, execution_revision: r } => (fabric_group_wire(g: g) as String) == "group-a" && (tx as String) == "d0-esc-1" && (a as String) == "d0-esc-1" && (r as String) == rev D0ModeRefused { reason: _ } => false }) - && (match pair_serving_d0_mode_admission(declared_host: "srv2", kernel_host: "srv2", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("srv2") _ => false }) - && (match pair_serving_d0_mode_admission(declared_host: "srv1", kernel_host: "srv2", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("runs on srv2") _ => false }) - && (match pair_serving_d0_mode_admission(declared_host: "srv1", kernel_host: "srv1", consent: "", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("GUNBC_D0_CONSENT") _ => false }) - && (match pair_serving_d0_mode_admission(declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: "", checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("expected_revision") _ => false }) - && (match pair_serving_d0_mode_admission(declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { D0ModeRefused { reason: r } => r.contains("not the tree this job runs from") _ => false }) + && (match pair_serving_d0_mode_admission(writer: srv1_writer(), declared_host: "srv2", kernel_host: "srv2", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("srv2") _ => false }) + && (match pair_serving_d0_mode_admission(writer: srv1_writer(), declared_host: "srv1", kernel_host: "srv2", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("runs on srv2") _ => false }) + && (match pair_serving_d0_mode_admission(writer: srv1_writer(), declared_host: "srv1", kernel_host: "srv1", consent: "", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("GUNBC_D0_CONSENT") _ => false }) + && (match pair_serving_d0_mode_admission(writer: srv1_writer(), declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: "", checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("expected_revision") _ => false }) + && (match pair_serving_d0_mode_admission(writer: srv1_writer(), declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { D0ModeRefused { reason: r } => r.contains("not the tree this job runs from") _ => false }) +} + +// The writer origin is supplied at the admission's interface (gunbc.auth.approval_request_client +// resolves it from the prod-role holder; test.claim.approval_request_client_witness_test runs that). +fn srv1_writer() -> ApprovalLoopbackOrigin { + ApprovalLoopbackOriginResolved { host: "srv1" as NonEmptyStr, origin: "http://127.0.0.1:8085" as NonEmptyStr } +} + +fn srv2_writer() -> ApprovalLoopbackOrigin { + ApprovalLoopbackOriginResolved { host: "srv2" as NonEmptyStr, origin: "http://127.0.0.1:8085" as NonEmptyStr } +} + +// THE EXECUTOR FOLLOWS THE WRITER'S HOST: with the role on srv2 the mode admits a srv2 dispatch on a +// srv2 kernel and refuses srv1 before any credential; with no prod role it refuses outright. +test fn the_d0_executor_is_the_approval_writers_host_and_no_prod_role_refuses() -> Bool { + let rev = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + (match pair_serving_d0_mode_admission(writer: srv2_writer(), declared_host: "srv2", kernel_host: "srv2", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeAdmitted { group: _, transaction: _, authorization: _, execution_revision: _ } => true D0ModeRefused { reason: _ } => false }) + && (match pair_serving_d0_mode_admission(writer: srv2_writer(), declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: r } => r.contains("srv2") _ => false }) + && (match pair_serving_d0_mode_admission(writer: ApprovalLoopbackOriginRefused { cause: ProdRoleUnheld }, declared_host: "srv1", kernel_host: "srv1", consent: "d0-esc-1", execution_revision: rev, checked_out_revision: rev) { D0ModeRefused { reason: _ } => true _ => false }) } From c4deed6a62f17e452da47c1c5985188702c9b877 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 10:18:14 +0000 Subject: [PATCH 43/48] approval client witness: no-prod-role helper as a declared fn Co-Authored-By: Claude Opus 5.5 (1M context) --- .../approval_request_client_witness_test.dag | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag index aa81caf9deb..d03ca02755a 100644 --- a/dag/test/claim/approval_request_client_witness_test.dag +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -109,14 +109,15 @@ test fn the_roadmap_writer_changes_the_endpoint_and_keeps_the_holders_host() -> } // (4) NoProdRole refuses on both writer arms, whatever the executor is. -test fn no_prod_role_refuses_on_both_writer_arms() -> Bool { - let unresolved = fn(w: ApprovalServingProcess) -> Bool { - match approval_origin_admission(origin: approval_loopback_origin_for(writer: w, realization: no_prod_role()), executor: on_host(h: "srv1")) { - ApprovalOriginUnresolved { cause: ProdRoleUnheld } => true - _ => false - } +fn unresolved_under_no_prod_role(w: ApprovalServingProcess) -> Bool { + match approval_origin_admission(origin: approval_loopback_origin_for(writer: w, realization: no_prod_role()), executor: on_host(h: "srv1")) { + ApprovalOriginUnresolved { cause: ProdRoleUnheld } => true + _ => false } - unresolved(RoadmapProcess) && unresolved(BrokerProcess) +} + +test fn no_prod_role_refuses_on_both_writer_arms() -> Bool { + unresolved_under_no_prod_role(w: RoadmapProcess) && unresolved_under_no_prod_role(w: BrokerProcess) } // An executor that cannot read its own hostname does not dial either. From df94130d58919e40a1c183a4fff0994db907936a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 10:45:27 +0000 Subject: [PATCH 44/48] approval origin: pure classify_approval_origin returns a standing, no token; the DialableApprovalOrigin mint is sealed to approval_origin_admission_here, which reads the hostname itself and is sealed to the two approval_gate compositions; approval_file_stored_request_at sealed to approval_file_stored_request; compile REDs for forged mint, literal, outside admission and outside filing step, with the canonical-routes control Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/auth/approval_gate.dag | 28 ++--- dag/gunbc/auth/approval_request_client.dag | 51 ++++++--- .../approval_request_client_witness_test.dag | 106 ++++++++++++------ 3 files changed, 121 insertions(+), 64 deletions(-) diff --git a/dag/gunbc/auth/approval_gate.dag b/dag/gunbc/auth/approval_gate.dag index 00412171970..da1dec134ab 100644 --- a/dag/gunbc/auth/approval_gate.dag +++ b/dag/gunbc/auth/approval_gate.dag @@ -1,5 +1,6 @@ module gunbc.auth.approval_gate +import std.decl_ref { decl_ref } import std.types { String, Bool, Int, NonEmptyStr, List, Timestamp } import std.measure { Second, second_count } import v2.std.optional { Present, Absent } @@ -25,8 +26,8 @@ import gunbc.auth.approval_decision_store { grant_from_approved_decision, GrantD import gunbc.auth.approval_keyring_pins { approval_submission_mac_key_path_env } import gunbc.auth.approval_request_client { sign_stored_request, approval_submit_url, approval_status_url, approval_poll_standing, - approval_origin_admission_here, approval_origin_admission_refusal_detail, DialableApprovalOrigin, - ApprovalOriginDialable, ApprovalOriginUnresolved, ApprovalExecutorNotTheOriginHost, ApprovalExecutorHostUnobserved, + approval_origin_admission_here, approval_origin_standing_detail, DialableApprovalOrigin, + ApprovalOriginDialable, ApprovalOriginNotAdmitted, SubmissionMacReady, SubmissionMacKeyNotHex, ApprovalPollStanding, PollApproved, PollDenied, PollExpired, PollStillPending, PollNotFiled, PollUnreadable, } @@ -193,14 +194,9 @@ fn approval_standing_read(escalation_id: NonEmptyStr, expires_at: Timestamp) -> match approval_instant_now() { Absent => StandingClockUnreadable Present { value: observed } => { - let admission = approval_origin_admission_here() - match admission { - ApprovalOriginUnresolved { cause: _ } => - StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_origin_admission_refusal_detail(a: admission) }, observed_at: observed, expires_at: expires_at), observed_at: observed } - ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => - StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_origin_admission_refusal_detail(a: admission) }, observed_at: observed, expires_at: expires_at), observed_at: observed } - ApprovalExecutorHostUnobserved { reason: _ } => - StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_origin_admission_refusal_detail(a: admission) }, observed_at: observed, expires_at: expires_at), observed_at: observed } + match approval_origin_admission_here() { + ApprovalOriginNotAdmitted { standing } => + StandingReadAt { standing: approval_poll_standing(status: StatusUnreadable { detail: approval_origin_standing_detail(standing: standing) }, observed_at: observed, expires_at: expires_at), observed_at: observed } ApprovalOriginDialable { dial } => { let got = http.Client.GetLocalhostBounded(url: approval_status_url(dial: dial, escalation_id: escalation_id)) StandingReadAt { standing: approval_poll_standing(status: approval_status_from_get(success: got.success, body: got.body), observed_at: observed, expires_at: expires_at), observed_at: observed } @@ -266,17 +262,15 @@ type ApprovalFiling fn approval_file_stored_request(stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling { - let admission = approval_origin_admission_here() - match admission { + match approval_origin_admission_here() { ApprovalOriginDialable { dial } => approval_file_stored_request_at(dial: dial, stored: stored, body_file_name: body_file_name, submit_timeout: submit_timeout) - ApprovalOriginUnresolved { cause: _ } => ApprovalFilingRefused { reason: approval_origin_admission_refusal_detail(a: admission) as String } - ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => ApprovalFilingRefused { reason: approval_origin_admission_refusal_detail(a: admission) as String } - ApprovalExecutorHostUnobserved { reason: _ } => ApprovalFilingRefused { reason: approval_origin_admission_refusal_detail(a: admission) as String } + ApprovalOriginNotAdmitted { standing } => ApprovalFilingRefused { reason: approval_origin_standing_detail(standing: standing) as String } } } -fn approval_file_stored_request_at(dial: DialableApprovalOrigin, stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling -{ +fn approval_file_stored_request_at(dial: DialableApprovalOrigin, stored: StoredApprovalRequest, body_file_name: String, submit_timeout: Second) -> ApprovalFiling admit_callers: [ + decl_ref(module_path: "gunbc.auth.approval_gate", decl_name: "approval_file_stored_request"), +] { let sub_path = actions_variable_trimmed(name: approval_submission_mac_key_path_env) if sub_path == "" { ApprovalFilingRefused { reason: join([approval_submission_mac_key_path_env as String, " is unset; the request cannot be filed"], "") } diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag index fbbd15c3be7..75fcad027c3 100644 --- a/dag/gunbc/auth/approval_request_client.dag +++ b/dag/gunbc/auth/approval_request_client.dag @@ -2,6 +2,7 @@ module gunbc.auth.approval_request_client import gunbc.live_deploy.desired { prod_role_actuated_realization } import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } +import std.decl_ref { decl_ref } import std.scoped_authorization { AuthorizationRequest } import extdeps.crypto.mac { MacKey, MacSigned, MacKeyMaterialNotHex, HmacSha256, MacKeyId, @@ -83,22 +84,25 @@ fn approval_loopback_origin_refusal_detail(cause: ProdRoleRealizationRefusal) -> } // ── THE EXECUTOR MUST BE THE ORIGIN'S HOST, BEFORE ANY LOCAL HTTP ───────────────────────────── -// The only way to obtain a URL is from a DialableApprovalOrigin, and its one mint is the admission -// below, which admits an origin only when the executor's own short hostname (gunbc.hostname_read, the -// established local-host read) IS the origin's host. So an executor that is not the prod-role -// holder's host -- or cannot read its own hostname -- refuses before any request is built. +// CLASSIFIER VERSUS CAPABILITY, the split gunbc.live_deploy.role_singleton_convergence uses for its +// admission. classify_approval_origin is pure and fixture-callable and returns a STANDING, never a +// token. The token -- DialableApprovalOrigin, the only thing a URL can be built from -- is minted by +// one sealed function, reachable only from approval_origin_admission_here, which reads this +// executor's own short hostname itself (gunbc.hostname_read, the established local-host read) and +// is in turn sealed to the two effectful approval_gate compositions. So no caller can hand in a +// hostname, and no URL exists for an executor that is not the prod-role holder's host. type DialableApprovalOrigin sole_constructor { host: NonEmptyStr origin: NonEmptyStr } -type ApprovalOriginAdmission - = ApprovalOriginDialable { dial: DialableApprovalOrigin } +type ApprovalOriginStanding + = ApprovalOriginWouldDial { host: NonEmptyStr, origin: NonEmptyStr } | ApprovalOriginUnresolved { cause: ProdRoleRealizationRefusal } | ApprovalExecutorNotTheOriginHost { origin_host: NonEmptyStr, executor_host: String } | ApprovalExecutorHostUnobserved { reason: String } -fn approval_origin_admission(origin: ApprovalLoopbackOrigin, executor: HostnameShortCapture) -> ApprovalOriginAdmission { +fn classify_approval_origin(origin: ApprovalLoopbackOrigin, executor: HostnameShortCapture) -> ApprovalOriginStanding { match origin { ApprovalLoopbackOriginRefused { cause } => ApprovalOriginUnresolved { cause: cause } ApprovalLoopbackOriginResolved { host, origin: o } => @@ -106,7 +110,7 @@ fn approval_origin_admission(origin: ApprovalLoopbackOrigin, executor: HostnameS HostnameShortCaptureRefused { reason } => ApprovalExecutorHostUnobserved { reason: reason } HostnameShortCaptured { value } => if value == (host as String) { - ApprovalOriginDialable { dial: DialableApprovalOrigin { host: host, origin: o } } + ApprovalOriginWouldDial { host: host, origin: o } } else { ApprovalExecutorNotTheOriginHost { origin_host: host, executor_host: value } } @@ -114,14 +118,33 @@ fn approval_origin_admission(origin: ApprovalLoopbackOrigin, executor: HostnameS } } -// The live admission: the actuated origin against this process's own host. -fn approval_origin_admission_here() -> ApprovalOriginAdmission { - approval_origin_admission(origin: approval_loopback_origin(), executor: hostname_short_read(transport: LocalShell)) +type ApprovalOriginAdmission + = ApprovalOriginDialable { dial: DialableApprovalOrigin } + | ApprovalOriginNotAdmitted { standing: ApprovalOriginStanding } + +fn mint_dialable_approval_origin(host: NonEmptyStr, origin: NonEmptyStr) -> DialableApprovalOrigin admit_callers: [ + decl_ref(module_path: "gunbc.auth.approval_request_client", decl_name: "approval_origin_admission_here"), +] { + DialableApprovalOrigin { host: host, origin: origin } +} + +// The live admission: the actuated origin against this process's own host, read here and nowhere else. +fn approval_origin_admission_here() -> ApprovalOriginAdmission admit_callers: [ + decl_ref(module_path: "gunbc.auth.approval_gate", decl_name: "approval_standing_read"), + decl_ref(module_path: "gunbc.auth.approval_gate", decl_name: "approval_file_stored_request"), +] { + let standing = classify_approval_origin(origin: approval_loopback_origin(), executor: hostname_short_read(transport: LocalShell)) + match standing { + ApprovalOriginWouldDial { host, origin } => ApprovalOriginDialable { dial: mint_dialable_approval_origin(host: host, origin: origin) } + ApprovalOriginUnresolved { cause: _ } => ApprovalOriginNotAdmitted { standing: standing } + ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => ApprovalOriginNotAdmitted { standing: standing } + ApprovalExecutorHostUnobserved { reason: _ } => ApprovalOriginNotAdmitted { standing: standing } + } } -fn approval_origin_admission_refusal_detail(a: ApprovalOriginAdmission) -> NonEmptyStr { - match a { - ApprovalOriginDialable { dial: _ } => "approval origin admitted" as NonEmptyStr +fn approval_origin_standing_detail(standing: ApprovalOriginStanding) -> NonEmptyStr { + match standing { + ApprovalOriginWouldDial { host: _, origin: _ } => "approval origin admitted" as NonEmptyStr ApprovalOriginUnresolved { cause } => approval_loopback_origin_refusal_detail(cause: cause) ApprovalExecutorNotTheOriginHost { origin_host, executor_host } => join(["approval writer is loopback on ", origin_host as String, " (the prod-role holder's host) but this executor is ", executor_host, "; refusing before any request"], "") as NonEmptyStr diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag index d03ca02755a..9c70db8f1e7 100644 --- a/dag/test/claim/approval_request_client_witness_test.dag +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -1,11 +1,13 @@ module test.claim.approval_request_client_witness_test import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } -import std.types { Bool, NonEmptyStr, String, List } +import std.types { Bool, NonEmptyStr, String, List, Int } +import v2.std.collection { Present, Absent } +import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } import gunbc.auth.approval_request_client { - approval_submit_url, approval_status_url, approval_loopback_origin, approval_loopback_origin_for, approval_loopback_origin_of, approval_request_submit_path, + approval_loopback_origin, approval_loopback_origin_for, approval_loopback_origin_of, approval_request_submit_path, ApprovalLoopbackOrigin, ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused, - approval_origin_admission, ApprovalOriginAdmission, ApprovalOriginDialable, ApprovalOriginUnresolved, ApprovalExecutorNotTheOriginHost, ApprovalExecutorHostUnobserved, + classify_approval_origin, ApprovalOriginStanding, ApprovalOriginWouldDial, ApprovalOriginUnresolved, ApprovalExecutorNotTheOriginHost, ApprovalExecutorHostUnobserved, approval_poll_standing, PollStillPending, PollApproved, PollExpired, PollUnreadable, StoredApprovalRequest, } @@ -58,40 +60,35 @@ fn resolved_host_and_origin(o: ApprovalLoopbackOrigin) -> List { } } -fn dialable_url(a: ApprovalOriginAdmission) -> String { - match a { - ApprovalOriginDialable { dial } => approval_submit_url(dial: dial) as String - ApprovalOriginUnresolved { cause: _ } => "" - ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => "" - ApprovalExecutorHostUnobserved { reason: _ } => "" +fn would_dial(st: ApprovalOriginStanding) -> List { + match st { + ApprovalOriginWouldDial { host, origin } => [host as String, origin as String] + ApprovalOriginUnresolved { cause: _ } => [] + ApprovalExecutorNotTheOriginHost { origin_host: _, executor_host: _ } => [] + ApprovalExecutorHostUnobserved { reason: _ } => [] } } -// (5) POSITIVE CONTROL: today's selection, the broker as writer, an executor on srv1 -- dialable, at -// the broker's loopback, and the URLs are that origin's. +// The behavioural REDs run the pure classifier, which returns a STANDING and no dial token; the token +// is minted only on the sealed live route (the compile REDs below). + +// (5) POSITIVE CONTROL: today's selection, the broker as writer, an executor on srv1 -- would dial the +// broker's loopback on srv1. test fn todays_broker_writer_on_the_srv1_holder_is_dialable_from_srv1() -> Bool { - let broker_origin = approval_broker_loopback_base_url(port: approval_broker_listen_port) as String - match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: prod_role_actuated_realization()), executor: on_host(h: "srv1")) { - ApprovalOriginDialable { dial } => - (dial.host as String) == "srv1" - && approval_submit_url(dial: dial) as String == concat(broker_origin, approval_request_submit_path as String) - && approval_status_url(dial: dial, escalation_id: "esc-1") as String == concat(concat(broker_origin, concat(approval_request_submit_path as String, "/")), "esc-1") - _ => false - } + would_dial(st: classify_approval_origin(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: prod_role_actuated_realization()), executor: on_host(h: "srv1"))) + == ["srv1", approval_broker_loopback_base_url(port: approval_broker_listen_port) as String] } -// (1) The role on srv2-deploy, the broker as writer, an executor on srv2: resolves and may dial. +// (1) The role on srv2-deploy, the broker as writer, an executor on srv2: would dial. test fn a_broker_writer_on_a_srv2_holder_is_dialable_from_srv2() -> Bool { - match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: srv2_holds()), executor: on_host(h: "srv2")) { - ApprovalOriginDialable { dial } => (dial.host as String) == "srv2" - _ => false - } + would_dial(st: classify_approval_origin(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: srv2_holds()), executor: on_host(h: "srv2"))) + == ["srv2", approval_broker_loopback_base_url(port: approval_broker_listen_port) as String] } // (2) The role on srv2-deploy, the broker as writer, an executor on srv1: refuses before any request, // naming both hosts -- it does not dial srv1's loopback, where a stale broker may still answer. test fn a_broker_writer_on_a_srv2_holder_refuses_a_srv1_executor_before_any_request() -> Bool { - match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: srv2_holds()), executor: on_host(h: "srv1")) { + match classify_approval_origin(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: srv2_holds()), executor: on_host(h: "srv1")) { ApprovalExecutorNotTheOriginHost { origin_host, executor_host } => (origin_host as String) == "srv2" && executor_host == "srv1" _ => false } @@ -100,17 +97,15 @@ test fn a_broker_writer_on_a_srv2_holder_refuses_a_srv1_executor_before_any_requ // (3) Switching the writer to the roadmap changes the endpoint and keeps the holder's host. test fn the_roadmap_writer_changes_the_endpoint_and_keeps_the_holders_host() -> Bool { let r = srv2_holds() - let broker = resolved_host_and_origin(o: approval_loopback_origin_for(writer: BrokerProcess, realization: r)) - let roadmap = resolved_host_and_origin(o: approval_loopback_origin_for(writer: RoadmapProcess, realization: r)) - count(broker) == 2 && count(roadmap) == 2 - && broker == ["srv2", approval_broker_loopback_base_url(port: approval_broker_listen_port) as String] - && roadmap == ["srv2", approval_loopback_origin_of(instance: srv2_deploy_dashboard_instance()) as String] - && dialable_url(a: approval_origin_admission(origin: approval_loopback_origin_for(writer: RoadmapProcess, realization: r), executor: on_host(h: "srv2"))) != dialable_url(a: approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: r), executor: on_host(h: "srv2"))) + would_dial(st: classify_approval_origin(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: r), executor: on_host(h: "srv2"))) + == ["srv2", approval_broker_loopback_base_url(port: approval_broker_listen_port) as String] + && would_dial(st: classify_approval_origin(origin: approval_loopback_origin_for(writer: RoadmapProcess, realization: r), executor: on_host(h: "srv2"))) + == ["srv2", approval_loopback_origin_of(instance: srv2_deploy_dashboard_instance()) as String] } // (4) NoProdRole refuses on both writer arms, whatever the executor is. fn unresolved_under_no_prod_role(w: ApprovalServingProcess) -> Bool { - match approval_origin_admission(origin: approval_loopback_origin_for(writer: w, realization: no_prod_role()), executor: on_host(h: "srv1")) { + match classify_approval_origin(origin: approval_loopback_origin_for(writer: w, realization: no_prod_role()), executor: on_host(h: "srv1")) { ApprovalOriginUnresolved { cause: ProdRoleUnheld } => true _ => false } @@ -122,12 +117,57 @@ test fn no_prod_role_refuses_on_both_writer_arms() -> Bool { // An executor that cannot read its own hostname does not dial either. test fn an_executor_whose_host_is_unobserved_refuses() -> Bool { - match approval_origin_admission(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: prod_role_actuated_realization()), executor: HostnameShortCaptureRefused { reason: "hostname -s exited unsuccessfully" }) { + match classify_approval_origin(origin: approval_loopback_origin_for(writer: BrokerProcess, realization: prod_role_actuated_realization()), executor: HostnameShortCaptureRefused { reason: "hostname -s exited unsuccessfully" }) { ApprovalExecutorHostUnobserved { reason: _ } => true _ => false } } +// ── THE DIAL TOKEN CANNOT BE FORGED, AND THE FILING STEP CANNOT BE REACHED AROUND IT ─────────── +// One compile per claim (each compile probe costs the census, gunbc.recurring_failure_mode +// entry_scoped_typecheck_rss_tracks_the_name_census). +data forged_dial_mint_source: String = "module probe_forged_dial_mint\nimport gunbc.auth.approval_request_client { mint_dialable_approval_origin, DialableApprovalOrigin }\nfn forge() -> DialableApprovalOrigin { mint_dialable_approval_origin(host: \"srv1\", origin: \"http://127.0.0.1:8085\") }\n" + +data forged_dial_literal_source: String = "module probe_forged_dial_literal\nimport gunbc.auth.approval_request_client { DialableApprovalOrigin }\nfn forge() -> DialableApprovalOrigin { DialableApprovalOrigin { host: \"srv1\", origin: \"http://127.0.0.1:8085\" } }\n" + +data outside_admission_here_source: String = "module probe_outside_admission_here\nimport gunbc.auth.approval_request_client { approval_origin_admission_here, ApprovalOriginAdmission }\nfn obtain() -> ApprovalOriginAdmission { approval_origin_admission_here() }\n" + +data outside_filing_step_source: String = "module probe_outside_filing_step\nimport gunbc.auth.approval_gate { approval_file_stored_request_at, ApprovalFiling }\nimport gunbc.auth.approval_request_client { DialableApprovalOrigin }\nimport gunbc.auth.approval_decision_store { StoredApprovalRequest }\nimport std.measure { Second }\nfn bypass(d: DialableApprovalOrigin, s: StoredApprovalRequest, t: Second) -> ApprovalFiling { approval_file_stored_request_at(dial: d, stored: s, body_file_name: \"x\", submit_timeout: t) }\n" + +data canonical_approval_routes_source: String = "module probe_canonical_approval_routes\nimport gunbc.auth.approval_gate { approval_file_stored_request, approval_standing_read, ApprovalFiling, ApprovalStandingRead }\nimport gunbc.auth.approval_decision_store { StoredApprovalRequest }\nimport std.measure { Second }\nimport std.types { Timestamp, NonEmptyStr }\nfn file(s: StoredApprovalRequest, t: Second) -> ApprovalFiling { approval_file_stored_request(stored: s, body_file_name: \"x\", submit_timeout: t) }\nfn read(e: NonEmptyStr, x: Timestamp) -> ApprovalStandingRead { approval_standing_read(escalation_id: e, expires_at: x) }\n" + +fn class_refusals_in(source: String, wanted: String) -> Int { + match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: wanted) { + Present { value: n } => n + Absent => 0 + } +} + +// (1) An outside module cannot mint the token: not through the mint, not as a literal, and not by +// calling the live admission (which is where the hostname is read). +test fn no_outside_module_can_mint_a_dial_token_through_the_mint() -> Bool { + class_refusals_in(source: forged_dial_mint_source, wanted: "ConstructorCallAdmissionRefused") >= 1 +} + +test fn no_outside_module_can_write_a_dial_token_literal() -> Bool { + class_refusals_in(source: forged_dial_literal_source, wanted: "SoleConstructorViolation") >= 1 +} + +test fn no_outside_module_can_call_the_live_origin_admission() -> Bool { + class_refusals_in(source: outside_admission_here_source, wanted: "ConstructorCallAdmissionRefused") >= 1 +} + +// (2) The filing step that reads the key, writes the envelope and POSTs is reachable only from the +// filing composition that admitted the origin. +test fn no_outside_module_can_call_the_filing_step() -> Bool { + class_refusals_in(source: outside_filing_step_source, wanted: "ConstructorCallAdmissionRefused") >= 1 +} + +// (3) THE CONTROL: the canonical filing and standing-read routes compile with no admission refusal. +test fn the_canonical_filing_and_standing_read_routes_compile() -> Bool { + class_refusals_in(source: canonical_approval_routes_source, wanted: "ConstructorCallAdmissionRefused") == 0 +} + // THE REGRESSION run 37265259209 hit: the live origin is the declared writer's, the broker's loopback // when the broker writes, never the roadmap's port. test fn the_live_origin_is_the_declared_writers() -> Bool { From 25dc63765fadc3fa4a159547bf008f94a719c095 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Mon, 5 Oct 2026 12:32:36 +0000 Subject: [PATCH 45/48] compile probes: one forged-routes source per witness file, each claim reading its refusal by class and subject (the census memoizes by source), cutting the floor's probe compiles from 14 to 5 after the floor hit its 90-minute cap Co-Authored-By: Claude Opus 5.5 (1M context) --- .../approval_request_client_witness_test.dag | 37 ++++++++-------- .../claim/deployment_risk_witness_test.dag | 43 ++++++++----------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag index 9c70db8f1e7..e0bcee405ea 100644 --- a/dag/test/claim/approval_request_client_witness_test.dag +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -3,7 +3,7 @@ module test.claim.approval_request_client_witness_test import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.types { Bool, NonEmptyStr, String, List, Int } import v2.std.collection { Present, Absent } -import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } +import gunbc.guarantee_probe_corpus { census_blocking_count_for_class, census_blocking_count_for_subject } import gunbc.auth.approval_request_client { approval_loopback_origin, approval_loopback_origin_for, approval_loopback_origin_of, approval_request_submit_path, ApprovalLoopbackOrigin, ApprovalLoopbackOriginResolved, ApprovalLoopbackOriginRefused, @@ -124,20 +124,23 @@ test fn an_executor_whose_host_is_unobserved_refuses() -> Bool { } // ── THE DIAL TOKEN CANNOT BE FORGED, AND THE FILING STEP CANNOT BE REACHED AROUND IT ─────────── -// One compile per claim (each compile probe costs the census, gunbc.recurring_failure_mode -// entry_scoped_typecheck_rss_tracks_the_name_census). -data forged_dial_mint_source: String = "module probe_forged_dial_mint\nimport gunbc.auth.approval_request_client { mint_dialable_approval_origin, DialableApprovalOrigin }\nfn forge() -> DialableApprovalOrigin { mint_dialable_approval_origin(host: \"srv1\", origin: \"http://127.0.0.1:8085\") }\n" - -data forged_dial_literal_source: String = "module probe_forged_dial_literal\nimport gunbc.auth.approval_request_client { DialableApprovalOrigin }\nfn forge() -> DialableApprovalOrigin { DialableApprovalOrigin { host: \"srv1\", origin: \"http://127.0.0.1:8085\" } }\n" - -data outside_admission_here_source: String = "module probe_outside_admission_here\nimport gunbc.auth.approval_request_client { approval_origin_admission_here, ApprovalOriginAdmission }\nfn obtain() -> ApprovalOriginAdmission { approval_origin_admission_here() }\n" - -data outside_filing_step_source: String = "module probe_outside_filing_step\nimport gunbc.auth.approval_gate { approval_file_stored_request_at, ApprovalFiling }\nimport gunbc.auth.approval_request_client { DialableApprovalOrigin }\nimport gunbc.auth.approval_decision_store { StoredApprovalRequest }\nimport std.measure { Second }\nfn bypass(d: DialableApprovalOrigin, s: StoredApprovalRequest, t: Second) -> ApprovalFiling { approval_file_stored_request_at(dial: d, stored: s, body_file_name: \"x\", submit_timeout: t) }\n" +// ONE probe source carries every forged route, one function each, so the floor compiles it once (the +// census is memoized by source; each compile costs the census, gunbc.recurring_failure_mode +// entry_scoped_typecheck_rss_tracks_the_name_census) and each claim reads ITS refusal by class AND +// subject -- the refused callee or type -- so no claim can pass on another route's refusal. +data forged_approval_routes_source: String = "module probe_forged_approval_routes\nimport gunbc.auth.approval_request_client { mint_dialable_approval_origin, DialableApprovalOrigin, approval_origin_admission_here, ApprovalOriginAdmission }\nimport gunbc.auth.approval_gate { approval_file_stored_request_at, ApprovalFiling }\nimport gunbc.auth.approval_decision_store { StoredApprovalRequest }\nimport std.measure { Second }\nfn forge_by_mint() -> DialableApprovalOrigin { mint_dialable_approval_origin(host: \"srv1\", origin: \"http://127.0.0.1:8085\") }\nfn forge_by_literal() -> DialableApprovalOrigin { DialableApprovalOrigin { host: \"srv1\", origin: \"http://127.0.0.1:8085\" } }\nfn obtain_admission() -> ApprovalOriginAdmission { approval_origin_admission_here() }\nfn bypass_filing(d: DialableApprovalOrigin, s: StoredApprovalRequest, t: Second) -> ApprovalFiling { approval_file_stored_request_at(dial: d, stored: s, body_file_name: \"x\", submit_timeout: t) }\n" data canonical_approval_routes_source: String = "module probe_canonical_approval_routes\nimport gunbc.auth.approval_gate { approval_file_stored_request, approval_standing_read, ApprovalFiling, ApprovalStandingRead }\nimport gunbc.auth.approval_decision_store { StoredApprovalRequest }\nimport std.measure { Second }\nimport std.types { Timestamp, NonEmptyStr }\nfn file(s: StoredApprovalRequest, t: Second) -> ApprovalFiling { approval_file_stored_request(stored: s, body_file_name: \"x\", submit_timeout: t) }\nfn read(e: NonEmptyStr, x: Timestamp) -> ApprovalStandingRead { approval_standing_read(escalation_id: e, expires_at: x) }\n" -fn class_refusals_in(source: String, wanted: String) -> Int { - match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: wanted) { +fn forged_route_refusals(class: String, subject: String) -> Int { + match census_blocking_count_for_subject(census: compile_dag_diagnostic_census(forged_approval_routes_source), wanted_class: class, wanted_subject: subject) { + Present { value: n } => n + Absent => 0 + } +} + +fn admission_refusals_in(source: String) -> Int { + match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: "ConstructorCallAdmissionRefused") { Present { value: n } => n Absent => 0 } @@ -146,26 +149,26 @@ fn class_refusals_in(source: String, wanted: String) -> Int { // (1) An outside module cannot mint the token: not through the mint, not as a literal, and not by // calling the live admission (which is where the hostname is read). test fn no_outside_module_can_mint_a_dial_token_through_the_mint() -> Bool { - class_refusals_in(source: forged_dial_mint_source, wanted: "ConstructorCallAdmissionRefused") >= 1 + forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "mint_dialable_approval_origin") >= 1 } test fn no_outside_module_can_write_a_dial_token_literal() -> Bool { - class_refusals_in(source: forged_dial_literal_source, wanted: "SoleConstructorViolation") >= 1 + forged_route_refusals(class: "SoleConstructorViolation", subject: "DialableApprovalOrigin") >= 1 } test fn no_outside_module_can_call_the_live_origin_admission() -> Bool { - class_refusals_in(source: outside_admission_here_source, wanted: "ConstructorCallAdmissionRefused") >= 1 + forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "approval_origin_admission_here") >= 1 } // (2) The filing step that reads the key, writes the envelope and POSTs is reachable only from the // filing composition that admitted the origin. test fn no_outside_module_can_call_the_filing_step() -> Bool { - class_refusals_in(source: outside_filing_step_source, wanted: "ConstructorCallAdmissionRefused") >= 1 + forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "approval_file_stored_request_at") >= 1 } // (3) THE CONTROL: the canonical filing and standing-read routes compile with no admission refusal. test fn the_canonical_filing_and_standing_read_routes_compile() -> Bool { - class_refusals_in(source: canonical_approval_routes_source, wanted: "ConstructorCallAdmissionRefused") == 0 + admission_refusals_in(source: canonical_approval_routes_source) == 0 } // THE REGRESSION run 37265259209 hit: the live origin is the declared writer's, the broker's loopback diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 78fc9cc5116..44d0338db8d 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -65,7 +65,7 @@ import gunbc.live_deploy.spec { deployment_fabric_storage_door_socket, } import gunbc.live_deploy.desired { deployment_spec_srv1, srv1_production_desired_deployment, DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } -import gunbc.guarantee_probe_corpus { census_blocking_count_for_class } +import gunbc.guarantee_probe_corpus { census_blocking_count_for_class, census_blocking_count_for_subject } import gunbc.live_deploy.role_singleton_convergence { RoleSingletonConvergence, RoleSingletonNoop, @@ -647,23 +647,20 @@ test fn an_unmarked_legacy_store_refuses_naming_the_operators_disposition() -> B // and the token's readers and every admitted apply step to those same compositions. So an outside // module can neither mint an admission from a fabricated decision, obtain one from the assessor, nor // carry one into an apply step later -- each refused at compile time as a located diagnostic. -data forged_admission_source: String = "module probe_forged_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { mint_role_singleton_admission, RoleSingletonNoop, RoleSingletonAdmission }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nfn forge() -> RoleSingletonAdmission { mint_role_singleton_admission(spec: deployment_spec_srv1(), decision: RoleSingletonNoop) }\n" - -data obtained_admission_source: String = "module probe_obtained_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { srv1_production_desired_deployment }\nimport gunbc.host_effect { LocalShell }\nfn obtain() -> RoleSingletonAdmissionResult { assess_and_admit(deployment: srv1_production_desired_deployment(), own: LocalShell) }\n" - -data outside_member_plan_source: String = "module probe_outside_member_plan\nimport gunbc.live_deploy.apply { live_deploy_apply_member_plan_via_transport }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.host_effect { LocalShell }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn bypass(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_member_plan_via_transport(spec: deployment_spec_srv1(), candidate: c, transport: LocalShell, access: a)\n true }\n" - -data outside_warrant_mint_source: String = "module probe_outside_warrant_mint\nimport gunbc.live_deploy.apply_warrant { warrant_live_deploy_apply, AdmittedLiveDeployApply }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.live_deploy.member_identity { ReleaseEffectPlan }\nfn forge(c: CandidateRelease, p: ReleaseEffectPlan) -> AdmittedLiveDeployApply { warrant_live_deploy_apply(spec: deployment_spec_srv1(), candidate: c, plan: p) }\n" - -data outside_warrant_literal_source: String = "module probe_outside_warrant_literal\nimport gunbc.live_deploy.apply_warrant { AdmittedLiveDeployApply }\nimport gunbc.host_effect { HostEffect, LiveDeployApply }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.live_deploy.member_identity { ReleaseEffectPlan }\nfn forge(c: CandidateRelease, p: ReleaseEffectPlan) -> HostEffect { LiveDeployApply { warrant: AdmittedLiveDeployApply { spec: deployment_spec_srv1(), candidate: c, plan: p } } }\n" +// ONE probe source carries every forged deploy route, one function each, so the floor compiles it +// once (the census is memoized by source) and each claim reads ITS refusal by class AND subject. +data forged_deploy_routes_source: String = "module probe_forged_deploy_routes\nimport gunbc.live_deploy.role_singleton_convergence { mint_role_singleton_admission, RoleSingletonNoop, RoleSingletonAdmission, assess_and_admit, assess_and_admit_rostered, RoleSingletonAdmissionResult, admitted_spec }\nimport gunbc.live_deploy.desired { deployment_spec_srv1, srv1_production_desired_deployment }\nimport gunbc.live_deploy.spec { DeploymentSpec }\nimport gunbc.host_effect { LocalShell, HostEffect, LiveDeployApply }\nimport gunbc.live_deploy.apply { live_deploy_apply_member_plan_via_transport }\nimport gunbc.live_deploy.apply_warrant { warrant_live_deploy_apply, AdmittedLiveDeployApply }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.live_deploy.member_identity { ReleaseEffectPlan }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn forge_admission() -> RoleSingletonAdmission { mint_role_singleton_admission(spec: deployment_spec_srv1(), decision: RoleSingletonNoop) }\nfn obtain_admission() -> RoleSingletonAdmissionResult { assess_and_admit(deployment: srv1_production_desired_deployment(), own: LocalShell) }\nfn replay_admission(a: RoleSingletonAdmission) -> DeploymentSpec { admitted_spec(admission: a) }\nfn alter_spec() -> RoleSingletonAdmissionResult { assess_and_admit_rostered(spec: deployment_spec_srv1(), own: LocalShell) }\nfn bypass_member_plan(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_member_plan_via_transport(spec: deployment_spec_srv1(), candidate: c, transport: LocalShell, access: a)\n true }\nfn forge_warrant(c: CandidateRelease, p: ReleaseEffectPlan) -> AdmittedLiveDeployApply { warrant_live_deploy_apply(spec: deployment_spec_srv1(), candidate: c, plan: p) }\nfn forge_warrant_literal(c: CandidateRelease, p: ReleaseEffectPlan) -> HostEffect { LiveDeployApply { warrant: AdmittedLiveDeployApply { spec: deployment_spec_srv1(), candidate: c, plan: p } } }\n" data canonical_apply_source: String = "module probe_canonical_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { srv1_production_desired_deployment }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn act(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(deployment: srv1_production_desired_deployment(), access: a, candidate: c)\n true }\n" -data altered_spec_to_assessor_source: String = "module probe_altered_spec_to_assessor\nimport gunbc.live_deploy.role_singleton_convergence { assess_and_admit_rostered, RoleSingletonAdmissionResult }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.host_effect { LocalShell }\nfn alter() -> RoleSingletonAdmissionResult { let s = deployment_spec_srv1()\n assess_and_admit_rostered(spec: s, own: LocalShell) }\n" - data spec_to_production_apply_source: String = "module probe_spec_to_production_apply\nimport gunbc.live_deploy.apply { live_deploy_apply_with_access }\nimport gunbc.live_deploy.desired { deployment_spec_srv1 }\nimport gunbc.live_deploy.candidate_identity { CandidateRelease }\nimport gunbc.ci_deploy_access { DeployAccess }\nfn hand(c: CandidateRelease, a: DeployAccess) -> Bool { let r = live_deploy_apply_with_access(spec: deployment_spec_srv1(), access: a, candidate: c)\n true }\n" -data replayed_admission_source: String = "module probe_replayed_role_singleton_admission\nimport gunbc.live_deploy.role_singleton_convergence { RoleSingletonAdmission, admitted_spec }\nimport gunbc.live_deploy.spec { DeploymentSpec }\nfn replay(a: RoleSingletonAdmission) -> DeploymentSpec { admitted_spec(admission: a) }\n" +fn forged_route_refusals(class: String, subject: String) -> Int { + match census_blocking_count_for_subject(census: compile_dag_diagnostic_census(forged_deploy_routes_source), wanted_class: class, wanted_subject: subject) { + Present { value: n } => n + Absent => 0 + } +} fn admission_refusals_in(source: String) -> Int { match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: "ConstructorCallAdmissionRefused") { @@ -695,35 +692,29 @@ test fn no_caller_supplied_spec_reaches_the_role_singleton_assessment() -> Bool } test fn an_altered_spec_is_refused_at_the_assessor_seal() -> Bool { - admission_refusals_in(source: altered_spec_to_assessor_source) >= 1 + forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "assess_and_admit_rostered") >= 1 } test fn no_outside_module_can_mint_obtain_or_replay_an_admission() -> Bool { - admission_refusals_in(source: forged_admission_source) >= 1 - && admission_refusals_in(source: obtained_admission_source) >= 1 - && admission_refusals_in(source: replayed_admission_source) >= 1 + forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "mint_role_singleton_admission") >= 1 + && forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "assess_and_admit") >= 1 + && forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "admitted_spec") >= 1 } -fn sole_constructor_refusals_in(source: String) -> Int { - match census_blocking_count_for_class(census: compile_dag_diagnostic_census(source), wanted: "SoleConstructorViolation") { - Present { value: n } => n - Absent => 0 - } -} // THE EFFECT DESTINATION IS CLOSED. (a) an outside call to the member-plan step and (b) an outside // warrant -- minted or written as a literal inside a LiveDeployApply -- are each refused at compile // time; (c) the canonical assess-and-act entry still compiles with no admission refusal. test fn the_realizer_receives_an_apply_only_through_the_assessed_composition() -> Bool { - admission_refusals_in(source: outside_member_plan_source) >= 1 + forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "live_deploy_apply_member_plan_via_transport") >= 1 } test fn no_outside_module_can_mint_a_warrant() -> Bool { - admission_refusals_in(source: outside_warrant_mint_source) >= 1 + forged_route_refusals(class: "ConstructorCallAdmissionRefused", subject: "warrant_live_deploy_apply") >= 1 } test fn no_outside_module_can_write_a_warrant_literal() -> Bool { - sole_constructor_refusals_in(source: outside_warrant_literal_source) >= 1 + forged_route_refusals(class: "SoleConstructorViolation", subject: "AdmittedLiveDeployApply") >= 1 } // THE ONE CANONICAL-PATH CONTROL: the row-taking production apply compiles with no admission refusal, From 6c4106d176937766884e967b39a77028e7a8857d Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 6 Oct 2026 05:30:48 +0000 Subject: [PATCH 46/48] fleet-converge gcp-iam refused job: the script is bash_build nodes emitted by v2.workflow.bash_emit (echo of the typed cause to stderr, exit 1), gated at generation like the release-bins scripts; RED: byte-equal to the modeled emission (review 76898) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/fleet/fleet_converge_workflow.dag | 52 ++++++++++++++++++- .../claim/deployment_risk_witness_test.dag | 34 ++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index fe0c3741d07..59d6c5ce628 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -63,6 +63,10 @@ import gunbc.workflow_capability_closure { capability_closure_is_closed, } import v2.std.algebra { list_map } +import v2.std.diagnostic { Outcome, Accepted, Rejected } +import v2.std.node { Node } +import v2.workflow.bash_emit { bash_emit_stmts } +import v2.extdeps.languages.bash_build { bash_build_with_redir, bash_build_command_with_words, bash_build_word_lit, bash_build_exit } import gunbc.fleet_workflow_steps { ci_prelude_steps_with_checkout, ci_prelude_annotations, @@ -126,7 +130,6 @@ import gunbc.auth.gcp_iam_converge { iam_observe_service_account, iam_converge_s import gunbc.auth.gcp_iam_converge_run { gcp_iam_converge_step_timeout_minutes, iam_converge_token_env, gcp_iam_converge_approved_output } import gunbc.auth.approval_broker_placement { approval_broker_host_under, ApprovalBrokerHostResolved, ApprovalBrokerHostRefused } import gunbc.roadmap_dashboard_instance { ProdRoleRealization, ProdRoleRealizationRefusal, prod_role_realization, prod_role_realization_refusal_detail } -import extdeps.exec.command { shell_quote } import gunbc.auth.managed_host_boot_federation { ManagedHostBootFederationStanding, BootFederationProvisioned, BootFederationUnprovisioned, boot_federation_for, boot_federation_provider_resource, @@ -4775,6 +4778,47 @@ fn fleet_converge_gcp_iam_converge_job_under(realization: ProdRoleRealization) - } } +// THE REFUSED JOB'S SCRIPT IS EMITTED, NOT SPELLED: an echo of the typed cause to stderr and exit 1, +// as v2.extdeps.languages.bash_build nodes through v2.workflow.bash_emit bash_emit_stmts. A script +// bash_emit rejects has no text here; the generation gate fleet_converge_gcp_iam_refusal_script_admitted +// refuses the whole workflow instead of emitting a step with no script. +fn gcp_iam_converge_refusal_statements(cause: ProdRoleRealizationRefusal) -> List { + [ + bash_build_with_redir( + inner: bash_build_command_with_words(words: [ + bash_build_word_lit(text: "echo"), + bash_build_word_lit(text: join(["REFUSED gunbc.fleet_converge_workflow gcp-iam-converge: ", prod_role_realization_refusal_detail(cause: cause)], "")), + ]), + redir: ^bash_production_fold_redir_stdout_to_stderr, + ), + bash_build_exit(code: "1"), + ] +} + +fn gcp_iam_converge_refusal_script(cause: ProdRoleRealizationRefusal) -> Outcome { + bash_emit_stmts(statements: gcp_iam_converge_refusal_statements(cause: cause), set_e: true) +} + +fn gcp_iam_converge_refusal_script_text(outcome: Outcome) -> String { + match outcome { + Accepted { value: v, diagnostics: _ } => v + Rejected { diagnostics: _ } => "" + } +} + +// Admitted when the live realization resolves (no refused job is emitted) or bash_emit accepts the +// refusal script for its cause. +fn fleet_converge_gcp_iam_refusal_script_admitted() -> Bool { + match approval_broker_host_under(realization: prod_role_actuated_realization()) { + ApprovalBrokerHostResolved { host: _ } => true + ApprovalBrokerHostRefused { cause } => + match gcp_iam_converge_refusal_script(cause: cause) { + Accepted { value: _, diagnostics: _ } => true + Rejected { diagnostics: _ } => false + } + } +} + fn fleet_converge_gcp_iam_converge_refused_job(cause: ProdRoleRealizationRefusal) -> Job { Job { id: fleet_converge_mode_job_id(mode: GcpIamConverge) as String, @@ -4785,7 +4829,7 @@ fn fleet_converge_gcp_iam_converge_refused_job(cause: ProdRoleRealizationRefusal RunStep { name: Present { value: "REFUSED: the approval broker has no host (prod role unresolved)" }, id: none, - run: join(["echo ", shell_quote(arg: join(["REFUSED gunbc.fleet_converge_workflow gcp-iam-converge: ", prod_role_realization_refusal_detail(cause: cause)], "")), " >&2; exit 1"], ""), + run: gcp_iam_converge_refusal_script_text(outcome: gcp_iam_converge_refusal_script(cause: cause)), shell: none, env: none, working_directory: none, @@ -5104,6 +5148,10 @@ fn fleet_converge_yml_after_toolchain_admission() -> FleetConvergeYamlGeneration } } else if !fleet_converge_boot_federation_admitted() { FleetConvergeYamlGenerationRefused { reason: fleet_converge_boot_federation_unprovisioned_refusal } + } else if !fleet_converge_gcp_iam_refusal_script_admitted() { + FleetConvergeYamlGenerationRefused { + reason: "REFUSED gunbc.fleet_converge_workflow: v2.workflow.bash_emit refused the gcp-iam-converge refusal script, so no workflow is emitted rather than a step with no script", + } } else if !release_bins_scripts_admitted() { FleetConvergeYamlGenerationRefused { reason: "REFUSED gunbc.fleet_converge_workflow: v2.workflow.bash_emit refused a release-bins runner (gunbc.fleet_release_bins_key), so no workflow is emitted rather than a step with no script", diff --git a/dag/test/claim/deployment_risk_witness_test.dag b/dag/test/claim/deployment_risk_witness_test.dag index 44d0338db8d..834d9d04e06 100644 --- a/dag/test/claim/deployment_risk_witness_test.dag +++ b/dag/test/claim/deployment_risk_witness_test.dag @@ -43,6 +43,7 @@ import gunbc.roadmap_dashboard_instance { ProdRoleRealizedBy, ProdRoleRealizationRefused, ProdRoleUnheld, + prod_role_realization_refusal_detail, ProdRoleHolderUnrealized, ProdRoleHolderUnactuatable, prod_role_realization, @@ -65,6 +66,9 @@ import gunbc.live_deploy.spec { deployment_fabric_storage_door_socket, } import gunbc.live_deploy.desired { deployment_spec_srv1, srv1_production_desired_deployment, DesiredDeployment, desired_deployment_roster, deployment_srv2_target, prod_role_actuated_realization, prod_role_actuated_realization_in } +import v2.std.diagnostic { Accepted, Rejected } +import v2.workflow.bash_emit { bash_emit_stmts } +import v2.extdeps.languages.bash_build { bash_build_with_redir, bash_build_command_with_words, bash_build_word_lit, bash_build_exit } import gunbc.guarantee_probe_corpus { census_blocking_count_for_class, census_blocking_count_for_subject } import gunbc.live_deploy.role_singleton_convergence { RoleSingletonConvergence, @@ -547,6 +551,36 @@ test fn the_gcp_iam_job_runs_on_the_holders_host_and_is_refused_under_no_prod_ro && unheld_refuses } +// THE REFUSED JOB'S SCRIPT IS THE MODELED EMISSION, byte for byte: an echo of the typed cause redirected +// to stderr and exit 1, built here as bash_build nodes and emitted by v2.workflow.bash_emit -- so a +// hand-joined shell string in the job, or any drift from the modeled route, is red. +fn expected_gcp_iam_refusal_script(detail: String) -> String { + match bash_emit_stmts(statements: [ + bash_build_with_redir( + inner: bash_build_command_with_words(words: [ + bash_build_word_lit(text: "echo"), + bash_build_word_lit(text: join(["REFUSED gunbc.fleet_converge_workflow gcp-iam-converge: ", detail], "")), + ]), + redir: ^bash_production_fold_redir_stdout_to_stderr, + ), + bash_build_exit(code: "1"), + ], set_e: true) { + Accepted { value: v, diagnostics: _ } => v + Rejected { diagnostics: _ } => "" + } +} + +test fn the_refused_gcp_iam_job_script_is_the_bash_emit_emission() -> Bool { + let expected = expected_gcp_iam_refusal_script(detail: prod_role_realization_refusal_detail(cause: ProdRoleUnheld)) + let unheld = fleet_converge_gcp_iam_converge_job_under(realization: fx_unheld()) + expected != "" + && count(unheld.steps) == 1 + && all(unheld.steps, st => match st { + RunStep { name: _, id: _, run, shell: _, env: _, working_directory: _, if_condition: _, continue_on_error: _, timeout_minutes: _ } => run == expected + _ => false + }) +} + // ── THE POSITIVE CONTROL, over the REAL selection and roster ──────────────────────────────────── // Today the daily-workspace deployment (srv1-live) holds the role: its spec carries every singleton // member, srv1's lab and srv2-deploy carry none, the GCP IAM job runs on srv1, the real realization From 57129b07848dc759b39f8dd302555acb6cc5d53c Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Tue, 6 Oct 2026 06:21:21 +0000 Subject: [PATCH 47/48] apply: LiveDeployApplyReported arm in live_deploy_pre_mutation_access (main made the match exhaustive); census-cost receipt: the probe peak moves with main Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/live_deploy/apply.dag | 1 + .../entry_scoped_typecheck_rss_tracks_the_name_census.dag | 2 ++ 2 files changed, 3 insertions(+) diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index 4b3c799c2d0..91a4be43717 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -173,6 +173,7 @@ import gunbc.running_release_identity { fn live_deploy_pre_mutation_access(effect: HostEffect, access: DeployAccess) -> DeployAccess { match effect { LiveDeployApply { warrant: _ } => deploy_access_sudoers_install_scope(access: access) + LiveDeployApplyReported { spec: _, candidate: _ } => access LiveDeployRetract { spec: _ } => deploy_access_sudoers_install_scope(access: access) RedfishAction { action: _ } => access ConvergePlan { policy: _ } => access diff --git a/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag b/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag index 683fcce8b9e..24c8103eeb2 100644 --- a/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag +++ b/dag/gunbc/recurring_failure_mode/entry_scoped_typecheck_rss_tracks_the_name_census.dag @@ -19,6 +19,8 @@ data entry_scoped_typecheck_rss_tracks_the_name_census: RecurringFailureMode = R "RECEIPT, THE SAME CLASS INSIDE A CLAIM (deployment-risk D2, #13217, 2026-10-05): every `compile_dag_diagnostic_census` probe in test.claim.deployment_risk_witness_test peaked at 17.6-20.9 GiB VmHWM run alone under a 20 GiB cgroup -- the outside-warrant-mint probe PASSED at 17.6 GiB with 607 eval_steps, the warrant-literal probe at 18.6 GiB with 914, and five others were OOM-killed at the bind -- and the peak did not move with the fixture: a probe importing only gunbc.live_deploy.role_singleton_convergence cost what a probe importing the whole apply tree cost. The census rows name v2.compiler.* modules the fixture never reaches, so the probe pays the source roots' census, not its closure. The eval steps are the claim's subject and are trivial; the compile is the whole cost. Measured by the per-claim VmHWM sampling run in that PR; the class's trigger above is the remedy, and narrowing a fixture's imports is not.", + "RECEIPT, THE PEAK MOVES WITH MAIN, NOT WITH THE CLAIM (deployment-risk D2, #13217, 2026-10-06): the same seven deployment_risk compile probes in one process peaked at 22.7 GiB on 25dc63765f and were OOM-killed at a 22 GiB bind at 23.0 GiB after main was merged (bfb2ee9164) with no change to the claims or their fixtures; a sibling lane (gunbc#13339) saw the same kill on its own merge. The cost tracks the source roots' census, which grows with every landing, so a claim's memory budget is not a fact of the claim.", + "RUNG FOUND AT: mitigatable. The stall refuses typed and located. The invalid cost-shape remains writable.", "ATTAINABLE CEILING: mechanically preventable. Whether typecheck RSS is charged to the closure or to the census is a fact of `compile_emission` / `tree_bare_census_for_root`; a check can refuse an `--entry` whose M/N ratio and peak disagree with the closure grain, or the construction can stop forcing the whole-root census on an Entry subject. It is not structurally impossible: indexing the pool for bare-name parity is a legitimate construction; the defect is charging its resident set to a 16-module typecheck as if it were that module's work.", From 777cbea532bcd87819023fdc510795202d61fd49 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Thu, 8 Oct 2026 18:02:20 +0000 Subject: [PATCH 48/48] enrolment witness: the moved-srv2 arm exercises the broker-host refusal (srv2 admitted, srv1 refused), so a refusal re-pinned to srv1 reds Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/test/claim/approval_device_enrolment_code_witness_test.dag | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dag/test/claim/approval_device_enrolment_code_witness_test.dag b/dag/test/claim/approval_device_enrolment_code_witness_test.dag index 51c1c6767b1..b43039c4c23 100644 --- a/dag/test/claim/approval_device_enrolment_code_witness_test.dag +++ b/dag/test/claim/approval_device_enrolment_code_witness_test.dag @@ -125,6 +125,8 @@ test fn the_mode_is_rostered_and_pinned_to_the_brokers_placed_host() -> Bool { }) && (match moved { EnrolmentCodeIssuePlaced { host, base_url } => (host as String) == "srv2" && string_contains(s: base_url as String, pattern: "srv2.") + && (match enrolment_code_issue_refuses_off_broker_host(host: "srv2", broker_host: host) { ExitSuccess => true _ => false }) + && (match enrolment_code_issue_refuses_off_broker_host(host: "srv1", broker_host: host) { ExitSuccess => false _ => true }) EnrolmentCodeIssueUnplaced { detail: _ } => false }) && (match unheld { EnrolmentCodeIssueUnplaced { detail: _ } => true _ => false })