Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions hub/docs/MAINTAINER.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,109 @@ If a PR is stalled, contentious, or raises questions HUB can't resolve:
## Effective date

This ownership transfer is effective 2026-06-09 (announcement: `shared-context/forum/cbp-hub-track-ownership-transfer-2026-06-09.md`). The supervisor track on HUB comes up at the office today; until then, PRs queue and HUB merges them when its track goes live.

## Deploy ratification — is this seat running what we approved? (F0.3 / R7c)

The fleet's currency check answers *"is the running image the on-disk binary,
and does that binary postdate the merged source?"* Both arms are necessary and
neither is sufficient: **a binary built from a parked feature branch passes
both.** The process matches the file, and the file is newer than anything
merged. That is not hypothetical — a build on a parked branch put unmerged code
at `ExecStart` here and HEAD-based currency called it clean.

**Currency is not ratification.** Ratification is a human (or a supervisor that
verified the build) asserting *this commit is the one this seat may run*.

### The two records, produced independently

| record | who writes it | what it says |
|---|---|---|
| running build | the compiler, stamped into the artifact | the commit + tree state this binary was built from |
| ratified build | the deploy path, via `scripts/ratify-build.sh` | the commit this seat is approved to run |

The daemon **only reads** the manifest. A process that could write its own
ratification record would be certifying itself.

### Using it

```bash
# ratify what a specific artifact attests about itself (preferred — the artifact
# is ASKED, via `hub build-info` JSON, rather than trusting memory of what was
# built or parsing an abbreviated sha out of human --version text)
hub/scripts/ratify-build.sh --from-binary /path/to/hub \
--manifest /etc/web4/ratified-build.json --by dp

# or ratify an explicit commit, optionally pinning the binary digest too
hub/scripts/ratify-build.sh <git-sha> /path/to/hub --manifest ...
```

**The manifest records a FULL 40-character commit id.** `ratify-build.sh` resolves an
abbreviation against the repository before writing (and refuses if it cannot), and the daemon
refuses a short one at admission. A short sha is a *repository-local locator* whose uniqueness
changes as history grows — not a durable identity — and in the commit-only fallback it is the
only identity claim carrying the control.

**Pin the binary digest.** Pass the binary path so the manifest records
`ratified_binary_sha256`. Without it the check can only make the weaker
**commit-level** claim, and the operator page labels it as such — because two
builds of the same commit are not the same executable. A different toolchain,
different feature flags, or a substituted artifact all preserve the commit while
changing the bytes. *Commit identity is provenance; artifact identity is the
ratification claim.*

**Set `HUB_EXEC_PATH`** to the artifact the unit will execute, so the staged arm
has something to check. Unset, that arm reports `unknown` — it deliberately does
**not** fall back to the running image, because reporting a fact about the
present as a fact about the next restart is the substitution this check exists to
catch.

`--from-binary` **refuses** a dirty or unverifiable build: such an artifact is
not any commit, so ratifying "the commit" would name something that does not
describe the bytes.

Point the daemon at the manifest with `HUB_RATIFIED_MANIFEST` (else it reads
`<hub-root>/ratified-build.json`). **Prefer a path the daemon user cannot
write** — root-owned, `0644`. A ratification record writable by the thing it
ratifies is not a control.

### What the operator sees

`/admin` renders a **Deploy ratification** block with two arms:

- **Running** — the **executing image's bytes** (read via `/proc/self/exe`, which
stays readable after a replace-in-place, so it is the bytes actually running
rather than the bytes now at the path) vs the manifest. Falls back to the
commit-level claim, clearly labelled, when no digest was ratified.
- **Staged at exec path** — the artifact the unit will run *next*, so an
unratified binary dropped in place is visible **before** the restart that
makes it live (set `HUB_EXEC_PATH` when the unit's path differs from the
process image).

Verdicts fail closed and keep their failure exits distinct:

| verdict | meaning |
|---|---|
| `current` | clean build, commit matches the manifest |
| `STALE` | established as NOT the ratified build (different commit, or a modified tree — a dirty build is not the ratified artifact even when the commit matches) |
| `unknown` | could not be established: no manifest, an unreadable one, or a build whose provenance is unverified. **Never a pass** — it wears the warning pill, because an operator must not read "we could not check" as "checked and fine". |

`unknown` and `STALE` are deliberately separate: *"nobody has ratified anything
here"* calls for a different response than *"this seat is running something
nobody approved."*

### Not yet closed: the deploy closure itself

R7c has a third limb this does not implement: **writes to the deploy closure —
the unit file, the deploy scripts, the exec path, and the ratification manifest —
must themselves be gated, refused, and escalatable.** Ratification and visibility
land here; the write-protection does not.

The reason is worth recording, because it is the control demonstrating itself:
that closure is enforced by the hestia gate's canonical governance-file list,
which lives in a file that is *itself* on that list. A session operating under
the gate cannot edit it — which is exactly the intended behaviour for
authority-bearing surfaces, and why the change belongs to a deliberate,
separately-reviewed act rather than a side effect of this work.

**Phase 0 is not complete until that lands.** Tracked separately; do not read the
Deploy ratification block as evidence that the deploy path is write-protected.
226 changes: 226 additions & 0 deletions hub/hub-daemon/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,100 @@ async fn landing_page(State(s): State<RestState>) -> Result<Html<String>, AdminE
Ok(public_layout(&s.hub_name, &s.hub_id.to_string(), &body))
}


/// F0.3 (R7c): the deploy-ratification block for the operator surface.
///
/// Two independently-produced records are compared: what this binary attests
/// about itself (compile-time stamp) and what the supervisor recorded as
/// approved. Both arms render — the RUNNING one, and the STAGED one at the
/// exec path, which answers before a restart makes an unratified artifact the
/// running one.
///
/// Fail-closed in rendering as well as in logic: `unknown` gets the warning
/// pill, not a neutral one. An operator scanning this page must not read
/// "we could not check" as "checked and fine".
fn ratified_block(s: &RestState) -> String {
use hub_lib::ratified::{self, DeployVerdict};
let path = s.paths.ratified_manifest();
let (manifest, read_err) = match ratified::RatifiedManifest::read(&path) {
Ok(m) => (m, None),
Err(e) => (None, Some(e.to_string())),
};
// The RUNNING arm decides on the executing image's bytes when the manifest
// pins a digest — commit identity is provenance, artifact identity is the
// ratification claim.
//
// Hashing the image is ~20 MB read + digest (12-36 ms measured) and runs
// SYNCHRONOUSLY inside an async handler, so it is computed only when a
// manifest actually pins a digest to compare against. Without one,
// `evaluate_running` returns before ever looking at it — which is every
// seat that has not been ratified yet, i.e. the common case today.
let running_digest = if ratified::is_artifact_pinned(manifest.as_ref()) {
ratified::running_image_sha256()
} else {
None
};
let running = ratified::evaluate_running(
&hub_lib::build_info::BUILD, running_digest.as_deref(), manifest.as_ref());
// The STAGED arm answers "what will the supervisor execute next?" — which
// only the supervisor's configured path can answer. Falling back to this
// process's own image would label a fact about the PRESENT as a fact about
// the NEXT restart, which is precisely the substitution this check exists
// to catch. Unconfigured ⇒ Unknown, never inferred.
let staged = match std::env::var("HUB_EXEC_PATH") {
Ok(p) if !p.trim().is_empty() => {
let path = std::path::PathBuf::from(p);
let digest = ratified::file_sha256(&path);
ratified::evaluate_staged(digest.as_deref(), manifest.as_ref())
}
_ => DeployVerdict::Unknown {
reason: "HUB_EXEC_PATH is not set, so the artifact the supervisor will execute on restart \
is not known to this process — it is NOT assumed to be the running image"
.to_string(),
},
};

let pill = |v: &DeployVerdict| match v {
DeployVerdict::Current => r#"<span class="pill">current</span>"#.to_string(),
DeployVerdict::Stale { .. } => r#"<span class="pill pill-warn">STALE</span>"#.to_string(),
DeployVerdict::Unknown { .. } => r#"<span class="pill pill-warn">unknown</span>"#.to_string(),
};
let detail = |v: &DeployVerdict| v.detail()
.map(|d| format!(" <span class=\"muted\">— {}</span>", html_escape(d)))
.unwrap_or_default();

let b = &hub_lib::build_info::BUILD;
let mut out = String::from("<h3>Deploy ratification</h3><dl>");
// A commit-only `current` must not read as "these are the ratified bytes".
let claim_note = if running.is_current() && !ratified::is_artifact_pinned(manifest.as_ref()) {
" <span class=\"muted\">(commit-level only — no artifact digest ratified)</span>"
} else {
""
};
out.push_str(&format!(
"<dt>Running</dt><dd>{}{}{}</dd>", pill(&running), detail(&running), claim_note));
out.push_str(&format!(
"<dt>Staged at exec path</dt><dd>{}{}</dd>", pill(&staged), detail(&staged)));
out.push_str(&format!(
"<dt>This binary</dt><dd><code>{}</code> ({:?}, built {})</dd>",
html_escape(b.git_sha_short), b.provenance, html_escape(b.built_at)));
match (&manifest, &read_err) {
(Some(m), _) => out.push_str(&format!(
"<dt>Ratified</dt><dd><code>{}</code>{}{}</dd>",
html_escape(&m.ratified_git_sha),
m.ratified_by.as_deref().map(|w| format!(" by {}", html_escape(w))).unwrap_or_default(),
m.ratified_at.as_deref().map(|a| format!(" at {}", html_escape(a))).unwrap_or_default())),
(None, Some(e)) => out.push_str(&format!(
"<dt>Ratified</dt><dd><span class=\"pill pill-warn\">manifest unreadable</span> \
<span class=\"muted\">— {}</span></dd>", html_escape(e))),
(None, None) => out.push_str(&format!(
"<dt>Ratified</dt><dd><span class=\"muted\">no manifest at {}</span></dd>",
html_escape(&path.display().to_string()))),
}
out.push_str("</dl>");
out
}

async fn overview(State(s): State<RestState>) -> Result<Html<String>, AdminError> {
let ledger = s.ledger.lock().await;
let projected = HubState::project(&*ledger);
Expand Down Expand Up @@ -298,6 +392,12 @@ async fn overview(State(s): State<RestState>) -> Result<Html<String>, AdminError
body.push_str(&format!("<dt>Head hash</dt><dd>{}</dd>", html_escape(&head_hash)));
body.push_str("</dl>");

// F0.3 (R7c): is this seat running what the society ratified? Placed high
// on the overview, next to identity — an operator who reads no further
// still sees whether the thing they are administering is the approved
// build. (A verdict rendered somewhere nobody looks is not filed.)
body.push_str(&ratified_block(&s));

body.push_str("<h2>Membership</h2><dl class=\"grid\">");
body.push_str(&format!("<dt>Members</dt><dd>{}</dd>", projected.member_count()));
body.push_str(&format!("<dt>Member pubkeys pinned</dt><dd>{}</dd>", projected.member_pubkeys.len()));
Expand Down Expand Up @@ -1465,3 +1565,129 @@ pub fn operator_router(state: RestState) -> Router {
.with_state(state)
}


#[cfg(test)]
mod ratified_surface_tests {
use super::*;
use crate::rest::channel_e2e_tests::fresh_rest_state;
use axum::extract::State;

async fn overview_html(s: &RestState) -> String {
overview(State(s.clone())).await.map(|Html(h)| h)
.unwrap_or_else(|e| panic!("overview failed: {} {}", e.0, e.1))
}

fn write_manifest(s: &RestState, json: &str) {
std::fs::write(s.paths.ratified_manifest(), json).expect("write manifest");
}

/// `HUB_EXEC_PATH` is process-global, and cargo runs these tests on
/// parallel threads — one test's `set_var` leaks into another's read.
/// (Caught by the full-suite run: both env tests passed in isolation and
/// one failed together, which is how env-racing test flakiness always
/// presents.) Every test that touches the variable takes this lock, so
/// they serialize against each other while the rest of the suite still
/// runs in parallel.
static EXEC_PATH_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// **The guard must be run against what it guards.** Each arm INDUCES its
/// condition and asserts the operator page changes — a rendering test that
/// only ever sees one state proves nothing about the other.
#[tokio::test]
async fn operator_page_distinguishes_unknown_stale_and_current() {
let (_tmp, state) = fresh_rest_state(None).await;

// ARM 1 — nothing ratified. Must read `unknown`, never a pass, and must
// carry the WARNING pill: an operator scanning the page cannot be
// allowed to read "we could not check" as "checked and fine".
let html = overview_html(&state).await;
assert!(html.contains("Deploy ratification"), "the section renders");
assert!(html.contains("no manifest at"), "says what is missing");
let running_line = html.split("<dt>Running</dt>").nth(1).expect("running row");
assert!(running_line.contains("pill-warn"),
"an unknown seat wears the warning pill, not a neutral one");
assert!(!running_line.starts_with("<dd><span class=\"pill\">current</span>"),
"unratified must never render current");

// ARM 2 — a manifest ratifying a DIFFERENT commit. This is the
// parked-checkout shape: the binary is clean and newer than merged
// source (every currency arm passes) but nobody ratified this commit.
write_manifest(&state, r#"{"ratified_git_sha":"0000000deadbeef00000000deadbeef000000000","ratified_by":"dp"}"#);
let html = overview_html(&state).await;
let running_line = html.split("<dt>Running</dt>").nth(1).expect("running row");
assert!(running_line.contains("STALE"),
"a clean build of an unratified commit is STALE, not current:\n{running_line}");
assert!(html.contains("0000000deadbeef"), "the ratified sha is shown for comparison");

// ARM 3 — ratify what is actually running. Under `cargo test` the build
// stamp may be `unknown` provenance, which is legitimately `unknown`
// rather than `current`; assert the DISCRIMINATING property instead —
// matching the running commit must not still read STALE-for-mismatch.
let running_sha = hub_lib::build_info::BUILD.git_sha;
if running_sha != "unknown" {
write_manifest(&state, &format!(
r#"{{"ratified_git_sha":"{running_sha}","ratified_by":"dp"}}"#));
let html = overview_html(&state).await;
let running_line = html.split("<dt>Running</dt>").nth(1).expect("running row");
assert!(!running_line.contains("is ratified for this seat"),
"a matching commit must not report a commit mismatch:\n{running_line}");
}
}

/// A manifest that exists but is malformed is its own state — not silently
/// the same as absent, and emphatically not a pass.
#[tokio::test]
async fn a_malformed_manifest_is_surfaced_not_swallowed() {
let (_tmp, state) = fresh_rest_state(None).await;
write_manifest(&state, "{ this is not json");
let html = overview_html(&state).await;
assert!(html.contains("manifest unreadable"),
"a broken ratification record is shown to the operator");
let running_line = html.split("<dt>Running</dt>").nth(1).expect("running row");
assert!(running_line.contains("pill-warn"), "and still fails closed");
}

/// Review follow-up (PR 708): with no configured exec path, the staged arm
/// must say UNKNOWN — it must not label this process's own image as "what
/// the supervisor will run next", which would report a fact about the
/// present as a fact about the next restart.
#[tokio::test]
async fn staged_arm_does_not_infer_the_future_exec_path() {
let (_tmp, state) = fresh_rest_state(None).await;
write_manifest(&state, &format!(
r#"{{"ratified_git_sha":"abcdef1234567890abcdef1234567890abcdef12","ratified_binary_sha256":"{}"}}"#,
"aa".repeat(32)));
let _env = EXEC_PATH_ENV.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("HUB_EXEC_PATH");
let html = overview_html(&state).await;
let staged_line = html.split("<dt>Staged at exec path</dt>").nth(1).expect("staged row");
assert!(staged_line.contains("unknown"),
"unconfigured next-exec path is unknown, not inferred:\n{staged_line}");
assert!(staged_line.contains("not assumed") || staged_line.contains("NOT assumed"),
"and says so explicitly:\n{staged_line}");
}

/// The staged arm answers BEFORE a restart: an unratified artifact at the
/// exec path is a fact the operator should see now, not discover after the
/// ignition that makes it the running binary.
#[tokio::test]
async fn staged_artifact_mismatch_is_visible_without_a_restart() {
let (tmp, state) = fresh_rest_state(None).await;
// A manifest that ratifies some binary digest...
write_manifest(&state, &format!(
r#"{{"ratified_git_sha":"abcdef1234567890abcdef1234567890abcdef12","ratified_binary_sha256":"{}"}}"#,
"aa".repeat(32)));
// ...and an artifact at the exec path that is NOT it.
let staged = tmp.path().join("staged-hub");
std::fs::write(&staged, b"an unratified binary").unwrap();
let _env = EXEC_PATH_ENV.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("HUB_EXEC_PATH", &staged);
let html = overview_html(&state).await;
std::env::remove_var("HUB_EXEC_PATH");

let staged_line = html.split("<dt>Staged at exec path</dt>").nth(1).expect("staged row");
assert!(staged_line.contains("STALE"), "staged mismatch is STALE:\n{staged_line}");
assert!(staged_line.contains("next restart"),
"the operator is told the consequence, not just the fact");
}
}
Loading
Loading