diff --git a/Cargo.lock b/Cargo.lock index ed54368c3..643ba84f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3681,6 +3681,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proof-admin-bin" +version = "0.1.0" +dependencies = [ + "clap", + "crypto", + "db", + "hex", + "proof-experiment", + "proof-rlm-store", + "proof-task", + "proof-topic-bundle", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "proof-canon" version = "0.1.0" @@ -4020,6 +4037,20 @@ dependencies = [ "toml", ] +[[package]] +name = "proof-topic-bundle" +version = "0.1.0" +dependencies = [ + "hex", + "proof-canon", + "proof-experiment", + "proof-task", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", +] + [[package]] name = "proof-vm-agent" version = "0.1.0" diff --git a/bins/proof-admin/Cargo.toml b/bins/proof-admin/Cargo.toml new file mode 100644 index 000000000..fe58da2c2 --- /dev/null +++ b/bins/proof-admin/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "proof-admin-bin" +description = "Proof operator CLI: validate and install a topic install bundle through the existing admin publish path" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[[bin]] +name = "proof-admin" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive", "env"] } +db = { path = "../../crates/db" } +proof-rlm-store = { path = "../../crates/proof-rlm-store" } +proof-task = { path = "../../crates/proof-task" } +proof-topic-bundle = { path = "../../crates/proof-topic-bundle" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[dev-dependencies] +crypto = { path = "../../crates/crypto" } +db = { path = "../../crates/db", features = ["testing"] } +hex = "0.4" +proof-experiment = { path = "../../crates/proof-experiment" } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/bins/proof-admin/src/main.rs b/bins/proof-admin/src/main.rs new file mode 100644 index 000000000..669edc782 --- /dev/null +++ b/bins/proof-admin/src/main.rs @@ -0,0 +1,785 @@ +//! `proof-admin` — Proof operator CLI for dynamic topics. +//! +//! P0 skeleton of the dynamic-topics admin path. It wraps the topic +//! publication procedure that **already exists** in this repository: +//! +//! | Step | Existing path this CLI reuses | +//! |------|------------------------------| +//! | Sign a topic | `xtask proof-topic` (sr25519 under `base-proof-topic-v1`) | +//! | Acceptance checks | [`proof_task::TopicDocument::validate`] + `verify_signature` — the same pair `POST /v1/admin/proof/topics` runs | +//! | Publish | `POST /v1/admin/proof/topics` (operator bearer) | +//! | Persist | `proof_topic_version` (migration `0020`) via `RlmStore::put_topic_version` | +//! | Score a custom id | `PROOF_VM_RUNNER_CUSTOM_IDS` + the pack staged under `PROOF_VM_AGENT_EXPERIMENT_PACK_DIR` | +//! +//! There is deliberately **no new topic table and no new route**: a topic has +//! one home (the signed document in `proof_topic_version`) and one publish +//! path (the admin route). What this CLI adds is the *procedure* — a bundle +//! that names the signed document plus the host env that must agree with it, +//! `validate` that runs the same acceptance the route runs, and `install +//! --dry-run` that prints the exact publish call and env lines without +//! touching anything. +//! +//! What this binary does **not** do, deliberately: +//! +//! - It never writes a topic. `install` prints the publish call for an +//! operator to run (the bearer stays on the host); a `--execute` path +//! belongs to a later slice. +//! - It touches no route, no allocator, and no scoring path. +//! - It removes none of the compiled-in bindings the current live topic uses. +//! +//! Exit codes: `0` ok, `1` error, `2` usage or configuration, `3` not +//! implemented in this slice. + +#![forbid(unsafe_code)] +#![allow(clippy::print_stdout, clippy::print_stderr)] + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use proof_rlm_store::{MemoryRlmStore, PgRlmStore, RlmStore, TopicVersionRow}; +use proof_task::ProofPin; +use proof_topic_bundle::{InstallEnvironment, TopicInstallBundle, TopicInstallPlan, PUBLISH_PATH}; + +/// Successful run. +const EXIT_OK: u8 = 0; +/// A command failed (bad bundle, refused document, database error). +const EXIT_ERROR: u8 = 1; +/// Bad usage or missing configuration. +const EXIT_USAGE: u8 = 2; +/// The command exists but its behaviour belongs to a later slice. +const EXIT_NOT_IMPLEMENTED: u8 = 3; + +/// Proof operator CLI. +#[derive(Debug, Parser)] +#[command( + name = "proof-admin", + version, + about = "Proof operator CLI: validate and install a topic install bundle", + long_about = "proof-admin wraps the existing Proof topic publish path (dynamic-topics P0). + +Validate a bundle — runs the same acceptance checks POST /v1/admin/proof/topics runs: + proof-admin topic validate --bundle tb4.json --pin config/proof-pin.toml + +Resolve the publish call and host env without touching anything: + proof-admin topic install --bundle tb4.json --env metal --dry-run + +List the installed topics (a read-only view of proof_topic_version): + proof-admin topic list + +Nothing here writes a topic, opens a route, or changes how a score is +computed. `install` prints the publish call and the host env for an operator +to run; `topic enable` / `disable` / `seal` exit 3 as not-implemented." +)] +struct Cli { + /// Postgres URL for the topic registry view. Falls back to `BASE_DATABASE_URL`. + #[arg(long, global = true, env = "BASE_DATABASE_URL", value_name = "URL")] + database_url: Option, + /// Read the Postgres URL from this file (mutually exclusive with the value). + #[arg( + long, + global = true, + env = "BASE_DATABASE_URL_FILE", + value_name = "PATH" + )] + database_url_file: Option, + /// Print machine-readable JSON instead of a summary. + #[arg(long, global = true)] + json: bool, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Debug, Subcommand)] +enum Cmd { + /// Manage Proof topic installs. + Topic { + #[command(subcommand)] + cmd: TopicCmd, + }, +} + +#[derive(Debug, Subcommand)] +enum TopicCmd { + /// Check a bundle: the shared acceptance checks plus the host cross-checks. + Validate { + /// Bundle JSON. + #[arg(long, value_name = "PATH")] + bundle: PathBuf, + /// Pin the document is checked against. Defaults to `config/proof-pin.toml`. + #[arg(long, value_name = "PATH", default_value = "config/proof-pin.toml")] + pin: PathBuf, + }, + /// Resolve the publish call and host env. `--dry-run` is the only mode + /// this slice implements; it touches nothing. + Install { + /// Bundle JSON. + #[arg(long, value_name = "PATH")] + bundle: PathBuf, + /// Install target. Must match the bundle's own `environment`. + #[arg(long, value_name = "staging|metal")] + env: String, + /// Pin the document is checked against. Defaults to `config/proof-pin.toml`. + #[arg(long, value_name = "PATH", default_value = "config/proof-pin.toml")] + pin: PathBuf, + /// Resolve and print the plan without touching anything. + #[arg(long)] + dry_run: bool, + /// Assert Owner authority and that staging passed first. Required for + /// `--env metal`; refused (usage) without it. This is an operator + /// assertion, not a verified precondition — the gate exists so a + /// metal install cannot happen by accident or by copy-paste. + #[arg(long)] + owner_metal_ack: bool, + }, + /// List installed topics: a read-only view of `proof_topic_version`. + List, + /// Show one installed topic. An alias resolves to its topic. + Show { + /// Topic slug, or an alias of one. + topic_id: String, + }, + /// Manage the temporary compatibility aliases a topic answers to. + Alias { + #[command(subcommand)] + cmd: AliasCmd, + }, + /// Not implemented in this slice. + Enable { + /// Topic slug. + topic_id: String, + }, + /// Not implemented in this slice. + Disable { + /// Topic slug. + topic_id: String, + }, + /// Not implemented in this slice. + Seal { + /// Topic slug. + topic_id: String, + /// Measured baseline primary. + #[arg(long, value_name = "VALUE")] + value: f64, + }, +} + +#[derive(Debug, Subcommand)] +enum AliasCmd { + /// Point an alias at a topic. The topic must be published already. + Set { + /// The alias slug (e.g. `tbench`). + alias: String, + /// The canonical topic slug it resolves to (e.g. `tb4`). + #[arg(long, value_name = "TOPIC_ID")] + topic: String, + }, + /// List the aliases of one topic. + List { + /// Canonical topic slug. + #[arg(long, value_name = "TOPIC_ID")] + topic: String, + }, + /// Retire an alias. The topic itself is untouched. + Rm { + /// The alias slug to remove. + alias: String, + }, +} + +/// Global options, split out of [`Cli`] so the subcommand can be borrowed. +#[derive(Debug)] +struct Options { + database_url: Option, + database_url_file: Option, + json: bool, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("proof-admin: tokio runtime: {e}"); + return ExitCode::from(EXIT_ERROR); + } + }; + match runtime.block_on(run(cli)) { + Ok(()) => ExitCode::from(EXIT_OK), + Err(Failure::Usage(msg)) => { + eprintln!("proof-admin: {msg}"); + ExitCode::from(EXIT_USAGE) + } + Err(Failure::NotImplemented(msg)) => { + eprintln!("proof-admin: {msg}"); + ExitCode::from(EXIT_NOT_IMPLEMENTED) + } + Err(Failure::Error(msg)) => { + eprintln!("proof-admin: {msg}"); + ExitCode::from(EXIT_ERROR) + } + } +} + +/// How a command failed, which decides the process exit code. +#[derive(Debug)] +enum Failure { + /// Bad usage or missing configuration. + Usage(String), + /// A later slice owns this behaviour. + NotImplemented(String), + /// Anything else (bad bundle, refused document, database error). + Error(String), +} + +async fn run(cli: Cli) -> Result<(), Failure> { + let opts = Options { + database_url: cli.database_url, + database_url_file: cli.database_url_file, + json: cli.json, + }; + match cli.cmd { + Cmd::Topic { cmd } => run_topic(&opts, &cmd).await, + } +} + +async fn run_topic(opts: &Options, cmd: &TopicCmd) -> Result<(), Failure> { + match cmd { + TopicCmd::Validate { bundle, pin } => cmd_validate(opts, bundle, pin), + TopicCmd::Install { + bundle, + env, + pin, + dry_run, + owner_metal_ack, + } => cmd_install(opts, bundle, env, pin, *dry_run, *owner_metal_ack), + TopicCmd::List => cmd_list(opts).await, + TopicCmd::Show { topic_id } => cmd_show(opts, topic_id).await, + TopicCmd::Alias { cmd } => run_alias(opts, cmd).await, + TopicCmd::Enable { topic_id } => Err(not_implemented("topic enable", topic_id)), + TopicCmd::Disable { topic_id } => Err(not_implemented("topic disable", topic_id)), + TopicCmd::Seal { topic_id, value } => Err(not_implemented( + &format!("topic seal (value {value})"), + topic_id, + )), + } +} + +async fn run_alias(opts: &Options, cmd: &AliasCmd) -> Result<(), Failure> { + let store = open_store(opts).await?; + match cmd { + AliasCmd::Set { alias, topic } => { + store + .put_alias(alias, topic) + .await + .map_err(|e| Failure::Error(e.to_string()))?; + if opts.json { + print_json(&serde_json::json!({ + "ok": true, + "alias": alias, + "topic_id": topic, + }))?; + return Ok(()); + } + println!("alias {alias} -> {topic}"); + println!(); + println!( + "Temporary compatibility mapping. Retire it with \ + `proof-admin topic alias rm {alias}` once links move to {topic}." + ); + Ok(()) + } + AliasCmd::List { topic } => { + let aliases = store + .aliases_for(topic) + .await + .map_err(|e| Failure::Error(format!("aliases for {topic}: {e}")))?; + if opts.json { + print_json(&serde_json::json!({ "topic_id": topic, "aliases": aliases }))?; + return Ok(()); + } + if aliases.is_empty() { + println!("No aliases for {topic}."); + } else { + for alias in &aliases { + println!("{alias} -> {topic}"); + } + } + Ok(()) + } + AliasCmd::Rm { alias } => { + let removed = store + .delete_alias(alias) + .await + .map_err(|e| Failure::Error(format!("remove alias {alias}: {e}")))?; + if !removed { + return Err(Failure::Error(format!("no alias {alias:?}"))); + } + if opts.json { + print_json(&serde_json::json!({ "ok": true, "removed": alias }))?; + return Ok(()); + } + println!("removed alias {alias}"); + Ok(()) + } + } +} + +/// A stub that names what is missing instead of guessing. +fn not_implemented(command: &str, topic_id: &str) -> Failure { + Failure::NotImplemented(format!( + "`{command}` for topic {topic_id:?} is not implemented in this slice (P0: bundle + \ + admin CLI skeleton). Nothing was changed. A topic's lifecycle is the signed document's \ + `status`; re-sign and re-publish through POST /v1/admin/proof/topics instead." + )) +} + +/// Read a bundle file. +fn load_bundle(path: &Path) -> Result { + let body = std::fs::read_to_string(path) + .map_err(|e| Failure::Error(format!("read {}: {e}", path.display())))?; + TopicInstallBundle::from_json(&body) + .map_err(|e| Failure::Error(format!("{}: {e}", path.display()))) +} + +/// Read the pin the document is checked against. +fn load_pin(path: &Path) -> Result { + let body = std::fs::read_to_string(path) + .map_err(|e| Failure::Error(format!("read {}: {e}", path.display())))?; + let pin = ProofPin::from_toml(&body).map_err(|e| Failure::Error(e.to_string()))?; + pin.validate().map_err(|e| Failure::Error(e.to_string()))?; + Ok(pin) +} + +/// The shared acceptance checks, in the order the admin route runs them. +/// +/// This is the point of the CLI: an operator finds out here — not on the host +/// that matters — that a document would be refused, and why. It runs exactly +/// the two checks `POST /v1/admin/proof/topics` runs, against the same pin. +fn accept_document(bundle: &TopicInstallBundle, pin: &ProofPin) -> Result<(), Failure> { + bundle + .validate_shape() + .map_err(|e| Failure::Error(e.to_string()))?; + let registered = bundle.registered_custom(); + let registered: Vec<&str> = registered.iter().map(String::as_str).collect(); + bundle + .topic + .validate(pin, ®istered) + .map_err(|e| Failure::Error(format!("topic document: {e}")))?; + bundle + .topic + .verify_signature(pin) + .map_err(|e| Failure::Error(format!("topic signature: {e}")))?; + Ok(()) +} + +/// Parse `--env` into an install target. +fn parse_env(raw: &str) -> Result { + raw.parse::().map_err(Failure::Usage) +} + +fn cmd_validate(opts: &Options, path: &Path, pin_path: &Path) -> Result<(), Failure> { + let bundle = load_bundle(path)?; + let pin = load_pin(pin_path)?; + accept_document(&bundle, &pin)?; + let digest = bundle.digest().map_err(|e| Failure::Error(e.to_string()))?; + let binding = bundle + .binding() + .map_err(|e| Failure::Error(e.to_string()))?; + if opts.json { + let body = serde_json::json!({ + "ok": true, + "bundle": path.display().to_string(), + "topic_id": bundle.topic.id, + "environment": bundle.environment.as_str(), + "document_status": bundle.topic.status, + "metric_family": bundle.topic.metric.family, + "custom_id": bundle.topic.metric.custom_id, + "runner_id": binding.as_ref().map(|b| b.runner.clone()), + "bundle_digest": digest, + "rlm_install": !bundle.rlm.is_empty(), + }); + print_json(&body)?; + return Ok(()); + } + println!("bundle {} is valid", path.display()); + println!(" topic_id {}", bundle.topic.id); + println!(" environment {}", bundle.environment); + println!(" document_status {}", status_word(bundle.topic.status)); + println!(" metric_family {}", bundle.topic.metric.family.as_str()); + println!( + " custom_id {}", + dash_if_empty(&bundle.topic.metric.custom_id) + ); + println!( + " runner_id {}", + binding + .as_ref() + .map_or_else(|| "-".to_owned(), |b| b.runner.clone()) + ); + println!(" bundle_digest {digest}"); + println!( + " rlm_install {}", + if bundle.rlm.is_empty() { + "-".to_owned() + } else { + "present (handed to the RLM verbatim)".to_owned() + } + ); + println!(); + println!("Checked against {}.", pin_path.display()); + println!( + "Nothing was written. Resolve the publish call with `proof-admin topic install --dry-run`." + ); + Ok(()) +} + +fn cmd_install( + opts: &Options, + path: &Path, + env: &str, + pin_path: &Path, + dry_run: bool, + owner_metal_ack: bool, +) -> Result<(), Failure> { + let bundle = load_bundle(path)?; + let requested = parse_env(env)?; + // Owner default: metal is Owner-only, and staging goes first. A metal + // plan is refused unless the operator asserts both, so a live target can + // never be reached by a default or a copy-pasted staging command. + if requested == InstallEnvironment::Metal && !owner_metal_ack { + return Err(Failure::Usage(format!( + "`--env metal` is Owner-only and requires --owner-metal-ack, which asserts that \ + (a) an Owner authorized this install and (b) staging has passed for this bundle \ + ({}). Install to staging first: `proof-admin topic install --bundle {} --env \ + staging --dry-run`.", + path.display(), + path.display() + ))); + } + let pin = load_pin(pin_path)?; + let plan = bundle + .plan(requested) + .map_err(|e| Failure::Error(format!("{}: {e}", path.display())))?; + // The same acceptance the publish route runs, so a dry run cannot print a + // call that the route would refuse. + accept_document(&bundle, &pin)?; + + if !dry_run { + // Deliberately not implemented in this slice: publishing needs the + // operator bearer, which stays on the host. Printing the call is the + // whole point of P0. + return Err(Failure::NotImplemented( + "a real install is not implemented in this slice (P0: bundle + admin CLI skeleton). \ + Nothing was changed. Run the printed publish call on the host that holds the \ + operator bearer, or use `--dry-run`." + .to_owned(), + )); + } + + if opts.json { + print_json(&plan)?; + return Ok(()); + } + print_plan(&plan, path, pin_path); + println!(); + println!("Dry run: nothing was written and no host was touched."); + Ok(()) +} + +fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { + println!("topic install plan"); + println!(" topic_id {}", plan.topic_id); + println!(" display_name {}", plan.display_name); + println!(" environment {}", plan.environment); + println!(" document_status {}", status_word(plan.document_status)); + println!(" metric_family {}", plan.metric_family.as_str()); + println!(" custom_id {}", dash_if_empty(&plan.custom_id)); + println!( + " runner_id {}", + plan.runner_id.as_deref().unwrap_or("-") + ); + println!( + " pack_digest {}", + plan.pack_digest.as_deref().unwrap_or("-") + ); + println!(" bundle_digest {}", plan.bundle_digest); + println!(" pin {}", pin_path.display()); + if plan.environment == InstallEnvironment::Metal { + println!(" owner_gate acknowledged (Owner-only metal install)"); + } else { + println!(" owner_gate n/a (staging)"); + } + println!(); + println!("1) Hand control to the topic's RLM (it installs and sets the topic up):"); + println!( + " # The RLM drives, in order: {}", + plan.rlm_jobs.join(" -> ") + ); + if plan.rlm_install.is_some() { + println!(" # This bundle carries an RLM install section. It is handed to the RLM"); + println!(" # verbatim and the admin CLI does not read into it: what the section"); + println!(" # contains is the topic's business, not this binary's. Rust never"); + println!(" # branches on a topic's rules, APIs, submit format, or scoring."); + } else { + println!(" # No RLM install section in this bundle: the RLM uses its defaults."); + } + println!(); + println!("2) Publish the signed document (one block; existing route, operator bearer):"); + println!(" # The route takes a TopicDocument, not the bundle envelope, so this"); + println!(" # extracts .topic into a private mktemp -d directory first."); + for line in publish_block(bundle_path).lines() { + println!(" {line}"); + } + println!(); + if plan.host_env.is_empty() { + println!("3) Host env: nothing extra is required for this topic."); + } else { + println!("3) Set these on the master before the topic can score:"); + for var in &plan.host_env { + println!(" {}={}", var.name, var.value); + println!(" # {}", var.why); + } + } +} + +/// The runnable publish step, as one shell block. +/// +/// The publish route takes a `TopicDocument`, **not** the bundle envelope, so +/// the procedure has to extract `topic` first. +/// +/// Two things make this safe to paste: +/// +/// - The extracted document goes into a **private directory created by +/// `mktemp -d`** (`mktemp` makes it 0700), and the file itself is `0600`. A +/// fixed shared path like `/tmp/document.json` would let any local process +/// replace the file between the checks and the publication, so the document +/// that gets published would not be the one that was validated. +/// - Extraction and publication are **one block**, so the path variable and +/// the file it names cannot drift apart or be swapped in between. An +/// operator pastes the whole thing once. +/// +/// The document itself was already accepted by `validate` before this is +/// printed, so the block does not re-check it; re-running `proof-admin topic +/// validate` on the extracted file is a reasonable extra step for an operator +/// who wants it. +fn publish_block(bundle_path: &Path) -> String { + let bundle = shell_single_quote(&bundle_path.display().to_string()); + format!( + "PROOF_TOPIC_DIR=$(mktemp -d) \\\n \ + && jq '.topic' {bundle} > \"$PROOF_TOPIC_DIR/document.json\" \\\n \ + && chmod 600 \"$PROOF_TOPIC_DIR/document.json\" \\\n \ + && curl -sS -X POST \\\n \ + -H \"Authorization: Bearer $PROOF_ADMIN_TOKEN\" \\\n \ + -H 'content-type: application/json' \\\n \ + --data-binary @\"$PROOF_TOPIC_DIR/document.json\" \\\n \ + {PUBLISH_PATH} \\\n \ + && rm -rf \"$PROOF_TOPIC_DIR\"" + ) +} + +/// Single-quote a path for `sh`, escaping any embedded quote. +/// +/// A path with a space or a quote must not turn the printed procedure into a +/// different command than the operator read. +fn shell_single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +async fn cmd_list(opts: &Options) -> Result<(), Failure> { + let store = open_store(opts).await?; + let rows = store + .latest_topics() + .await + .map_err(|e| Failure::Error(format!("list topics: {e}")))?; + if opts.json { + let body: Vec = rows.iter().map(topic_json).collect(); + print_json(&body)?; + return Ok(()); + } + if rows.is_empty() { + println!("No topics installed."); + return Ok(()); + } + println!("{} topic(s) installed:", rows.len()); + for row in &rows { + println!(" {}", summarize(row)); + } + println!(); + println!("Read from proof_topic_version; the signed document is the source of truth."); + Ok(()) +} + +async fn cmd_show(opts: &Options, topic_id: &str) -> Result<(), Failure> { + let store = open_store(opts).await?; + // An alias resolves to its canonical slug first, so `show tbench` finds + // `tb4`. Resolution is fail-closed in the store: an alias whose topic has + // no published version resolves to nothing rather than to an empty row. + let resolved = store + .resolve_alias(topic_id) + .await + .map_err(|e| Failure::Error(format!("resolve {topic_id}: {e}")))?; + let canonical = resolved.as_deref().unwrap_or(topic_id); + let row = store + .latest_topic(canonical) + .await + .map_err(|e| Failure::Error(format!("show {canonical}: {e}")))?; + let Some((version, document)) = row else { + return Err(Failure::Error(format!( + "no installed topic {topic_id:?}{}. Use `proof-admin topic list` to see the exact ids.", + resolved + .as_deref() + .map(|c| format!(" (alias of {c:?})")) + .unwrap_or_default() + ))); + }; + let row = TopicVersionRow { + topic_id: canonical.to_owned(), + version, + document, + }; + if let Some(canonical) = resolved.as_deref() { + if !opts.json { + println!("{topic_id} is an alias of {canonical}"); + println!(); + } + } + if opts.json { + print_json(&topic_json(&row))?; + return Ok(()); + } + print_row(&row); + Ok(()) +} + +/// The topic registry: the existing `proof_topic_version` rows. +/// +/// A configured but unreachable database is fatal: falling back to an empty +/// in-memory view would report "nothing installed" for a host that has topics. +async fn open_store(opts: &Options) -> Result, Failure> { + let Some(url) = database_url(opts)? else { + return Err(Failure::Usage( + "this command reads the topic registry and needs a database: set \ + BASE_DATABASE_URL (or BASE_DATABASE_URL_FILE). `topic validate` and \ + `topic install --dry-run` need no database." + .into(), + )); + }; + let pool = db::connect(&url) + .await + .map_err(|e| Failure::Error(format!("connect: {e}")))?; + // `PgRlmStore` is the production registry; the memory store exists for + // CI/local and is never selected here, so a real host never reads an + // empty view by accident. + let _ = MemoryRlmStore::new; + Ok(Box::new(PgRlmStore::new(pool))) +} + +/// `BASE_DATABASE_URL` value, or the contents of `BASE_DATABASE_URL_FILE`. +/// +/// The two are mutually exclusive, matching `crates/config`: a value and a +/// file that disagree would be a silent choice between two databases. +fn database_url(opts: &Options) -> Result, Failure> { + let value = opts + .database_url + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let file = opts.database_url_file.as_deref(); + match (value, file) { + (Some(_), Some(_)) => Err(Failure::Usage( + "set BASE_DATABASE_URL or BASE_DATABASE_URL_FILE, not both".into(), + )), + (Some(url), None) => Ok(Some(url.to_owned())), + (None, Some(path)) => { + let raw = std::fs::read_to_string(path) + .map_err(|e| Failure::Error(format!("read {}: {e}", path.display())))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(Failure::Usage(format!("{} is empty", path.display()))); + } + Ok(Some(trimmed.to_owned())) + } + (None, None) => Ok(None), + } +} + +fn print_row(row: &TopicVersionRow) { + let doc = &row.document; + println!("topic {}", row.topic_id); + println!(" version {}", row.version); + println!(" status {}", status_word(doc.status)); + println!(" metric_family {}", doc.metric.family.as_str()); + println!( + " custom_id {}", + dash_if_empty(&doc.metric.custom_id) + ); + println!(" payout_mode {}", doc.payout_mode.as_str()); + println!(" valid_from_epoch {}", doc.valid_from_epoch); + println!( + " valid_until_epoch {}", + doc.valid_until_epoch + .map_or_else(|| "-".to_owned(), |e| e.to_string()) + ); + println!(" baseline_sealed {}", doc.baseline.is_sealed()); + println!( + " signature {}…", + doc.signature.get(..16).unwrap_or(doc.signature.as_str()) + ); + println!(); + println!("The signed document is the source of truth; this view reads it verbatim."); +} + +/// One-line summary for `topic list`. +fn summarize(row: &TopicVersionRow) -> String { + let doc = &row.document; + format!( + "{:<24} v{:<3} {:<10} {:<10} custom_id={}", + row.topic_id, + row.version, + status_word(doc.status), + doc.metric.family.as_str(), + dash_if_empty(&doc.metric.custom_id) + ) +} + +/// Lifecycle word, matching the wire spelling the document uses. +fn status_word(status: proof_task::TopicStatus) -> &'static str { + match status { + proof_task::TopicStatus::Draft => "draft", + proof_task::TopicStatus::Open => "open", + proof_task::TopicStatus::Closed => "closed", + } +} + +fn topic_json(row: &TopicVersionRow) -> serde_json::Value { + serde_json::json!({ + "topic_id": row.topic_id, + "version": row.version, + "status": row.document.status, + "metric_family": row.document.metric.family, + "custom_id": row.document.metric.custom_id, + "payout_mode": row.document.payout_mode.as_str(), + "valid_from_epoch": row.document.valid_from_epoch, + "valid_until_epoch": row.document.valid_until_epoch, + "baseline_sealed": row.document.baseline.is_sealed(), + "document": row.document, + }) +} + +fn print_json(value: &T) -> Result<(), Failure> { + let body = serde_json::to_string_pretty(value).map_err(|e| Failure::Error(e.to_string()))?; + println!("{body}"); + Ok(()) +} + +fn dash_if_empty(s: &str) -> String { + if s.trim().is_empty() { + "-".to_owned() + } else { + s.to_owned() + } +} diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs new file mode 100644 index 000000000..04eadf533 --- /dev/null +++ b/bins/proof-admin/tests/cli.rs @@ -0,0 +1,955 @@ +//! Process-level tests for `proof-admin` (dynamic-topics P0). +//! +//! The commands that must work end to end are `topic validate` and +//! `topic install --dry-run`: both run the same acceptance checks the existing +//! `POST /v1/admin/proof/topics` route runs, and neither touches a host. The +//! stubs must fail closed with exit code 3 rather than doing something partial. +//! +//! A real install is deliberately **not** implemented in this slice, so the +//! test asserts it refuses rather than writing anything; the registry view +//! (`topic list` / `topic show`) is covered against Postgres in +//! `crates/proof-rlm-store/tests/store_contract.rs` and by one DB-gated test +//! here. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +/// Exit code for a failure (bad bundle, refused document, ...). +const EXIT_ERROR: i32 = 1; +/// Exit code for bad usage or missing configuration. +const EXIT_USAGE: i32 = 2; +/// Exit code for a command a later slice owns. +const EXIT_NOT_IMPLEMENTED: i32 = 3; + +fn workdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "proof-admin-{}-{tag}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + fs::create_dir_all(&dir).expect("workdir"); + dir +} + +fn write_file(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, body).expect("write file"); + path +} + +/// Run the binary with every database variable removed, so a command that +/// silently reached for one would fail here rather than on an operator host. +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_proof-admin")) + .args(args) + .env_remove("BASE_DATABASE_URL") + .env_remove("BASE_DATABASE_URL_FILE") + .output() + .expect("run proof-admin") +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn code(out: &Output) -> i32 { + out.status.code().unwrap_or(-1) +} + +/// A bundle whose signed document matches `pin_body`'s key, so `validate` +/// exercises the real acceptance path. +/// +/// Built by signing a real `TopicDocument` with a test mini-secret, then +/// embedding it: the CLI checks the signature exactly as the route does. +mod fixture { + use proof_task::{ + default_adamw, holdout_commitment, synthetic_holdout, Constraints, MetricDirection, + MetricFamily, MetricSpec, PayoutMode, TopicDocument, TopicStatus, FLOPS_BUDGET_MAX, + STRATUM_SIZE, + }; + + /// Test mini-secret. Not a real key, never a production one. + pub fn sk() -> [u8; 32] { + let mut s = [3u8; 32]; + s[0] = 17; + s + } + + pub fn pin_toml() -> String { + let pk = hex::encode(crypto::public_key_from_mini_secret(&sk()).expect("pk")); + format!( + r#"challenge_id = "proof" +scoring_version = 1 +base_model_family = "Qwen/Qwen3.8" +eval_image = "ghcr.io/cortexlm/proof-eval" +eval_image_digest = "sha256:{}" +topic_pubkey = "{pk}" +flops_budget_max = 2000000000000000000 +epsilon_nll_min = 0.02 +epsilon_topic_max_regress_min = 0.05 +epsilon_throughput_rel_min = 0.05 +quality_floor_nll_max = 0.02 +holdout_size = 120 +stratum_size = 24 + +[inference] +provider = "openai_compatible" +base_url = "http://127.0.0.1:8000/v1" +model = "master-proxy-v0" +mode = "chat" +max_input_tokens = 32768 +max_output_tokens = 8192 +"#, + "ab".repeat(32) + ) + } + + /// A signed custom topic selecting the in-guest runner, the shape the live + /// `tb4` topic has. + pub fn signed_topic(pack_digest: &str) -> TopicDocument { + let mut doc = TopicDocument { + id: "tb4".into(), + statement: "Score the pinned task pack with the pinned runner.".into(), + payout_mode: PayoutMode::Discovery, + constraints: Constraints::default(), + metric: MetricSpec { + family: MetricFamily::Custom, + primary: "primary_value".into(), + direction: MetricDirection::Max, + unit: "rate".into(), + epsilon_rel: 0.05, + custom_id: "tbench".into(), + ..MetricSpec::default() + }, + baseline: default_adamw(FLOPS_BUDGET_MAX), + holdout_commitment: holdout_commitment(&synthetic_holdout(STRATUM_SIZE, 1)), + status: TopicStatus::Draft, + ..TopicDocument::default() + }; + doc.constraints.params.insert( + proof_experiment::PARAM_RUNNER.into(), + "rlm_fc_in_guest_harbor".into(), + ); + doc.constraints.params.insert( + proof_experiment::PARAM_PACK_DIGEST.into(), + pack_digest.into(), + ); + doc.signature = doc.sign_with(&sk()).expect("sign"); + doc + } + + /// The Arch default bundle: slug `tb4`, custom id `tbench`. + pub fn bundle_json(environment: &str) -> String { + let hex = "ab".repeat(32); + let pack = format!("sha256:{hex}"); + let topic = signed_topic(&pack); + let bundle = serde_json::json!({ + "schema_version": 1, + "environment": environment, + "display_name": "Terminal-Bench 4", + "topic": topic, + "host": { + "rlm_image_digest": format!("sha256:{hex}"), + "experiment_image_digest": format!("sha256:{hex}"), + "pack_digest": pack, + "pack_dir": "/var/lib/proof/packs", + "custom_ids_entry": "tbench" + }, + // A small illustrative RLM section, so the committed fixture also + // exercises the hand-off. A real bundle carries the topic's own + // rules / migrations / apis / submission_format / scoring. + "rlm": { + "rules": [ + {"id": "no_short_circuit", "text": "the harness must run the task"} + ], + "submission_format": {"kind": "tar", "max_bytes": 5_242_880} + } + }); + serde_json::to_string_pretty(&bundle).expect("json") + } +} + +/// Regenerate the committed dry-run fixture. +/// +/// Gated on `PROOF_ADMIN_FIXTURE_DIR` so it is a no-op in CI. The fixture is +/// the operator artifact for the Owner A→Z walkthrough and must be signed by +/// the same test key its pin carries, so it cannot be hand-edited safely: +/// +/// ```bash +/// PROOF_ADMIN_FIXTURE_DIR=bins/proof-admin/tests/fixtures \ +/// cargo test -p proof-admin-bin --test cli regenerate_dry_run_fixture +/// ``` +#[test] +fn regenerate_dry_run_fixture() { + let Ok(dir) = std::env::var("PROOF_ADMIN_FIXTURE_DIR") else { + return; + }; + let dir = PathBuf::from(dir); + fs::create_dir_all(&dir).expect("fixture dir"); + fs::write( + dir.join("tb4.install-bundle.json"), + fixture::bundle_json("staging"), + ) + .expect("bundle"); + fs::write(dir.join("tb4.pin.toml"), fixture::pin_toml()).expect("pin"); +} + +/// The committed dry-run fixture must stay runnable. +/// +/// `tests/fixtures/tb4.bundle.json` + `tb4.pin.toml` are the operator artifact +/// the A→Z walkthrough uses, so a schema change that quietly breaks them must +/// fail here rather than in the Owner's hands. This runs the **same two +/// commands** the fixture README documents. +#[test] +fn the_committed_dry_run_fixture_still_validates_and_plans() { + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let bundle = fixtures.join("tb4.install-bundle.json"); + let pin = fixtures.join("tb4.pin.toml"); + assert!(bundle.is_file(), "missing {}", bundle.display()); + assert!(pin.is_file(), "missing {}", pin.display()); + + // 1. validate + let out = run(&[ + "topic", + "validate", + "--bundle", + bundle.to_str().unwrap(), + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), 0, "fixture must validate: {}", stderr(&out)); + let body = stdout(&out); + assert!(body.contains("topic_id tb4"), "{body}"); + assert!(body.contains("custom_id tbench"), "{body}"); + assert!( + body.contains("rlm_install present"), + "the fixture carries an RLM section: {body}" + ); + + // 2. install --dry-run, the documented staging command. + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "staging", + "--dry-run", + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), 0, "fixture must plan: {}", stderr(&out)); + let body = stdout(&out); + assert!(body.contains("environment staging"), "{body}"); + assert!(body.contains("owner_gate n/a (staging)"), "{body}"); + assert!( + body.contains("Hand control to the topic's RLM"), + "the plan must show the hand-off: {body}" + ); + + // The fixture is staging-only: a metal plan must be refused, both by the + // declared target and by the Owner gate. + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + "--owner-metal-ack", + "--dry-run", + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), EXIT_ERROR, "{}", stderr(&out)); + assert!( + stderr(&out).contains("declares environment staging"), + "{}", + stderr(&out) + ); +} + +#[test] +fn validate_accepts_the_arch_default_bundle_and_writes_nothing() { + let dir = workdir("validate-ok"); + let bundle = write_file(&dir, "tb4.json", &fixture::bundle_json("metal")); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let out = run(&[ + "topic", + "validate", + "--bundle", + bundle.to_str().unwrap(), + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let body = stdout(&out); + for needle in [ + "is valid", + "topic_id tb4", + "environment metal", + "custom_id tbench", + "runner_id rlm_fc_in_guest_harbor", + "bundle_digest sha256:", + "Nothing was written", + ] { + assert!(body.contains(needle), "missing {needle:?} in:\n{body}"); + } + fs::remove_dir_all(&dir).ok(); +} + +/// `validate` runs the same acceptance the publish route runs, so a document +/// the route would refuse is refused here — with the reason. +#[test] +fn validate_refuses_a_document_the_publish_route_would_refuse() { + let dir = workdir("validate-refuse"); + + // A signature that does not verify under the pin's topic key. + let mut wrong_key = + serde_json::from_str::(&fixture::bundle_json("metal")).expect("json"); + let mut other = [9u8; 32]; + other[1] = 4; + let doc: proof_task::TopicDocument = + serde_json::from_value(wrong_key["topic"].clone()).expect("document"); + let resigned = doc.sign_with(&other).expect("sign with another key"); + wrong_key["topic"]["signature"] = serde_json::Value::String(resigned); + let bundle = write_file(&dir, "wrong-key.json", &wrong_key.to_string()); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let out = run(&[ + "topic", + "validate", + "--bundle", + bundle.to_str().unwrap(), + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), EXIT_ERROR, "stderr={}", stderr(&out)); + assert!( + stderr(&out).contains("signature"), + "stderr must name the signature: {}", + stderr(&out) + ); + assert!( + stdout(&out).is_empty(), + "a failure prints nothing to stdout" + ); + + // An unknown key is refused rather than ignored: a step this build cannot + // name is a step nothing performs. + let mut unknown = + serde_json::from_str::(&fixture::bundle_json("metal")).expect("json"); + unknown["runner_id"] = serde_json::Value::String("rlm_fc_in_guest_harbor".into()); + let bundle = write_file(&dir, "unknown.json", &unknown.to_string()); + let out = run(&[ + "topic", + "validate", + "--bundle", + bundle.to_str().unwrap(), + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), EXIT_ERROR); + assert!( + stderr(&out).contains("runner_id"), + "stderr must name the unknown key: {}", + stderr(&out) + ); + + // A host expectation that contradicts the signed document. + let mut contradicting = + serde_json::from_str::(&fixture::bundle_json("metal")).expect("json"); + contradicting["host"]["pack_digest"] = + serde_json::Value::String(format!("sha256:{}", "cd".repeat(32))); + let bundle = write_file(&dir, "contradicting.json", &contradicting.to_string()); + let out = run(&[ + "topic", + "validate", + "--bundle", + bundle.to_str().unwrap(), + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), EXIT_ERROR); + assert!( + stderr(&out).contains("contradicts the signed document"), + "stderr={}", + stderr(&out) + ); + + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn validate_json_output_is_machine_readable() { + let dir = workdir("validate-json"); + let bundle = write_file(&dir, "tb4.json", &fixture::bundle_json("metal")); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let out = run(&[ + "--json", + "topic", + "validate", + "--bundle", + bundle.to_str().unwrap(), + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let parsed: serde_json::Value = + serde_json::from_str(&stdout(&out)).expect("validate --json is JSON"); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["topic_id"], "tb4"); + assert_eq!(parsed["environment"], "metal"); + assert_eq!(parsed["custom_id"], "tbench"); + assert_eq!(parsed["runner_id"], "rlm_fc_in_guest_harbor"); + assert!( + parsed["bundle_digest"] + .as_str() + .unwrap_or_default() + .starts_with("sha256:"), + "{parsed}" + ); + fs::remove_dir_all(&dir).ok(); +} + +/// The dry run prints the **existing** publish call and the host env, and +/// touches nothing. +#[test] +fn dry_run_install_prints_the_existing_publish_call_and_host_env() { + let dir = workdir("dry-run"); + let bundle = write_file(&dir, "tb4.json", &fixture::bundle_json("metal")); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + "--owner-metal-ack", + "--pin", + pin.to_str().unwrap(), + "--dry-run", + ]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let body = stdout(&out); + for needle in [ + "topic install plan", + "topic_id tb4", + "environment metal", + "custom_id tbench", + "runner_id rlm_fc_in_guest_harbor", + "Hand control to the topic's RLM (it installs and sets the topic up)", + "provision -> propose_rules -> baseline", + "Publish the signed document (one block; existing route, operator bearer)", + "jq '.topic'", + "mktemp -d", + "chmod 600", + "--data-binary @\"$PROOF_TOPIC_DIR/document.json\"", + "/challenge/proof/v1/admin/proof/topics", + "PROOF_VM_RUNNER_CUSTOM_IDS=tbench", + "PROOF_RLM_VM_IMAGE_DIGEST=sha256:", + "PROOF_EXPERIMENT_VM_IMAGE_DIGEST=sha256:", + "PROOF_VM_AGENT_EXPERIMENT_PACK_DIR=/var/lib/proof/packs", + "nothing was written and no host was touched", + ] { + assert!(body.contains(needle), "missing {needle:?} in:\n{body}"); + } + // The pack directory is a path, never the digest: the variable names a + // directory and the host re-hashes what it finds there. + assert!( + !body.contains("PROOF_VM_AGENT_EXPERIMENT_PACK_DIR=sha256:"), + "the pack dir must not carry a digest:\n{body}" + ); + // The bearer is never printed; the operator supplies it. + assert!( + body.contains("Bearer $PROOF_ADMIN_TOKEN"), + "the token must stay a placeholder:\n{body}" + ); + // The procedure must be runnable shell, not a placeholder an operator has + // to hand-edit. + assert!( + !body.contains(" = body + .lines() + .skip_while(|l| !l.contains("PROOF_TOPIC_DIR=$(mktemp -d)")) + .take_while(|l| !l.trim().is_empty()) + .map(str::trim) + .collect(); + assert!( + block.iter().any(|l| l.contains("jq '.topic'")) + && block.iter().any(|l| l.contains("curl -sS -X POST")), + "extraction and publication must be one block:\n{block:#?}" + ); + let script = block.join("\n"); + let status = std::process::Command::new("sh") + .arg("-n") + .arg("-c") + .arg(&script) + .status() + .expect("sh -n"); + assert!( + status.success(), + "the printed publish block must be valid shell: {script}" + ); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn dry_run_install_json_matches_the_plan_shape() { + let dir = workdir("dry-run-json"); + let bundle = write_file(&dir, "tb4.json", &fixture::bundle_json("staging")); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let out = run(&[ + "--json", + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "staging", + "--pin", + pin.to_str().unwrap(), + "--dry-run", + ]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let parsed: serde_json::Value = + serde_json::from_str(&stdout(&out)).expect("dry run --json is JSON"); + assert_eq!(parsed["topic_id"], "tb4"); + assert_eq!(parsed["environment"], "staging"); + assert_eq!(parsed["custom_id"], "tbench"); + assert_eq!(parsed["runner_id"], "rlm_fc_in_guest_harbor"); + assert_eq!(parsed["publish_route"], "POST /v1/admin/proof/topics"); + assert_eq!(parsed["pack_dir_env"], "PROOF_VM_AGENT_EXPERIMENT_PACK_DIR"); + assert!( + parsed["host_env"].as_array().is_some_and(|a| a.len() == 4), + "{parsed}" + ); + assert!(parsed["bundle_digest"].as_str().is_some(), "{parsed}"); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn install_refuses_an_environment_the_bundle_does_not_declare() { + let dir = workdir("env-mismatch"); + let bundle = write_file(&dir, "tb4.json", &fixture::bundle_json("metal")); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "staging", + "--pin", + pin.to_str().unwrap(), + "--dry-run", + ]); + assert_eq!(code(&out), EXIT_ERROR, "stderr={}", stderr(&out)); + let err = stderr(&out); + assert!( + err.contains("declares environment metal") && err.contains("--env staging"), + "stderr={err}" + ); + fs::remove_dir_all(&dir).ok(); +} + +/// A real install is out of scope for this slice: it must refuse loudly rather +/// than write anything, and it must not need a database to say so. +#[test] +fn a_real_install_is_not_implemented_and_changes_nothing() { + let dir = workdir("no-real-install"); + let bundle = write_file(&dir, "tb4.json", &fixture::bundle_json("metal")); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + "--owner-metal-ack", + "--pin", + pin.to_str().unwrap(), + ]); + assert_eq!(code(&out), EXIT_NOT_IMPLEMENTED, "stderr={}", stderr(&out)); + let err = stderr(&out); + assert!(err.contains("not implemented in this slice"), "{err}"); + assert!(err.contains("Nothing was changed"), "{err}"); + assert!(stdout(&out).is_empty(), "a stub prints nothing to stdout"); + fs::remove_dir_all(&dir).ok(); +} + +/// Owner default: metal is Owner-only and staging goes first, so a metal plan +/// without the explicit acknowledgement is a usage error, not a silent +/// fallback and not a partial install. +#[test] +fn a_metal_install_requires_the_owner_acknowledgement() { + let dir = workdir("metal-gate"); + let bundle = write_file(&dir, "tb4.json", &fixture::bundle_json("metal")); + let pin = write_file(&dir, "pin.toml", &fixture::pin_toml()); + let args = |extra: &[&str]| { + let mut a = vec![ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + "--pin", + pin.to_str().unwrap(), + "--dry-run", + ]; + a.extend_from_slice(extra); + run(&a) + }; + + // Without the flag: refused, and it says how to proceed. + let out = args(&[]); + assert_eq!(code(&out), EXIT_USAGE, "stderr={}", stderr(&out)); + let err = stderr(&out); + assert!(err.contains("Owner-only"), "{err}"); + assert!(err.contains("--owner-metal-ack"), "{err}"); + assert!( + err.contains("staging has passed"), + "the gate must state the staging precondition: {err}" + ); + assert!( + err.contains("--env \n staging") || err.contains("staging --dry-run"), + "the gate must point at staging first: {err}" + ); + assert!(stdout(&out).is_empty(), "a refused plan prints no plan"); + + // With the flag: the plan resolves and says the gate was acknowledged. + let out = args(&["--owner-metal-ack"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + assert!( + stdout(&out).contains("owner_gate acknowledged"), + "{}", + stdout(&out) + ); + + // Staging is never gated: that is the default path. + let staging = write_file(&dir, "tb4-staging.json", &fixture::bundle_json("staging")); + let out = run(&[ + "topic", + "install", + "--bundle", + staging.to_str().unwrap(), + "--env", + "staging", + "--pin", + pin.to_str().unwrap(), + "--dry-run", + ]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + assert!( + stdout(&out).contains("owner_gate n/a (staging)"), + "{}", + stdout(&out) + ); + + fs::remove_dir_all(&dir).ok(); +} + +/// The Owner default: slug `tb4` with `tbench` as a temporary alias. The +/// alias resolves through the store, and the CLI says which topic it hit. +#[tokio::test] +async fn an_alias_resolves_to_its_topic() { + let Some(url) = std::env::var("DATABASE_URL") + .ok() + .map(|u| u.trim().to_owned()) + .filter(|u| !u.is_empty()) + else { + return; + }; + let tp = match db::test_pool_with_url(&url).await { + Ok(tp) => tp, + Err(e) => panic!("test_pool: {e}"), + }; + let store = proof_rlm_store::PgRlmStore::new(tp.pool().clone()); + let doc = fixture::signed_topic(&format!("sha256:{}", "ab".repeat(32))); + proof_rlm_store::RlmStore::put_topic_version(&store, &doc) + .await + .expect("persist"); + + let schema = tp.schema().to_owned(); + let scoped = format!("{url}?options=-c%20search_path%3D{schema}%2Cpublic"); + let run_db = |args: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_proof-admin")) + .args(args) + .env("BASE_DATABASE_URL", &scoped) + .env_remove("BASE_DATABASE_URL_FILE") + .output() + .expect("run proof-admin") + }; + + // Before the alias exists, the temporary slug is unknown. + let out = run_db(&["topic", "show", "tbench"]); + assert_eq!(code(&out), EXIT_ERROR, "stderr={}", stderr(&out)); + + // Set the Owner default alias. + let out = run_db(&["topic", "alias", "set", "tbench", "--topic", "tb4"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + assert!(stdout(&out).contains("tbench -> tb4"), "{}", stdout(&out)); + + // The alias now resolves, and the CLI says so. + let out = run_db(&["topic", "show", "tbench"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let body = stdout(&out); + assert!(body.contains("tbench is an alias of tb4"), "{body}"); + assert!(body.contains("topic tb4"), "{body}"); + + let out = run_db(&["--json", "topic", "show", "tbench"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let parsed: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); + assert_eq!( + parsed["topic_id"], "tb4", + "the alias reports the canonical id" + ); + + // Listing shows the temporary mapping. + let out = run_db(&["topic", "alias", "list", "--topic", "tb4"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + assert!(stdout(&out).contains("tbench -> tb4"), "{}", stdout(&out)); + + // An alias for an unpublished topic is refused. + let out = run_db(&[ + "topic", + "alias", + "set", + "ghost", + "--topic", + "never-published", + ]); + assert_eq!(code(&out), EXIT_ERROR, "stderr={}", stderr(&out)); + assert!( + stderr(&out).contains("no published version"), + "{}", + stderr(&out) + ); + + // Retiring the alias leaves the topic alone. + let out = run_db(&["topic", "alias", "rm", "tbench"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let out = run_db(&["topic", "show", "tb4"]); + assert_eq!(code(&out), 0, "the topic survives: {}", stderr(&out)); + let out = run_db(&["topic", "alias", "rm", "tbench"]); + assert_eq!(code(&out), EXIT_ERROR, "already gone: {}", stderr(&out)); + + tp.drop_schema().await.expect("drop"); +} + +#[test] +fn read_commands_without_a_database_are_usage_errors() { + for args in [vec!["topic", "list"], vec!["topic", "show", "tb4"]] { + let out = run(&args); + assert_eq!(code(&out), EXIT_USAGE, "{args:?}: {}", stderr(&out)); + assert!( + stderr(&out).contains("needs a database"), + "{args:?}: {}", + stderr(&out) + ); + } +} + +#[test] +fn database_url_and_file_are_mutually_exclusive() { + let dir = workdir("db-url-both"); + let url_file = write_file(&dir, "url.txt", "postgres://example/db"); + let out = Command::new(env!("CARGO_BIN_EXE_proof-admin")) + .args([ + "topic", + "list", + "--database-url", + "postgres://example/other", + "--database-url-file", + url_file.to_str().unwrap(), + ]) + .env_remove("BASE_DATABASE_URL") + .env_remove("BASE_DATABASE_URL_FILE") + .output() + .expect("run"); + assert_eq!(code(&out), EXIT_USAGE, "stderr={}", stderr(&out)); + assert!(stderr(&out).contains("not both"), "stderr={}", stderr(&out)); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn enable_disable_and_seal_fail_closed_with_exit_3() { + for args in [ + vec!["topic", "enable", "tb4"], + vec!["topic", "disable", "tb4"], + vec!["topic", "seal", "tb4", "--value", "0.42"], + ] { + let out = run(&args); + assert_eq!( + code(&out), + EXIT_NOT_IMPLEMENTED, + "{args:?}: {}", + stderr(&out) + ); + let err = stderr(&out); + assert!( + err.contains("not implemented in this slice"), + "{args:?}: {err}" + ); + assert!( + err.contains("Nothing was changed"), + "a stub must say it changed nothing: {args:?}: {err}" + ); + assert!( + stdout(&out).is_empty(), + "a stub prints nothing to stdout: {args:?}" + ); + } +} + +#[test] +fn help_lists_every_p0_subcommand_and_says_what_is_not_implemented() { + let out = run(&["topic", "--help"]); + assert_eq!(code(&out), 0); + let body = stdout(&out); + for sub in [ + "validate", "install", "list", "show", "enable", "disable", "seal", + ] { + assert!(body.contains(sub), "missing subcommand {sub} in:\n{body}"); + } + assert!( + body.contains("--dry-run"), + "the dry-run flag must be discoverable:\n{body}" + ); + assert!( + body.to_lowercase() + .contains("not implemented in this slice"), + "the stubs must say so in help:\n{body}" + ); +} + +/// The admin CLI hands control to the RLM; it does not interpret the topic. +/// +/// This is the architectural guard: the CLI may *name* the RLM-owned parts in +/// its output, but no topic behavior may be compiled into it. A future edit +/// that branches on a topic id, or bakes in a rule, metric, or submit format, +/// fails here. +#[test] +fn the_cli_does_not_bake_in_topic_behavior() { + const SOURCE: &str = include_str!("../src/main.rs"); + // Strip comments: the crate may *explain* the boundary (and its help text + // shows an example bundle name), but no literal may live in logic. + let logic: String = SOURCE + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + // The one place a seed id is allowed is the CLI's own help/examples. + let without_examples = logic + .replace("tb4.json", "") + .replace("`tbench`", "") + .replace("`tb4`", ""); + assert!( + !without_examples.contains("tb4") && !without_examples.contains("tbench"), + "a topic id must not appear in CLI logic" + ); + for forbidden in [ + "terminal-bench", + "harbor", + "success_rate", + "no_short_circuit", + "submission_format", + ] { + assert!( + !without_examples.to_lowercase().contains(forbidden), + "{forbidden} must not be compiled into the admin CLI" + ); + } + // It must not read the RLM section's *contents* either: only carry them. + assert!( + !without_examples.contains("rlm.rules") + && !without_examples.contains("rlm.scoring") + && !without_examples.contains("rlm.migrations") + && !without_examples.contains("rlm.apis"), + "the CLI must carry the RLM section, never read into it" + ); +} + +/// The registry view reads the existing `proof_topic_version` rows. +#[tokio::test] +async fn the_registry_view_lists_what_the_scoring_path_persisted() { + let Some(url) = std::env::var("DATABASE_URL") + .ok() + .map(|u| u.trim().to_owned()) + .filter(|u| !u.is_empty()) + else { + return; + }; + let tp = match db::test_pool_with_url(&url).await { + Ok(tp) => tp, + Err(e) => panic!("test_pool: {e}"), + }; + // Persist through the scoring path's own store, then read it back through + // the CLI: there is one table, so the view cannot disagree with scoring. + let store = proof_rlm_store::PgRlmStore::new(tp.pool().clone()); + let doc = fixture::signed_topic(&format!("sha256:{}", "ab".repeat(32))); + proof_rlm_store::RlmStore::put_topic_version(&store, &doc) + .await + .expect("persist through the scoring path"); + + let schema = tp.schema().to_owned(); + let scoped = format!("{url}?options=-c%20search_path%3D{schema}%2Cpublic"); + let run_db = |args: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_proof-admin")) + .args(args) + .env("BASE_DATABASE_URL", &scoped) + .env_remove("BASE_DATABASE_URL_FILE") + .output() + .expect("run proof-admin") + }; + + let out = run_db(&["--json", "topic", "list"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let listed: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); + assert_eq!(listed[0]["topic_id"], "tb4"); + assert_eq!(listed[0]["version"], 1); + assert_eq!(listed[0]["custom_id"], "tbench"); + + let out = run_db(&["--json", "topic", "show", "tb4"]); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let shown: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); + assert_eq!(shown["topic_id"], "tb4"); + assert_eq!(shown["document"]["id"], "tb4"); + assert_eq!(shown["document"]["signature"], doc.signature); + + // An unknown id is an error that says what to do, not an empty success. + let out = run_db(&["topic", "show", "nope"]); + assert_eq!(code(&out), EXIT_ERROR); + assert!( + stderr(&out).contains("no installed topic"), + "{}", + stderr(&out) + ); + + tp.drop_schema().await.expect("drop"); +} diff --git a/bins/proof-admin/tests/fixtures/README-dry-run.md b/bins/proof-admin/tests/fixtures/README-dry-run.md new file mode 100644 index 000000000..c934ebf19 --- /dev/null +++ b/bins/proof-admin/tests/fixtures/README-dry-run.md @@ -0,0 +1,115 @@ +# `proof-admin` dry-run fixture — Owner A→Z + +Operator dry-run artifact for the dynamic-topics P0 skeleton (PR #297). +**Nothing here is a production topic.** + +| File | What it is | +|------|------------| +| `tb4.install-bundle.json` | A **Topic Install Bundle**: slug `tb4`, alias `tbench`, install target `staging`, carrying a signed `TopicDocument` and an RLM install section. | +| `tb4.pin.toml` | The `ProofPin` that document is checked against. | + +## Exact commands + +Run from the repository root: + +```bash +cargo run -p proof-admin-bin -- topic validate \ + --bundle bins/proof-admin/tests/fixtures/tb4.install-bundle.json \ + --pin bins/proof-admin/tests/fixtures/tb4.pin.toml + +cargo run -p proof-admin-bin -- topic install \ + --bundle bins/proof-admin/tests/fixtures/tb4.install-bundle.json \ + --env staging --dry-run \ + --pin bins/proof-admin/tests/fixtures/tb4.pin.toml +``` + +`--bin proof-admin` works too and is package-name-agnostic: + +```bash +cargo run --bin proof-admin -- topic validate \ + --bundle bins/proof-admin/tests/fixtures/tb4.install-bundle.json \ + --pin bins/proof-admin/tests/fixtures/tb4.pin.toml +``` + +Both write nothing and need no database. + +### Two things the command needs + +**`-p proof-admin-bin`, not `-p proof-admin`.** The repo names binary packages +with a `-bin` suffix (`trustroot-bin` → `trustroot`, `validator-bin` → +`validator`), so the package is `proof-admin-bin` and the *binary* is +`proof-admin`. `cargo run --bin proof-admin -- …` sidesteps the distinction. + +### `--pin` is required, and here is why + +The fixture is signed with the **test mini-secret** the CLI tests use, so it +must be checked against the fixture pin. Omitting `--pin` falls back to +`config/proof-pin.toml`, which carries the **real** proof trust root: + +``` +$ cargo run -p proof-admin -- topic validate \ + --bundle bins/proof-admin/tests/fixtures/tb4.install-bundle.json +proof-admin: topic signature: topic signature does not verify under the proof trust-root key +``` + +That refusal is the signature check working correctly — a test-signed document +is not this subnet's topic. The real `tb4` document is signed by the `proof` +row key and is a follow-up (see below). + +## What this fixture is not + +- **Not a production topic.** The document is signed with a test mini-secret; + `tb4.pin.toml` carries the matching `topic_pubkey`. +- **Not a real RLM install.** The `rlm` section is a small illustrative sample + (`rules`, `submission_format`). A real bundle carries the topic's own rules, + migrations, APIs, submission format, and scoring — which the RLM consumes + and this repository never interprets. +- **Not the metal artifact.** The metal signed Operator `tb4.json` is a + follow-up; this fixture exists so the staging A→Z walkthrough can exercise + `validate` and `--dry-run` today. + +## Staging migrate + +`crates/db/migrations/0024_proof_topic_alias.sql` is the **only** schema +change in this PR. + +**There is no manual migration command to run.** Migrations are embedded in +the `db` crate (`sqlx::migrate!("./migrations")`) and applied automatically on +boot wherever `BASE_DATABASE_URL` is set — the gateway does this. So the +staging path is the **service restart**: + +```bash +# Restart the master services with BASE_DATABASE_URL set (compose / remote-deploy). +# Migrations apply on boot; no separate step. +``` + +`cargo sqlx migrate run` is **not** an option in this repo: `sqlx-cli` is not +a workspace dependency and is not installed in a clean checkout, so that +command fails with `error: no such command: sqlx`. + +What it does, exactly: + +- **Adds** `proof_topic_alias` (`alias → topic_id`, plus a `topic_id` index). + It is a mapping and nothing else — no display name, no pins, no status, no + document; those stay in `proof_topic_version` (migration `0020`). +- **Adds** `BEFORE INSERT` (and `UPDATE` on the alias table) triggers, + `proof_topic_alias_no_shadow` and `proof_topic_version_no_shadow`, which + make an alias collision with a published slug fail closed in **both** + directions. This is a **publish-path integrity guard, not scoring math** — + it cannot change a score, a payout, or a sealed vector. +- **Does not** `ALTER` or `DROP` anything: the `0020` tables keep their + columns, keys, and grants. + +## Regenerating + +The bundle's document must verify under its pin, so both files come from the +same test fixture the CLI tests use. Do not hand-edit them: + +```bash +PROOF_ADMIN_FIXTURE_DIR=bins/proof-admin/tests/fixtures \ + cargo test -p proof-admin-bin --test cli regenerate_dry_run_fixture +``` + +`the_committed_dry_run_fixture_still_validates_and_plans` runs both documented +commands on every test run, so a schema change that breaks this fixture fails +CI rather than reaching the Owner. diff --git a/bins/proof-admin/tests/fixtures/tb4.install-bundle.json b/bins/proof-admin/tests/fixtures/tb4.install-bundle.json new file mode 100644 index 000000000..7cff616e9 --- /dev/null +++ b/bins/proof-admin/tests/fixtures/tb4.install-bundle.json @@ -0,0 +1,97 @@ +{ + "display_name": "Terminal-Bench 4", + "environment": "staging", + "host": { + "custom_ids_entry": "tbench", + "experiment_image_digest": "sha256:abababababababababababababababababababababababababababababababab", + "pack_digest": "sha256:abababababababababababababababababababababababababababababababab", + "pack_dir": "/var/lib/proof/packs", + "rlm_image_digest": "sha256:abababababababababababababababababababababababababababababababab" + }, + "rlm": { + "rules": [ + { + "id": "no_short_circuit", + "text": "the harness must run the task" + } + ], + "submission_format": { + "kind": "tar", + "max_bytes": 5242880 + } + }, + "schema_version": 1, + "topic": { + "baseline": { + "betas": [ + 0.9, + 0.95 + ], + "dtype": "bf16", + "eps": 1e-8, + "flops_budget": 2000000000000000000, + "lr": 0.0003, + "metrics_commitment": "", + "notes": "", + "optimizer": "adamw", + "schedule": "cosine", + "script_sha256": "", + "seed": 42, + "wall_budget_s": 0, + "warmup_ratio": 0.02, + "weight_decay": 0.1 + }, + "constraints": { + "max_inter_node_gbps": null, + "no_infiniband": false, + "no_nccl_fast_fabric": false, + "no_nvlink": false, + "params": { + "experiment_pack_digest": "sha256:abababababababababababababababababababababababababababababababab", + "in_guest_benchmark_runner": "rlm_fc_in_guest_harbor" + } + }, + "discovery": { + "novelty_pool_share_bps": 7000, + "pass_floor_share_bps": 3000 + }, + "epsilon_nll": 0.02, + "epsilon_topic_max_regress": 0.05, + "flops_budget": 2000000000000000000, + "holdout_commitment": "e2f97658a5ff704f1f20982c8e3bbda5aba9793ac390df7f96a3641f33e5483d", + "holdout_size": 120, + "id": "tb4", + "inference": { + "base_url": null, + "max_input_tokens": null, + "max_output_tokens": null, + "mode": null, + "model": null, + "provider": null, + "require_judge_offer_commitment": null + }, + "metric": { + "custom_id": "tbench", + "direction": "max", + "epsilon_rel": 0.05, + "family": "custom", + "primary": "primary_value", + "quality_floor_nll": 0.0, + "unit": "rate", + "wall_budget_s": 0 + }, + "payout_mode": "discovery", + "proxy_model": null, + "schema_version": 1, + "signature": "14252aa026fda0a80957ea01c952311f171707cbc0c7d966e2594a8edd46a732d5132660f89c7a77cd4efba6fb6bf47803f155af26783fe46fc8ae418aee2a85", + "statement": "Score the pinned task pack with the pinned runner.", + "status": "draft", + "valid_from_epoch": 0, + "valid_until_epoch": null, + "validation": { + "accept_if": "reproduced, no contamination, beat baseline by epsilon", + "reject_if": "unreproduced claim, FLOP over budget, harness short-circuit", + "score_on": "holdout metric vs sealed baseline" + } + } +} \ No newline at end of file diff --git a/bins/proof-admin/tests/fixtures/tb4.pin.toml b/bins/proof-admin/tests/fixtures/tb4.pin.toml new file mode 100644 index 000000000..48e778e0a --- /dev/null +++ b/bins/proof-admin/tests/fixtures/tb4.pin.toml @@ -0,0 +1,21 @@ +challenge_id = "proof" +scoring_version = 1 +base_model_family = "Qwen/Qwen3.8" +eval_image = "ghcr.io/cortexlm/proof-eval" +eval_image_digest = "sha256:abababababababababababababababababababababababababababababababab" +topic_pubkey = "5a714bcc1332e8ce4354ab4573450fc2517beb5568e3771c48e7195b415e3d7b" +flops_budget_max = 2000000000000000000 +epsilon_nll_min = 0.02 +epsilon_topic_max_regress_min = 0.05 +epsilon_throughput_rel_min = 0.05 +quality_floor_nll_max = 0.02 +holdout_size = 120 +stratum_size = 24 + +[inference] +provider = "openai_compatible" +base_url = "http://127.0.0.1:8000/v1" +model = "master-proxy-v0" +mode = "chat" +max_input_tokens = 32768 +max_output_tokens = 8192 diff --git a/crates/db/migrations/0024_proof_topic_alias.sql b/crates/db/migrations/0024_proof_topic_alias.sql new file mode 100644 index 000000000..9c6993ef5 --- /dev/null +++ b/crates/db/migrations/0024_proof_topic_alias.sql @@ -0,0 +1,101 @@ +-- Proof topic aliases: the temporary compatibility slug a topic answers to. +-- +-- Owner default: the first topic's slug is `tb4`, with `tbench` as a +-- **temporary** alias, so existing miner links keep resolving while the +-- canonical slug moves. This table is that mapping and nothing else. +-- +-- Why this is not a second topic table: a row here is `alias -> topic_id`. +-- No display name, no pins, no status, no document — every one of those lives +-- in `proof_topic_version` (migration 0020), which stays the single source of +-- truth for what a topic *is*. An alias cannot drift from the topic it names +-- because it carries no topic data to drift. Deleting the row retires the +-- alias; nothing else changes. +-- +-- Shared challenge DB, `topic_id` discriminant: the same table serves every +-- topic in the one database, exactly like `proof_topic_version`. There is no +-- per-topic schema anywhere in this design. +-- +-- No foreign key: `proof_topic_version` is keyed `(topic_id, version)`, so +-- `topic_id` alone is not unique and cannot be an FK target. Resolution is +-- therefore fail-closed in the store instead — an alias whose topic has no +-- published version resolves to *nothing*, never to an empty document. The +-- alias CHECKs are shape guards only. +-- +-- A canonical slug is never shadowed. If an alias equals some *other* +-- published topic's id, resolving that id as an alias would hand back a +-- different topic's signed document. The store refuses to write such a row +-- and refuses to resolve one, and the trigger below closes the same hole for +-- a writer that goes straight to SQL. Both directions are needed: the trigger +-- fires when the alias is written, and again when a topic is published under +-- a name that an existing alias already claims. +-- +-- `alias` is a topic slug (`[a-z0-9][a-z0-9-]{1,62}`), matching the id shape +-- `proof_topic_version` enforces, and an alias may never be its own topic's +-- id: that would be a second spelling of the same key in one lookup. +-- +-- Mutable and **deletable**, unlike the journal tables: retiring a temporary +-- alias is the intended end state, so `base_app` gets DELETE here. + +CREATE TABLE proof_topic_alias ( + alias TEXT PRIMARY KEY, + topic_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT proof_topic_alias_slug_check CHECK (alias ~ '^[a-z0-9][a-z0-9-]{1,62}$'), + CONSTRAINT proof_topic_alias_topic_check CHECK (topic_id ~ '^[a-z0-9][a-z0-9-]{1,62}$'), + CONSTRAINT proof_topic_alias_not_self CHECK (alias <> topic_id) +); + +-- The read is "every alias of this topic" (list/show) and the reverse +-- single-alias lookup (resolve). +CREATE INDEX ix_proof_topic_alias_topic ON proof_topic_alias (topic_id); + +-- `topic_id` alone is not unique in `proof_topic_version` (it is keyed by +-- `(topic_id, version)`), so the shadow guard cannot be a UNIQUE constraint. +-- It is a trigger instead, checked in both directions: a published topic may +-- not be claimed as an alias, and an alias may not be published as a topic. +-- +-- An `EXISTS` check alone is not enough: under READ COMMITTED two concurrent +-- claims for the same slug each see no row from the other, so *both* commit +-- and the slug is shadowed after all. A transaction-scoped advisory lock on +-- the slug serializes the pair, so the second claim blocks until the first +-- commits and then sees it. The lock is keyed on the slug, so unrelated +-- topics never contend, and it is released automatically at commit/rollback. +CREATE OR REPLACE FUNCTION proof_topic_claim_lock(slug text) RETURNS void AS $$ +BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended(slug, 0)); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION proof_topic_alias_no_shadow() RETURNS trigger AS $$ +DECLARE + slug text; +BEGIN + IF TG_TABLE_NAME = 'proof_topic_alias' THEN + slug := NEW.alias; + PERFORM proof_topic_claim_lock(slug); + IF EXISTS (SELECT 1 FROM proof_topic_version WHERE topic_id = slug) THEN + RAISE EXCEPTION 'alias % is already a published topic id', slug + USING ERRCODE = 'check_violation'; + END IF; + ELSE + slug := NEW.topic_id; + PERFORM proof_topic_claim_lock(slug); + IF EXISTS (SELECT 1 FROM proof_topic_alias WHERE alias = slug) THEN + RAISE EXCEPTION 'topic % is already claimed as an alias', slug + USING ERRCODE = 'check_violation'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER proof_topic_alias_no_shadow + BEFORE INSERT OR UPDATE ON proof_topic_alias + FOR EACH ROW EXECUTE FUNCTION proof_topic_alias_no_shadow(); + +CREATE TRIGGER proof_topic_version_no_shadow + BEFORE INSERT ON proof_topic_version + FOR EACH ROW EXECUTE FUNCTION proof_topic_alias_no_shadow(); + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE proof_topic_alias TO base_app; diff --git a/crates/proof-rlm-store/Cargo.toml b/crates/proof-rlm-store/Cargo.toml index 967b61cc7..838188e54 100644 --- a/crates/proof-rlm-store/Cargo.toml +++ b/crates/proof-rlm-store/Cargo.toml @@ -22,7 +22,7 @@ tracing = "0.1" [dev-dependencies] db = { path = "../db", features = ["testing"] } proof-rlm = { path = "../proof-rlm", features = ["test-fixtures"] } -tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "time"] } [lints] workspace = true diff --git a/crates/proof-rlm-store/src/lib.rs b/crates/proof-rlm-store/src/lib.rs index 996c65cbe..a81dfebe8 100644 --- a/crates/proof-rlm-store/src/lib.rs +++ b/crates/proof-rlm-store/src/lib.rs @@ -85,6 +85,21 @@ impl ChecklistRow { } } +/// One temporary compatibility alias for a topic slug. +/// +/// Owner default: the first topic's slug is `tb4` with `tbench` as a +/// **temporary** alias, so existing miner links keep resolving while the +/// canonical slug settles. A row carries the mapping and nothing else — no +/// name, no pins, no status — so it cannot drift from the topic it names. +/// Retiring the alias is deleting the row. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TopicAliasRow { + /// The alias slug that resolves to `topic_id`. + pub alias: String, + /// The canonical topic slug the alias names. + pub topic_id: String, +} + /// One lifecycle move for one topic. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TransitionRow { @@ -100,6 +115,22 @@ pub struct TransitionRow { pub note: String, } +/// One persisted topic version, as the registry view reads it. +/// +/// This is a **view** over `proof_topic_version`, not a second topic table: +/// every field except `version` lives inside the signed document, which stays +/// the one source of truth. The status is `document.status`, and the +/// signature is `document.signature`; neither is duplicated here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TopicVersionRow { + /// Topic slug (the registry key). + pub topic_id: String, + /// Newest persisted version for that slug. + pub version: u32, + /// The signed document, verbatim. + pub document: TopicDocument, +} + /// What the RLM measured before any submission (learning continuum start). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct BaselineRow { @@ -181,6 +212,33 @@ pub trait RlmStore: Send + Sync { topic_id: &str, ) -> Result, StoreError>; + /// Newest persisted version of **every** topic, ordered by `topic_id`. + /// + /// A read-only registry view over the same `proof_topic_version` rows + /// [`Self::latest_topic`] reads. It exists so the operator CLI can list + /// what is installed without a second table that could disagree with the + /// signed documents. An empty result is an empty vector, not an error: + /// nothing is installed yet is a normal state. + async fn latest_topics(&self) -> Result, StoreError>; + + /// Record (or replace) a temporary alias for a topic slug. + /// + /// Fail-closed: the topic must already have a published version, because + /// an alias pointing at nothing would resolve to no document and look + /// like an unknown topic to a miner. `alias == topic_id` is refused — that + /// is the topic's own key, not an alias. + async fn put_alias(&self, alias: &str, topic_id: &str) -> Result<(), StoreError>; + /// The topic slug `alias` resolves to, or `None`. + /// + /// `None` covers both "no such alias" and "the aliased topic has no + /// published version", so a stale row can never resolve to an empty + /// document. + async fn resolve_alias(&self, alias: &str) -> Result, StoreError>; + /// Every alias of one topic, ordered by alias. + async fn aliases_for(&self, topic_id: &str) -> Result, StoreError>; + /// Remove a temporary alias. Returns whether a row was deleted. + async fn delete_alias(&self, alias: &str) -> Result; + /// Persist a rule version. Must be `current + 1` (or 1 for the first). async fn put_rules(&self, rules: &RuleSet) -> Result<(), StoreError>; /// Newest rule version. diff --git a/crates/proof-rlm-store/src/memory.rs b/crates/proof-rlm-store/src/memory.rs index 3e731d176..d47678069 100644 --- a/crates/proof-rlm-store/src/memory.rs +++ b/crates/proof-rlm-store/src/memory.rs @@ -11,7 +11,7 @@ use proof_task::TopicDocument; use crate::{ check_artefact, check_promotion, check_rules, parse_row_id, replay, ArtefactRow, BaselineRow, - ChecklistRow, PromotionRow, RlmStore, StoreError, TransitionRow, + ChecklistRow, PromotionRow, RlmStore, StoreError, TopicVersionRow, TransitionRow, }; #[derive(Default)] @@ -23,6 +23,7 @@ struct Inner { baselines: BTreeMap>, artefacts: BTreeMap>, promotions: BTreeMap>, + aliases: BTreeMap, } /// In-memory store. @@ -64,6 +65,69 @@ impl RlmStore for MemoryRlmStore { })) } + async fn latest_topics(&self) -> Result, StoreError> { + let g = self.lock()?; + // `BTreeMap` iteration is already ordered by topic id, which is the + // order the Postgres view returns. + let mut out = Vec::with_capacity(g.topics.len()); + for (topic_id, versions) in &g.topics { + let Some(doc) = versions.last() else { + continue; + }; + out.push(TopicVersionRow { + topic_id: topic_id.clone(), + version: u32::try_from(versions.len()).unwrap_or(u32::MAX), + document: doc.clone(), + }); + } + Ok(out) + } + + async fn put_alias(&self, alias: &str, topic_id: &str) -> Result<(), StoreError> { + let mut g = self.lock()?; + if !g.topics.contains_key(topic_id) { + return Err(StoreError::Malformed(format!( + "alias {alias:?} names topic {topic_id:?}, which has no published version" + ))); + } + // A canonical slug is never shadowed (see the Postgres store). + if g.topics.contains_key(alias) { + return Err(StoreError::Malformed(format!( + "alias {alias:?} is already a published topic id; a canonical slug is never \ + shadowed by an alias" + ))); + } + g.aliases.insert(alias.to_owned(), topic_id.to_owned()); + Ok(()) + } + + async fn resolve_alias(&self, alias: &str) -> Result, StoreError> { + let g = self.lock()?; + // Fail closed like Postgres: the target must still have a version. + // A canonical slug wins: never resolve an alias whose own name is a + // published topic, or `show` would return a different topic. + if g.topics.contains_key(alias) { + return Ok(None); + } + Ok(g.aliases + .get(alias) + .filter(|t| g.topics.contains_key(*t)) + .cloned()) + } + + async fn aliases_for(&self, topic_id: &str) -> Result, StoreError> { + let g = self.lock()?; + Ok(g.aliases + .iter() + .filter(|(_, t)| t.as_str() == topic_id) + .map(|(a, _)| a.clone()) + .collect()) + } + + async fn delete_alias(&self, alias: &str) -> Result { + Ok(self.lock()?.aliases.remove(alias).is_some()) + } + async fn put_rules(&self, rules: &RuleSet) -> Result<(), StoreError> { let mut g = self.lock()?; let versions = g.rules.entry(rules.topic_id.clone()).or_default(); diff --git a/crates/proof-rlm-store/src/pg.rs b/crates/proof-rlm-store/src/pg.rs index 94785d399..a3cc56cfe 100644 --- a/crates/proof-rlm-store/src/pg.rs +++ b/crates/proof-rlm-store/src/pg.rs @@ -15,7 +15,7 @@ use serde_json::Value; use crate::{ check_artefact, check_promotion, check_rules, parse_row_id, replay, ArtefactRow, BaselineRow, - ChecklistRow, PromotionRow, RlmStore, StoreError, TransitionRow, + ChecklistRow, PromotionRow, RlmStore, StoreError, TopicVersionRow, TransitionRow, }; /// Postgres-backed store. @@ -195,6 +195,123 @@ impl RlmStore for PgRlmStore { .transpose() } + /// Newest version per topic, read straight from `proof_topic_version`. + /// + /// `DISTINCT ON` is the whole query: the journal is append-only, so the + /// newest row per slug *is* the current one, and no second table has to + /// be kept in step with it. + async fn latest_topics(&self) -> Result, StoreError> { + let rows: Vec<(String, i32, Value)> = sqlx::query_as( + "SELECT DISTINCT ON (topic_id) topic_id, version, document \ + FROM proof_topic_version ORDER BY topic_id, version DESC", + ) + .fetch_all(&self.pool) + .await?; + let mut out = Vec::with_capacity(rows.len()); + for (topic_id, version, doc) in rows { + out.push(TopicVersionRow { + topic_id, + version: to_u32(version)?, + document: serde_json::from_value(doc).map_err(malformed)?, + }); + } + Ok(out) + } + + async fn put_alias(&self, alias: &str, topic_id: &str) -> Result<(), StoreError> { + // The whole check-then-insert runs in **one** transaction, because the + // guard has to be atomic: `pg_advisory_xact_lock` is released at the + // end of its transaction, so issuing these as separate statements + // would drop the lock before the insert and reopen the race the + // migration's trigger closes. The trigger takes the same lock, so the + // two layers serialize against each other rather than against + // different keys. + let mut tx = self.pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(alias) + .execute(&mut *tx) + .await?; + // Fail closed before the write: an alias must name a topic that + // actually has a published version, or resolution would hand back + // nothing and look like an unknown topic. + let target_published: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM proof_topic_version WHERE topic_id = $1)", + ) + .bind(topic_id) + .fetch_one(&mut *tx) + .await?; + if !target_published { + return Err(StoreError::Malformed(format!( + "alias {alias:?} names topic {topic_id:?}, which has no published version" + ))); + } + // A canonical slug is never shadowed. If `alias` is itself a + // published topic, then resolving it as an alias would hand back a + // *different* topic's signed document for that slug. + let alias_is_topic: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM proof_topic_version WHERE topic_id = $1)", + ) + .bind(alias) + .fetch_one(&mut *tx) + .await?; + if alias_is_topic { + return Err(StoreError::Malformed(format!( + "alias {alias:?} is already a published topic id; a canonical slug is never \ + shadowed by an alias" + ))); + } + sqlx::query( + "INSERT INTO proof_topic_alias (alias, topic_id) VALUES ($1, $2) \ + ON CONFLICT (alias) DO UPDATE SET topic_id = EXCLUDED.topic_id, \ + updated_at = now()", + ) + .bind(alias) + .bind(topic_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + async fn resolve_alias(&self, alias: &str) -> Result, StoreError> { + // The join is the fail-closed part: an alias whose topic has no + // published version resolves to nothing rather than to an empty + // document. + let row: Option<(String,)> = sqlx::query_as( + // Three guards, all fail-closed: the alias must exist, its target + // must have a published version (else it resolves to an empty + // document), and the alias must **not** itself be a published + // topic id — a canonical slug always wins over an alias, so a row + // that predates this guard cannot shadow one either. + "SELECT a.topic_id FROM proof_topic_alias a \ + WHERE a.alias = $1 \ + AND EXISTS (SELECT 1 FROM proof_topic_version v WHERE v.topic_id = a.topic_id) \ + AND NOT EXISTS (SELECT 1 FROM proof_topic_version s WHERE s.topic_id = a.alias)", + ) + .bind(alias) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(t,)| t)) + } + + async fn aliases_for(&self, topic_id: &str) -> Result, StoreError> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT alias FROM proof_topic_alias WHERE topic_id = $1 ORDER BY alias", + ) + .bind(topic_id) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(a,)| a).collect()) + } + + async fn delete_alias(&self, alias: &str) -> Result { + let done = sqlx::query("DELETE FROM proof_topic_alias WHERE alias = $1") + .bind(alias) + .execute(&self.pool) + .await?; + Ok(done.rows_affected() > 0) + } + async fn put_rules(&self, rules: &RuleSet) -> Result<(), StoreError> { let current = self .current_rules(&rules.topic_id) diff --git a/crates/proof-rlm-store/tests/store_contract.rs b/crates/proof-rlm-store/tests/store_contract.rs index f6f5446e6..10ca1ccf8 100644 --- a/crates/proof-rlm-store/tests/store_contract.rs +++ b/crates/proof-rlm-store/tests/store_contract.rs @@ -17,6 +17,9 @@ async fn contract(store: &dyn RlmStore) { // Topic versions advance per persisted document. assert!(store.latest_topic(&t.id).await.unwrap().is_none()); + // The registry view is empty before anything is installed, and empty is + // an empty vector rather than an error. + assert!(store.latest_topics().await.unwrap().is_empty()); assert_eq!(store.put_topic_version(&t).await.unwrap(), 1); let mut resigned = t.clone(); resigned.statement.push_str(" (v2)"); @@ -25,6 +28,94 @@ async fn contract(store: &dyn RlmStore) { assert_eq!(v, 2); assert!(latest.statement.ends_with("(v2)")); + // The registry view reads the newest version of every topic, ordered by + // id, and carries the signed document verbatim — the one source of truth. + let listed = store.latest_topics().await.unwrap(); + assert_eq!(listed.len(), 1, "{listed:?}"); + assert_eq!(listed[0].topic_id, t.id); + assert_eq!(listed[0].version, 2); + assert_eq!(listed[0].document, resigned); + let mut other = t.clone(); + other.id = "aaa-other-v0".into(); + store.put_topic_version(&other).await.unwrap(); + let listed = store.latest_topics().await.unwrap(); + assert_eq!( + listed + .iter() + .map(|r| r.topic_id.as_str()) + .collect::>(), + ["aaa-other-v0", t.id.as_str()], + "ordered by topic_id" + ); + + // Aliases: the Owner default is slug `tb4` with temporary alias `tbench`. + // An alias resolves to the canonical slug, an unknown one to nothing, and + // an alias for a topic with no published version is refused outright. + assert!(store.resolve_alias("tbench").await.unwrap().is_none()); + store.put_alias("tbench", &t.id).await.unwrap(); + assert_eq!( + store.resolve_alias("tbench").await.unwrap().as_deref(), + Some(t.id.as_str()) + ); + assert_eq!(store.aliases_for(&t.id).await.unwrap(), ["tbench"]); + assert!(store + .resolve_alias("no-such-alias") + .await + .unwrap() + .is_none()); + assert!( + store.put_alias("orphan", "never-published").await.is_err(), + "an alias must name a topic that has a published version" + ); + assert!( + store.resolve_alias("orphan").await.unwrap().is_none(), + "the refused alias must not have been written" + ); + // A canonical slug is never shadowed. An alias that equals another + // *published* topic's id would make that slug resolve to a different + // topic's document, so it is refused at write time and never resolved. + store + .put_alias("shadow-attempt", "aaa-other-v0") + .await + .unwrap(); + assert!( + store.put_alias("aaa-other-v0", &t.id).await.is_err(), + "an alias may not take a published topic's canonical slug" + ); + assert_eq!( + store + .resolve_alias("aaa-other-v0") + .await + .unwrap() + .as_deref(), + None, + "the canonical slug must not resolve to another topic" + ); + store.delete_alias("shadow-attempt").await.unwrap(); + + // A second alias on the same topic, then retire one. + store.put_alias("tb4-legacy", &t.id).await.unwrap(); + assert_eq!( + store.aliases_for(&t.id).await.unwrap(), + ["tb4-legacy", "tbench"], + "aliases are ordered by alias" + ); + assert!(store.delete_alias("tb4-legacy").await.unwrap()); + assert!( + !store.delete_alias("tb4-legacy").await.unwrap(), + "already gone" + ); + assert_eq!(store.aliases_for(&t.id).await.unwrap(), ["tbench"]); + // Re-pointing an existing alias replaces it rather than conflicting. + let other = store.latest_topic("aaa-other-v0").await.unwrap(); + assert!(other.is_some(), "the second topic was published above"); + store.put_alias("tbench", "aaa-other-v0").await.unwrap(); + assert_eq!( + store.resolve_alias("tbench").await.unwrap().as_deref(), + Some("aaa-other-v0") + ); + assert!(store.aliases_for(&t.id).await.unwrap().is_empty()); + // Rules: v1 from the document, v2 from the RLM, gaps refused. let v1 = rules(); assert!(store.current_rules(&t.id).await.unwrap().is_none()); @@ -222,6 +313,100 @@ async fn memory_store_honours_the_contract() { contract(&MemoryRlmStore::new()).await; } +/// An alias claim and a topic publish must not both claim one slug. +/// +/// This is the **cross-table** race the advisory lock exists for. The alias +/// insert and the topic publish each check the other table, and under READ +/// COMMITTED neither sees the other's uncommitted row. Two things must hold, +/// and the test checks both because each covers a different layer: +/// +/// 1. **Blocking.** With the shared transaction-scoped lock, the publish waits +/// for the in-flight alias claim. Without the lock it sees no committed +/// alias and succeeds immediately. (Pass condition: still blocked.) +/// 2. **Rejection.** Once the alias claim *commits* and releases the lock, the +/// waiting publish must be **refused**, not admitted — that is the +/// publisher-side collision check. A test that only asserts the block +/// stays green if that check is deleted, which is exactly the regression +/// this covers. +/// +/// Postgres-only: the memory store has one mutex and no such race. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_concurrent_alias_and_topic_publish_cannot_both_claim_a_slug() { + let Ok(url) = std::env::var("DATABASE_URL") else { + return; + }; + if url.trim().is_empty() { + return; + } + let tp = db::test_pool_with_url(&url).await.expect("isolated schema"); + let pool = tp.pool(); + let store = PgRlmStore::new(pool.clone()); + + // A published topic for the alias to point at. + let doc = topic(); + store.put_topic_version(&doc).await.unwrap(); + let contested = "contested-slug-v0"; + + // A: claim `contested` as an alias and hold the transaction open. + let mut holder = pool.begin().await.expect("begin holder"); + sqlx::query("INSERT INTO proof_topic_alias (alias, topic_id) VALUES ($1, $2)") + .bind(contested) + .bind(&doc.id) + .execute(&mut *holder) + .await + .expect("alias insert inside the open transaction"); + + // B: publish a topic whose id is `contested`, on another connection. + let publisher_pool = pool.clone(); + let mut publisher = tokio::spawn(async move { + sqlx::query( + "INSERT INTO proof_topic_version (topic_id, version, status, document, signature) \ + VALUES ($1, 1, 'draft', '{}'::jsonb, 'sig')", + ) + .bind(contested) + .execute(&publisher_pool) + .await + }); + + // 1. It must block while A is open. + let blocked = tokio::time::timeout(std::time::Duration::from_millis(750), &mut publisher).await; + assert!( + blocked.is_err(), + "the publish did not block on the slug claim, so an alias and a topic can both \ + claim {contested}: {blocked:?}" + ); + + // A commits: the alias claim is now visible and the lock is released. + holder.commit().await.expect("commit the alias claim"); + + // 2. The waiting publish must be **refused**, not admitted. + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), &mut publisher) + .await + .expect("the blocked publish must finish once the claim commits") + .expect("the publisher task must not panic"); + let err = outcome.expect_err( + "the publish was admitted after the alias claim committed, so both claimed the slug", + ); + assert!( + err.to_string().contains("already claimed as an alias"), + "the publish must be refused by the collision check, got: {err}" + ); + + // And the invariant, read back: exactly one claim exists, and the slug + // never resolves through an alias to a different topic's document. + assert!( + store.latest_topic(contested).await.unwrap().is_none(), + "the refused publish must not have written a topic row" + ); + assert_eq!( + store.resolve_alias(contested).await.unwrap().as_deref(), + Some(doc.id.as_str()), + "the alias claim is the one that won" + ); + + tp.drop_schema().await.expect("drop"); +} + #[tokio::test] async fn postgres_store_honours_the_contract_when_a_database_is_present() { if std::env::var_os("DATABASE_URL").is_none() { diff --git a/crates/proof-topic-bundle/Cargo.toml b/crates/proof-topic-bundle/Cargo.toml new file mode 100644 index 000000000..d586c5649 --- /dev/null +++ b/crates/proof-topic-bundle/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "proof-topic-bundle" +description = "Proof topic install bundle: the operator procedure that publishes one signed topic (schema, cross-checks against the signed document, canonical digest, install plan)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +hex = "0.4" +proof-canon = { path = "../proof-canon" } +proof-experiment = { path = "../proof-experiment" } +proof-task = { path = "../proof-task" } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } +sha2 = "0.10" +thiserror = "2" + +[lints] +workspace = true diff --git a/crates/proof-topic-bundle/src/lib.rs b/crates/proof-topic-bundle/src/lib.rs new file mode 100644 index 000000000..e91498326 --- /dev/null +++ b/crates/proof-topic-bundle/src/lib.rs @@ -0,0 +1,1524 @@ +//! Proof **topic install bundle**: the operator procedure that publishes one +//! signed topic. +//! +//! This crate deliberately does **not** define a second topic registry. A +//! Proof topic already has one home: the operator-signed [`TopicDocument`] +//! (`proof-task`), published through `POST /v1/admin/proof/topics` and +//! persisted in `proof_topic_version` (migration `0020`). The bindings a topic +//! needs are already signed topic data too — `constraints.params` carries the +//! in-guest runner and its pinned pack digest (`proof-experiment`), and the +//! image pins are operator env. +//! +//! What was missing is the *procedure*: which signed document, which install +//! target, which host env must agree with it, and what the topic's RLM is +//! asked to install. That is this bundle. It **references** the document, +//! **cross-checks** the host expectations against it, and **carries** the +//! RLM-owned section verbatim; it never restates a binding in a second place +//! that could drift. +//! +//! # The RLM owns topic behavior; this crate does not +//! +//! Topics are **RLM-based and autonomous**. The admin CLI's job is to hand +//! control to the topic's RLM — it asks the RLM to install and set itself up. +//! Everything that makes a topic *that* topic belongs to the bundle's +//! [`RlmSection`]: its anti-cheat **rules**, the **SQL migrations** it needs, +//! the **APIs** it exposes, its **submission format**, and its **scoring**. +//! +//! Rust never interprets any of it. This crate checks the section's *shape* +//! (an object, bounded) and carries it byte-for-byte; it does not know what a +//! rule, a migration, an API, or a scoring function *means*. That is the +//! whole point: no `if topic == …` branch, no compiled-in rule list, no +//! metric or submit format baked into challenge, gateway, or orchestrator +//! code. A topic's behavior travels in its signed document and its RLM +//! section, never in this binary. +//! +//! Consequence for tests and fixtures: the seed slug `tb4` and its temporary +//! alias `tbench` are **strings** that appear in test fixtures and operator +//! examples. They are never a condition in logic. +//! +//! Three rules carry the fail-closed posture: +//! +//! - **Unknown keys are refused.** A field this build does not understand is a +//! step nothing performs, so `deny_unknown_fields` rejects it at parse. +//! - **A digest is never invented.** Every expected pin is +//! `sha256:<64 lowercase hex>`, checked against the same shape the host env +//! uses. +//! - **The document wins.** A host expectation that disagrees with the signed +//! document is a reject, not a silent override: the signature is what the +//! scoring path trusts, so an operator env that says otherwise would mean +//! the topic runs something other than what was signed. + +#![forbid(unsafe_code)] +#![allow( + clippy::missing_errors_doc, + clippy::module_name_repetitions, + clippy::must_use_candidate, + clippy::doc_markdown +)] + +use std::fmt; +use std::str::FromStr; + +use proof_experiment::{ExperimentBinding, ExperimentError}; +use proof_task::{MetricFamily, TopicDocument, TopicError, TopicStatus}; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; + +/// Only accepted `schema_version`. +pub const BUNDLE_SCHEMA_VERSION: u32 = 1; + +/// Longest legal `display_name`. +pub const MAX_DISPLAY_NAME_LEN: usize = 128; + +/// Every key the bundle schema accepts, sorted. The schema is this Rust type +/// (`deny_unknown_fields`), not a second document that could drift from it: +/// this list is what a test pins, so adding or removing a key is a deliberate +/// edit here rather than a silent widening of what an operator may write. +pub const BUNDLE_KEYS: [&str; 6] = [ + "display_name", + "environment", + "host", + "rlm", + "schema_version", + "topic", +]; + +/// Keys with no `serde` default: a bundle that omits one is a parse error +/// naming the field, never an empty value that fails later. +pub const REQUIRED_BUNDLE_KEYS: [&str; 5] = [ + "display_name", + "environment", + "host", + "schema_version", + "topic", +]; + +/// Keys of the `host` block, sorted. +pub const HOST_KEYS: [&str; 5] = [ + "custom_ids_entry", + "experiment_image_digest", + "pack_digest", + "pack_dir", + "rlm_image_digest", +]; + +/// Install targets, in the order the CLI offers them. +pub const INSTALL_ENVIRONMENTS: [&str; 2] = ["staging", "metal"]; + +/// Prefix of every digest. +pub const DIGEST_PREFIX: &str = "sha256:"; + +/// The existing admin publish route this bundle prepares a call for. +/// +/// Not a new route: `proof-http` already serves it, and the CLI's `validate` +/// runs the same acceptance checks that route runs before it writes. +pub const PUBLISH_ROUTE: &str = "POST /v1/admin/proof/topics"; + +/// The RLM jobs an install drives, in order, for operator output. +/// +/// These are the **existing** RLM lifecycle steps (`proof-rlm-scorer` +/// `TopicSetup`): the RLM is asked to provision, write its rules, and seal a +/// baseline. Naming them here is documentation for the operator; the CLI does +/// not run them, and none of them is topic-specific. +pub const RLM_INSTALL_JOBS: [&str; 3] = ["provision", "propose_rules", "baseline"]; + +/// The publish path as it appears in a printed `curl` line. +pub const PUBLISH_PATH: &str = "/challenge/proof/v1/admin/proof/topics"; + +/// Operator env naming the custom ids the host will score. +pub const ENV_CUSTOM_IDS: &str = "PROOF_VM_RUNNER_CUSTOM_IDS"; + +/// Operator env pinning the RLM VM image. +pub const ENV_RLM_IMAGE: &str = "PROOF_RLM_VM_IMAGE_DIGEST"; + +/// Operator env pinning the experiment guest image. +pub const ENV_EXPERIMENT_IMAGE: &str = "PROOF_EXPERIMENT_VM_IMAGE_DIGEST"; + +/// Operator env holding the directory of staged experiment packs. +pub const ENV_PACK_DIR: &str = "PROOF_VM_AGENT_EXPERIMENT_PACK_DIR"; + +/// Where a topic may be installed. +/// +/// The install target is operator state, not topic data: the same bundle is +/// installed to staging first and to metal later, and `bins/proof-admin` +/// refuses when the flag and the bundle disagree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallEnvironment { + /// Staging host. Nothing here scores live. + Staging, + /// Live metal. + Metal, +} + +impl InstallEnvironment { + /// Wire word (`staging` / `metal`). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Staging => "staging", + Self::Metal => "metal", + } + } +} + +impl fmt::Display for InstallEnvironment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for InstallEnvironment { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "staging" => Ok(Self::Staging), + "metal" => Ok(Self::Metal), + other => Err(format!( + "{other:?} is not an install target ({})", + INSTALL_ENVIRONMENTS.join(" | ") + )), + } + } +} + +/// Why a bundle was refused. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum BundleError { + /// The body was not JSON, or carried a key this build does not know. + #[error("parse topic install bundle: {0}")] + Parse(String), + /// Schema version drift. + #[error("schema_version {got}, this build reads {want}")] + WrongSchema { + /// What the bundle said. + got: u32, + /// What this build reads. + want: u32, + }, + /// `display_name` is empty or oversized. + #[error("display_name must be 1..={MAX_DISPLAY_NAME_LEN} chars")] + BadDisplayName, + /// A host expectation is not `sha256:<64 lowercase hex>`. + #[error("host.{field} {got:?} is not {DIGEST_PREFIX}<64 lowercase hex>")] + BadDigest { + /// Which expectation (`rlm_image_digest`, `experiment_image_digest`, `pack_digest`). + field: &'static str, + /// What the bundle said. + got: String, + }, + /// The document selects an in-guest runner but nothing pins its pack. + #[error( + "the signed document selects in-guest runner {runner_id:?}, so host.pack_digest is \ + required and must equal the document's constraints.params.experiment_pack_digest \ + (sha256:<64 hex>; never invented)" + )] + RunnerWithoutPack { + /// The runner the document selected. + runner_id: String, + }, + /// A pack is pinned that nothing runs. + #[error( + "host.pack_digest is set but the signed document selects no in-guest runner: a pack no \ + runner reads is dead weight, and this bundle would stage it anyway" + )] + PackWithoutRunner, + /// A host expectation disagrees with the signed document. + #[error("host.{field} {got:?} contradicts the signed document, which says {document:?}")] + HostContradictsDocument { + /// Which expectation. + field: &'static str, + /// What the bundle said. + got: String, + /// What the document says. + document: String, + }, + /// `custom_ids_entry` names no custom id while the document is custom. + #[error( + "{ENV_CUSTOM_IDS} does not register this topic's metric.custom_id {custom_id:?}; an \ + open custom topic whose id is not registered cannot score (503)" + )] + CustomIdNotRegistered { + /// The topic's `metric.custom_id`. + custom_id: String, + }, + /// `pack_dir` is not an absolute path with no traversal. + #[error("host.pack_dir {0:?} must be an absolute path with no `..` segment")] + BadPackDir(String), + /// The document itself was refused by the shared topic checks. + #[error("topic document: {0}")] + Topic(#[from] TopicError), + /// The document's `constraints.params` are not a usable runner binding. + #[error("topic binding: {0}")] + Binding(#[from] ExperimentError), + /// An RLM section field is not an object or array. + #[error("rlm.{field} must be a JSON object or array, got {got}")] + RlmNotObject { + /// Which field. + field: String, + /// What it was. + got: &'static str, + }, + /// An RLM part was written as an explicit `null`. + #[error( + "rlm.{field} is an explicit null; a part the operator wrote is never silently dropped \ + — remove the key instead" + )] + RlmExplicitNull { + /// Which part. + field: String, + }, + /// The RLM section is larger than the bound. + #[error("rlm section is {0} bytes of canonical JSON, at most {MAX_RLM_BYTES} are allowed")] + RlmTooLarge(usize), + /// The canonical form could not be built. + #[error("canonicalize bundle: {0}")] + Canonicalize(String), + /// The `--env` flag and the bundle's `environment` disagree. + #[error("bundle declares environment {bundle}, but --env {requested} was requested")] + EnvironmentMismatch { + /// What the bundle declared. + bundle: InstallEnvironment, + /// What the operator asked for. + requested: InstallEnvironment, + }, +} + +/// The operator env that must agree with the signed document. +/// +/// Every field is optional: an operator writes only what the topic needs. What +/// is present is checked against the document, never used to override it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct HostExpectations { + /// `PROOF_RLM_VM_IMAGE_DIGEST` the host must pin. + #[serde(skip_serializing_if = "Option::is_none")] + pub rlm_image_digest: Option, + /// Experiment guest image the host must pin. + #[serde(skip_serializing_if = "Option::is_none")] + pub experiment_image_digest: Option, + /// The experiment pack the KVM host must hold, staged under + /// `PROOF_VM_AGENT_EXPERIMENT_PACK_DIR`. Must equal the document's + /// `constraints.params.experiment_pack_digest` when the document selects + /// an in-guest runner. + #[serde(skip_serializing_if = "Option::is_none")] + pub pack_digest: Option, + /// The **directory** the pack tar is staged in on the KVM host, i.e. the + /// value of `PROOF_VM_AGENT_EXPERIMENT_PACK_DIR`. + /// + /// A path, never a digest: the variable names a directory, and the host + /// re-hashes the tar it finds there against the document's pin. + #[serde(skip_serializing_if = "Option::is_none")] + pub pack_dir: Option, + /// Comma-separated value the host will set as `PROOF_VM_RUNNER_CUSTOM_IDS`. + /// An open custom topic scores only when this registers its + /// `metric.custom_id`. + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_ids_entry: Option, +} + +/// What the topic's **RLM** is asked to install — opaque to Rust. +/// +/// Topics are RLM-based and autonomous. Everything topic-specific lives here, +/// owned by the bundle and consumed by the RLM inside its VM: the anti-cheat +/// **rules**, the **SQL migrations** the topic needs, the **APIs** it exposes, +/// its **submission format**, and its **scoring**. +/// +/// The whole object is held as **raw JSON text** ([`RawValue`]), not a parsed +/// value. That is deliberate and load-bearing: parsing and re-serializing +/// reorders keys, collapses duplicate keys, and normalises whitespace, so the +/// bytes handed to the RLM would not be the bytes the operator wrote. This +/// crate carries the text it was given — including the enclosing object's own +/// key order and any duplicate keys inside it. +/// +/// [`Self::validate_shape`] *parses a copy* to bound and shape-check the +/// section (an object; each known part an object or array; not an explicit +/// `null`). Checking is not transforming: the hand-off is always +/// [`Self::raw`], the original bytes. Nothing here is validated semantically, +/// and nothing here may become a branch in challenge, gateway, or +/// orchestrator code — a part Rust has never heard of goes in the section +/// rather than requiring a code change. +#[derive(Debug, Clone)] +pub struct RlmSection { + /// The section verbatim: the authoritative hand-off bytes. + raw: Box, +} + +/// The parts a section may name, for the shape check and for error naming. +/// +/// These are the parts the bundle owns per the architecture. Naming them here +/// makes the shape reviewable; it does **not** make this crate understand +/// them, and an unrecognised key is not this crate's business to reject on +/// semantic grounds — see [`RlmSection::validate_shape`]. +pub const RLM_KEYS: [&str; 5] = [ + "apis", + "migrations", + "rules", + "scoring", + "submission_format", +]; + +/// Largest RLM section, in bytes. +/// +/// A bound, not a schema: it stops a bundle from smuggling an unbounded blob +/// through the install path, and it says nothing about what the content is. +pub const MAX_RLM_BYTES: usize = 256 * 1024; + +/// The empty section, which is what a bundle with no `rlm` key carries. +const EMPTY_RLM: &str = "{}"; + +impl Default for RlmSection { + /// The empty section (`{}`). + /// + /// `EMPTY_RLM` is a valid JSON object by construction, so the parse cannot + /// fail. The workspace denies `expect` outside tests, and the alternative + /// (a fallback that also parses) would be noise around an unreachable arm + /// — so this documents the invariant instead of hiding it. + #[allow(clippy::expect_used)] + fn default() -> Self { + Self { + raw: RawValue::from_string(EMPTY_RLM.to_owned()).expect("`{}` is valid JSON"), + } + } +} + +impl PartialEq for RlmSection { + fn eq(&self, other: &Self) -> bool { + self.raw() == other.raw() + } +} + +impl Eq for RlmSection {} + +impl Serialize for RlmSection { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // `RawValue` writes its bytes through untouched. + self.raw.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RlmSection { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Capture the whole value as text; `validate_shape` checks it later, + // so a parse error never has to be reconstructed from a typed value. + Ok(Self { + raw: Box::::deserialize(deserializer)?, + }) + } +} + +impl RlmSection { + /// The section verbatim — the bytes handed to the RLM. + #[must_use] + pub fn raw(&self) -> &str { + self.raw.get() + } + + /// Build from raw text, the way a bundle file supplies it. + /// + /// # Errors + /// + /// [`BundleError::Canonicalize`] when the text is not valid JSON. + pub fn from_raw(text: &str) -> Result { + Ok(Self { + raw: RawValue::from_string(text.to_owned()) + .map_err(|e| BundleError::Canonicalize(e.to_string()))?, + }) + } + + /// Whether the section names nothing at all (`{}`). + /// + /// A malformed section reads as empty here; [`Self::validate_shape`] is + /// what refuses it, so this can only ever *withhold* a hand-off. + #[must_use] + pub fn is_empty(&self) -> bool { + serde_json::from_str::>(self.raw()) + .is_ok_and(|m| m.is_empty()) + } + + /// Shape only: a bounded JSON object whose named parts are objects or + /// arrays, and no part is an explicit `null`. + /// + /// Deliberately says nothing about the *content* — a rule list this crate + /// does not recognise is not an error, because recognising it would mean + /// this crate knows the topic. The parse here is a **check**; the hand-off + /// stays [`Self::raw`]. + fn validate_shape(&self) -> Result<(), BundleError> { + let text = self.raw(); + if text.len() > MAX_RLM_BYTES { + return Err(BundleError::RlmTooLarge(text.len())); + } + let trimmed = text.trim(); + if !trimmed.starts_with('{') { + return Err(BundleError::RlmNotObject { + field: "rlm".to_owned(), + got: raw_kind(trimmed), + }); + } + let parsed: serde_json::Map = + serde_json::from_str(text).map_err(|e| BundleError::Parse(e.to_string()))?; + for (key, value) in &parsed { + // An explicit null is refused rather than dropped: the operator + // wrote it, so silently discarding it would change the install. + if value.is_null() { + return Err(BundleError::RlmExplicitNull { field: key.clone() }); + } + // Only the parts this crate names are shape-checked; a part it has + // never heard of is the RLM's business, not an error. + if RLM_KEYS.contains(&key.as_str()) && !value.is_object() && !value.is_array() { + return Err(BundleError::RlmNotObject { + field: key.clone(), + got: raw_kind(&value.to_string()), + }); + } + } + Ok(()) + } +} + +/// One topic install bundle. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TopicInstallBundle { + /// Must equal [`BUNDLE_SCHEMA_VERSION`]. + pub schema_version: u32, + /// Install target this bundle was written for. + pub environment: InstallEnvironment, + /// Human label for operator output. Not topic data: the scoring contract + /// is the signed document below. + pub display_name: String, + /// The signed topic document, verbatim. + pub topic: TopicDocument, + /// Operator env this install needs. + pub host: HostExpectations, + /// What the topic's RLM installs. Opaque to Rust: see [`RlmSection`]. + #[serde(default)] + pub rlm: RlmSection, +} + +/// One operator env line the SOP asks for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostEnvVar { + /// Variable name. + pub name: String, + /// Value the host must set. + pub value: String, + /// Why, in operator English. + pub why: String, +} + +/// The resolved install: what a `--dry-run` prints and what an operator runs. +/// +/// Everything here is derived from the signed document or from the bundle's +/// own target. Nothing is stored: the registry remains `proof_topic_version`, +/// and this plan is a procedure, not a row. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TopicInstallPlan { + /// Topic slug (the document's `id`). + pub topic_id: String, + /// Human label. + pub display_name: String, + /// Install target. + pub environment: InstallEnvironment, + /// Lifecycle the signed document declares. + pub document_status: TopicStatus, + /// Metric family the document declares. + pub metric_family: MetricFamily, + /// `metric.custom_id` (empty on non-custom families). + pub custom_id: String, + /// In-guest runner the document selects, when it selects one. + pub runner_id: Option, + /// Experiment pack digest the document pins, when it pins one. + pub pack_digest: Option, + /// The existing admin route that publishes this document. + pub publish_route: String, + /// What the CLI hands the RLM: the install section, verbatim. + /// + /// Present only when the bundle carries one. This is the hand-off — the + /// admin CLI asks the RLM to install and set the topic up; it does not + /// interpret, rewrite, or partially apply any of it. + #[serde(skip_serializing_if = "Option::is_none")] + pub rlm_install: Option, + /// The RLM job kinds this install is expected to drive, for operator + /// output only. Derived from the existing RLM lifecycle, not from the + /// bundle's contents: this crate still reads none of it. + pub rlm_jobs: Vec, + /// Operator env lines this install needs, in the order to set them. + pub host_env: Vec, + /// Where the pack is staged on the KVM host, when a pack is pinned. + pub pack_dir_env: Option, + /// `sha256:` over the canonical bundle. + pub bundle_digest: String, +} + +/// Name a raw JSON part's kind, for an error that says what arrived. +fn raw_kind(text: &str) -> &'static str { + let t = text.trim(); + match t.chars().next() { + Some('"') => "a string", + Some('{') => "an object", + Some('[') => "an array", + Some('t' | 'f') => "a boolean", + Some('n') => "null", + _ => "a number", + } +} + +fn is_digest(s: &str) -> bool { + s.strip_prefix(DIGEST_PREFIX).is_some_and(is_lower_hex64) +} + +/// Exactly 64 **lowercase** hex characters, with no surrounding whitespace. +/// +/// Deliberately stricter than `proof_canon::is_hex64`, which trims and accepts +/// uppercase: the host env this mirrors is compared verbatim, so accepting +/// `sha256:AB…` or `sha256: ab… ` would let a bundle validate and then +/// disagree with the pin actually staged. One spelling of a digest. +fn is_lower_hex64(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Split a `PROOF_VM_RUNNER_CUSTOM_IDS` value into ids. +/// +/// Comma- or whitespace-separated, trimmed, empties dropped — the same shape +/// `proof-challenge` parses from that variable. +#[must_use] +pub fn parse_custom_ids(raw: &str) -> Vec { + raw.split([',', ' ', '\t', '\n']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect() +} + +impl TopicInstallBundle { + /// Parse a bundle body (JSON only). + /// + /// Unknown keys are refused here: a step this build cannot name is a step + /// nothing performs. + pub fn from_json(body: &str) -> Result { + serde_json::from_str(body).map_err(|e| BundleError::Parse(e.to_string())) + } + + /// Shape checks that need no pin: schema, label, digest spellings, and the + /// cross-checks between the host block and the signed document. + /// + /// The document's own floors, signature, and seal are **not** checked + /// here — that is [`Self::accept`], which runs the same acceptance the + /// admin publish route runs. + pub fn validate_shape(&self) -> Result<(), BundleError> { + if self.schema_version != BUNDLE_SCHEMA_VERSION { + return Err(BundleError::WrongSchema { + got: self.schema_version, + want: BUNDLE_SCHEMA_VERSION, + }); + } + let name = self.display_name.trim(); + if name.is_empty() || name.chars().count() > MAX_DISPLAY_NAME_LEN { + return Err(BundleError::BadDisplayName); + } + for (field, value) in [ + ("rlm_image_digest", self.host.rlm_image_digest.as_deref()), + ( + "experiment_image_digest", + self.host.experiment_image_digest.as_deref(), + ), + ("pack_digest", self.host.pack_digest.as_deref()), + ] { + if let Some(v) = value { + if !is_digest(v) { + return Err(BundleError::BadDigest { + field, + got: v.to_owned(), + }); + } + } + } + self.check_pack_dir()?; + // Shape only. The RLM section's *content* is the topic's business: + // this crate carries it, never interprets it. + self.rlm.validate_shape()?; + self.cross_check_host() + } + + /// Whether `pack_dir` is a usable directory value. + fn check_pack_dir(&self) -> Result<(), BundleError> { + let Some(dir) = self.host.pack_dir.as_deref() else { + return Ok(()); + }; + let d = dir.trim(); + let ok = d.starts_with('/') + && d.len() <= 512 + && !d.chars().any(char::is_control) + && !d.split('/').any(|seg| seg == ".."); + if ok { + Ok(()) + } else { + Err(BundleError::BadPackDir(dir.to_owned())) + } + } + + /// The in-guest runner binding the **signed document** carries, if any. + pub fn binding(&self) -> Result, BundleError> { + Ok(ExperimentBinding::from_params( + &self.topic.constraints.params, + )?) + } + + /// Cross-check the host block against the signed document. + /// + /// The document is authoritative. A disagreement is a reject: the + /// signature is what the scoring path trusts, so an operator env that says + /// otherwise would run something other than what was signed. + fn cross_check_host(&self) -> Result<(), BundleError> { + let binding = self.binding()?; + match (&binding, self.host.pack_digest.as_deref()) { + (Some(b), Some(host_digest)) => { + if host_digest != b.pack.digest { + return Err(BundleError::HostContradictsDocument { + field: "pack_digest", + got: host_digest.to_owned(), + document: b.pack.digest.clone(), + }); + } + } + // A runner with nothing to run cannot score, so the pack travels + // with it — the same rule the document's own params enforce. + (Some(b), None) => { + return Err(BundleError::RunnerWithoutPack { + runner_id: b.runner.clone(), + }); + } + (None, Some(_)) => return Err(BundleError::PackWithoutRunner), + (None, None) => {} + } + // A custom topic is scored by the runner registered under its + // `metric.custom_id`; an open one whose id is not registered answers + // 503. When the bundle declares the host's id list, it has to contain + // that id. + if self.topic.metric.family == MetricFamily::Custom { + let id = self.topic.metric.custom_id.trim(); + if let Some(raw) = self.host.custom_ids_entry.as_deref() { + if !parse_custom_ids(raw).iter().any(|e| e == id) { + return Err(BundleError::CustomIdNotRegistered { + custom_id: id.to_owned(), + }); + } + } + } + Ok(()) + } + + /// The custom ids this bundle's host block registers, for the shared + /// acceptance check (an `open` custom topic needs its id registered). + #[must_use] + pub fn registered_custom(&self) -> Vec { + self.host + .custom_ids_entry + .as_deref() + .map_or_else(Vec::new, parse_custom_ids) + } + + /// Canonical JSON of the bundle: sorted keys, no insignificant + /// whitespace. This is what [`Self::digest`] hashes, so two files that + /// differ only in formatting install as the same bundle. + pub fn canonical(&self) -> Result { + let value = + serde_json::to_value(self).map_err(|e| BundleError::Canonicalize(e.to_string()))?; + Ok(proof_canon::canonical_json(&value)) + } + + /// `sha256:<64 hex>` over [`Self::canonical`]. + pub fn digest(&self) -> Result { + use sha2::{Digest, Sha256}; + let canonical = self.canonical()?; + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + Ok(format!("{DIGEST_PREFIX}{}", hex::encode(hasher.finalize()))) + } + + /// Resolve the install plan for `requested`, refusing a bundle whose + /// declared `environment` is not the target being installed to. + pub fn plan(&self, requested: InstallEnvironment) -> Result { + self.validate_shape()?; + if self.environment != requested { + return Err(BundleError::EnvironmentMismatch { + bundle: self.environment, + requested, + }); + } + let binding = self.binding()?; + let custom_id = self.topic.metric.custom_id.trim().to_owned(); + let mut host_env = Vec::new(); + if let Some(digest) = self.host.rlm_image_digest.as_deref() { + host_env.push(HostEnvVar { + name: ENV_RLM_IMAGE.to_owned(), + value: digest.to_owned(), + why: "RLM VM image the orchestrator boots for this topic".to_owned(), + }); + } + if let Some(digest) = self.host.experiment_image_digest.as_deref() { + host_env.push(HostEnvVar { + name: ENV_EXPERIMENT_IMAGE.to_owned(), + value: digest.to_owned(), + why: "guest image for this topic's in-guest runner jobs".to_owned(), + }); + } + if let Some(raw) = self.host.custom_ids_entry.as_deref() { + host_env.push(HostEnvVar { + name: ENV_CUSTOM_IDS.to_owned(), + value: raw.trim().to_owned(), + why: format!( + "registers {custom_id:?} so this host can score it (empty registry = 503)" + ), + }); + } + // `PROOF_VM_AGENT_EXPERIMENT_PACK_DIR` names a **directory**, so the + // value comes from `host.pack_dir`, never from the pack digest. The + // digest is what the host re-hashes the staged tar against, and it + // travels in the `why` so the operator can check both. + if let (Some(dir), Some(digest)) = ( + self.host.pack_dir.as_deref(), + self.host.pack_digest.as_deref(), + ) { + host_env.push(HostEnvVar { + name: ENV_PACK_DIR.to_owned(), + value: dir.trim().to_owned(), + why: format!( + "stage the pack tar here; the host re-hashes it and refuses a mismatch \ + against {digest}" + ), + }); + } + Ok(TopicInstallPlan { + topic_id: self.topic.id.clone(), + display_name: self.display_name.trim().to_owned(), + environment: self.environment, + document_status: self.topic.status, + metric_family: self.topic.metric.family, + custom_id, + runner_id: binding.as_ref().map(|b| b.runner.clone()), + pack_digest: binding.as_ref().map(|b| b.pack.digest.clone()), + publish_route: PUBLISH_ROUTE.to_owned(), + rlm_install: (!self.rlm.is_empty()).then(|| self.rlm.clone()), + rlm_jobs: RLM_INSTALL_JOBS.iter().map(|s| (*s).to_owned()).collect(), + host_env, + pack_dir_env: binding.as_ref().map(|_| ENV_PACK_DIR.to_owned()), + bundle_digest: self.digest()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proof_task::{ + default_adamw, holdout_commitment, synthetic_holdout, MetricSpec, PayoutMode, STRATUM_SIZE, + }; + + const HEX: &str = "abababababababababababababababababababababababababababababababab"; + + fn digest() -> String { + format!("{DIGEST_PREFIX}{HEX}") + } + + /// A custom topic that selects an in-guest runner, the shape the live + /// `tb4` topic has. + fn custom_topic(custom_id: &str) -> TopicDocument { + let mut doc = TopicDocument { + id: "tb4".into(), + statement: "Score the pinned task pack with the pinned runner.".into(), + payout_mode: PayoutMode::Discovery, + metric: MetricSpec { + family: MetricFamily::Custom, + primary: "primary_value".into(), + custom_id: custom_id.into(), + epsilon_rel: 0.05, + ..MetricSpec::default() + }, + baseline: default_adamw(proof_task::FLOPS_BUDGET_MAX), + holdout_commitment: holdout_commitment(&synthetic_holdout(STRATUM_SIZE, 1)), + ..TopicDocument::default() + }; + doc.constraints.params.insert( + proof_experiment::PARAM_RUNNER.into(), + "rlm_fc_in_guest_harbor".into(), + ); + doc.constraints + .params + .insert(proof_experiment::PARAM_PACK_DIGEST.into(), digest()); + doc + } + + fn tb4() -> TopicInstallBundle { + TopicInstallBundle { + schema_version: BUNDLE_SCHEMA_VERSION, + environment: InstallEnvironment::Metal, + display_name: "Terminal-Bench 4".into(), + topic: custom_topic("tbench"), + host: HostExpectations { + rlm_image_digest: Some(digest()), + experiment_image_digest: Some(digest()), + pack_digest: Some(digest()), + pack_dir: Some("/var/lib/proof/packs".into()), + custom_ids_entry: Some("tbench".into()), + }, + rlm: RlmSection::default(), + } + } + + /// A raw section from text, the way a bundle file supplies it. + fn rlm(text: &str) -> RlmSection { + RlmSection::from_raw(text).expect("raw json") + } + + /// A section carrying all five RLM-owned parts. + fn rlm_section() -> RlmSection { + rlm( + r#"{"rules": [{"id": "no_short_circuit", "text": "run the task"}], + "migrations": [{"name": "0001_scratch", "sql": "CREATE TABLE s (id TEXT)"}], + "apis": [{"path": "/v1/topic/status", "method": "GET"}], + "submission_format": {"kind": "tar", "max_bytes": 5242880}, + "scoring": {"primary": "success_rate", "epsilon_rel": 0.05}}"#, + ) + } + + #[test] + fn the_arch_default_bundle_plans_against_the_existing_admin_route() { + let bundle = tb4(); + bundle.validate_shape().expect("validates"); + let plan = bundle.plan(InstallEnvironment::Metal).expect("plan"); + assert_eq!(plan.topic_id, "tb4"); + assert_eq!(plan.environment, InstallEnvironment::Metal); + assert_eq!(plan.custom_id, "tbench"); + assert_eq!(plan.runner_id.as_deref(), Some("rlm_fc_in_guest_harbor")); + assert_eq!(plan.pack_digest.as_deref(), Some(digest().as_str())); + assert_eq!( + plan.publish_route, PUBLISH_ROUTE, + "the plan names the existing route, not a new one" + ); + assert_eq!(plan.pack_dir_env.as_deref(), Some(ENV_PACK_DIR)); + let names: Vec<&str> = plan.host_env.iter().map(|e| e.name.as_str()).collect(); + assert_eq!( + names, + [ + ENV_RLM_IMAGE, + ENV_EXPERIMENT_IMAGE, + ENV_CUSTOM_IDS, + ENV_PACK_DIR + ] + ); + assert!(plan.bundle_digest.starts_with(DIGEST_PREFIX)); + assert_eq!(plan.bundle_digest.len(), DIGEST_PREFIX.len() + 64); + } + + /// The bundle carries the signed document verbatim, so there is exactly + /// one copy of every binding in the system. + #[test] + fn the_document_is_carried_verbatim_and_is_the_only_source_of_truth() { + let mut bundle = tb4(); + bundle.topic.signature = "cd".repeat(64); + let value = serde_json::to_value(&bundle).expect("json"); + assert_eq!( + value["topic"]["constraints"]["params"][proof_experiment::PARAM_RUNNER], + "rlm_fc_in_guest_harbor" + ); + assert_eq!( + value["topic"]["metric"]["custom_id"], "tbench", + "the custom id is the document's, not a bundle field" + ); + assert_eq!( + value["topic"]["signature"], + "cd".repeat(64), + "the signature travels with the document" + ); + // The bundle itself has no place to restate a binding. + let keys: Vec<&String> = value.as_object().expect("object").keys().collect(); + for forbidden in [ + "runner_id", + "custom_id", + "pin_rlm", + "n_concurrent", + "sealed_custom_value", + ] { + assert!( + !keys.iter().any(|k| k.as_str() == forbidden), + "the bundle must not duplicate topic data: {forbidden}" + ); + } + } + + /// The RLM section is **opaque**: this crate carries it and never interprets + /// it. The test proves the carry is byte-exact and that content this crate has + /// never heard of is not an error — recognising it would mean this crate + /// knows the topic, which is exactly the hardcoding the boundary prevents. + #[test] + fn the_rlm_section_is_carried_verbatim_and_never_interpreted() { + let mut bundle = tb4(); + bundle.rlm = rlm_section(); + bundle + .validate_shape() + .expect("an opaque section validates"); + let plan = bundle.plan(InstallEnvironment::Metal).expect("plan"); + + let carried = plan + .rlm_install + .as_ref() + .expect("the section is handed over"); + assert_eq!(carried, &bundle.rlm, "handed over unchanged"); + assert_eq!( + carried.raw(), + bundle.rlm.raw(), + "the hand-off is the original text" + ); + + // Content Rust has never seen is still not an error. + let mut exotic = tb4(); + exotic.rlm = + rlm(r#"{"some_future_metric_this_build_has_never_heard_of": {"weight": 0.7}}"#); + exotic + .validate_shape() + .expect("unknown content is not a validation error"); + + assert_eq!(plan.rlm_jobs, ["provision", "propose_rules", "baseline"]); + + let empty = tb4(); + empty.validate_shape().expect("an absent section is fine"); + assert!( + empty + .plan(InstallEnvironment::Metal) + .expect("plan") + .rlm_install + .is_none(), + "a bundle with no RLM section hands over nothing" + ); + } + + /// The hand-off must preserve the **bytes** the operator wrote — including the + /// enclosing object's own key order and duplicate keys inside it. + /// + /// Parsing the section into a `Value` and re-serializing would reorder its + /// keys, collapse duplicates, and normalise whitespace, so the RLM would + /// receive something other than what was signed off. This is checked at the + /// object level, not only inside a named part: an earlier revision preserved + /// the parts but rebuilt the object around them. + #[test] + fn the_hand_off_preserves_key_order_duplicates_and_whitespace() { + // Object keys deliberately NOT in `RLM_KEYS` order, plus a duplicate key + // and significant inner whitespace. + let awkward = concat!( + r#"{"scoring": {"b": 1, "a": 2, "a": 3, "sp": "x y"}, "#, + r#""rules": [{"id": "r", "text": "t"}], "apis": []}"# + ); + let fixture = serde_json::to_string(&tb4()).expect("fixture json"); + // Splice the section in as **text**, so the test does not itself round-trip + // it through a `Value` (which is the lossy path under test). + let body = fixture.replacen("\"rlm\":{}", &format!("\"rlm\":{awkward}"), 1); + assert!(body.contains(awkward), "the splice must have landed"); + let bundle = TopicInstallBundle::from_json(&body).expect("parse"); + bundle.validate_shape().expect("validates"); + assert_eq!( + bundle.rlm.raw(), + awkward, + "the exact bytes must survive parsing, object order included" + ); + + // And through a serialize/parse round trip, as the plan's JSON output does. + let plan = bundle.plan(InstallEnvironment::Metal).expect("plan"); + let plan_json = serde_json::to_string(&plan).expect("plan json"); + let reparsed: TopicInstallPlan = serde_json::from_str(&plan_json).expect("reparse plan"); + assert_eq!( + reparsed.rlm_install.as_ref().expect("carried").raw(), + awkward, + "the exact bytes must survive the plan's own JSON" + ); + } + + /// An explicit `null` is refused, not silently dropped. + /// + /// Folding `"rules": null` into "absent" would mean the operator signed off one + /// bundle and the RLM received another — or, worse, that the whole hand-off + /// vanished. The section keeps the text, and the shape check refuses it. + #[test] + fn an_explicit_null_rlm_part_is_refused_not_dropped() { + let fixture = serde_json::to_string(&tb4()).expect("fixture json"); + let body = fixture.replacen("\"rlm\":{}", "\"rlm\":{\"rules\":null}", 1); + let bundle = TopicInstallBundle::from_json(&body).expect("parse"); + assert_eq!(bundle.rlm.raw(), r#"{"rules":null}"#, "kept, not folded"); + let err = bundle + .validate_shape() + .expect_err("an explicit null is refused"); + assert!( + matches!(err, BundleError::RlmExplicitNull { ref field } if field == "rules"), + "{err:?}" + ); + assert!(err.to_string().contains("never silently dropped"), "{err}"); + } + + /// Only the *shape* of the section is checked, and only to keep it bounded. + #[test] + fn the_rlm_section_is_shape_checked_but_not_semantically_validated() { + // A named part must be an object or array. + for key in RLM_KEYS { + let mut bundle = tb4(); + bundle.rlm = rlm(&format!(r#"{{"{key}": "a bare string"}}"#)); + let err = bundle + .validate_shape() + .expect_err(&format!("{key} must be an object or array")); + assert!( + matches!(err, BundleError::RlmNotObject { ref field, .. } if field == key), + "{key}: {err:?}" + ); + } + + // The section itself must be an object. + let mut scalar = tb4(); + scalar.rlm = rlm("[1, 2, 3]"); + assert!(matches!( + scalar.validate_shape(), + Err(BundleError::RlmNotObject { .. }) + )); + + // An unrecognised key is NOT an error: it is the RLM's business. + let mut unknown = tb4(); + unknown.rlm = rlm(r#"{"a_part_this_build_has_never_heard_of": "opaque"}"#); + unknown + .validate_shape() + .expect("an unknown part is data, not an error"); + + // The bound is on the section's own size. + let mut huge = tb4(); + huge.rlm = rlm(&format!( + r#"{{"scoring": {{"pad": "{}"}}}}"#, + "x".repeat(MAX_RLM_BYTES) + )); + let err = huge.validate_shape().expect_err("oversized section"); + let BundleError::RlmTooLarge(reported) = err else { + panic!("expected RlmTooLarge, got {err:?}"); + }; + assert!(reported > MAX_RLM_BYTES, "{reported}"); + } + + /// The topic slug and its alias are **strings**, never conditions. + /// + /// This is the guard against the hardcoding the architecture forbids: the + /// seed ids may appear in fixtures and examples, but no logic may branch on + /// them, and this crate must not know any topic by name. The check is on the + /// crate's own non-test source, so a future edit that adds `if topic == "tb4"` + /// fails here. + #[test] + fn no_topic_literal_appears_in_this_crates_logic() { + const SOURCE: &str = include_str!("lib.rs"); + // The test module is where fixtures legitimately name the seed ids, and + // a doc comment may *explain* the rule — so the check runs on the + // non-test source with comment lines stripped. That is precisely "no + // topic literal in logic": a string in a `let`, `match`, or `if` is + // caught; prose about the boundary is not. + let strip = |s: &str| -> String { + s.lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n") + }; + let logic = strip( + SOURCE + .split("#[cfg(test)]") + .next() + .expect("non-test source"), + ); + assert!( + !logic.contains("tb4") && !logic.contains("tbench"), + "topic ids belong in fixtures and signed documents, never in logic" + ); + // The same guard for the parts a topic would otherwise be tempted to bake + // in: this crate must not name a metric, a task, or a benchmark. + for forbidden in ["terminal-bench", "harbor", "success_rate"] { + assert!( + !logic.to_lowercase().contains(forbidden), + "{forbidden} must not be compiled into this crate" + ); + } + // Guard the guard: a literal in real code must still be caught even + // though a comment beside it is filtered out. + assert!( + strip("let topic = \"tb4\"; // fixture").contains("tb4"), + "the comment filter must not hide a literal in code" + ); + // The RLM-owned key *names* are the one thing it may know, because they + // are the section's shape. + for key in RLM_KEYS { + assert!(logic.contains(key), "the section shape must name {key}"); + } + } + + #[test] + fn the_schema_key_lists_match_the_type() { + let bundle = tb4(); + let value = serde_json::to_value(&bundle).expect("json"); + let mut keys: Vec = value.as_object().expect("object").keys().cloned().collect(); + keys.sort_unstable(); + assert_eq!(keys, BUNDLE_KEYS, "the bundle key list drifted"); + + let mut host_keys: Vec = value["host"] + .as_object() + .expect("host object") + .keys() + .cloned() + .collect(); + host_keys.sort_unstable(); + assert_eq!(host_keys, HOST_KEYS, "the host key list drifted"); + for key in REQUIRED_BUNDLE_KEYS { + assert!(keys.iter().any(|k| k == key), "{key} must be required"); + } + } + + #[test] + fn unknown_keys_are_refused_at_parse() { + let body = r#"{ + "schema_version": 1, "environment": "metal", "display_name": "x", + "topic": {}, "runner_id": "rlm_fc_in_guest_harbor" + }"#; + let err = TopicInstallBundle::from_json(body).expect_err("unknown key"); + assert!( + matches!(err, BundleError::Parse(ref m) if m.contains("runner_id")), + "{err}" + ); + + let host_body = r#"{ + "schema_version": 1, "environment": "metal", "display_name": "x", + "topic": {}, "host": {"custom_id": "tbench"} + }"#; + let err = TopicInstallBundle::from_json(host_body).expect_err("unknown host key"); + assert!( + matches!(err, BundleError::Parse(ref m) if m.contains("custom_id")), + "{err}" + ); + } + + #[test] + fn required_keys_are_named_when_absent() { + for (body, missing) in [ + ( + r#"{"environment":"metal","display_name":"x","topic":{},"host":{}}"#, + "schema_version", + ), + ( + r#"{"schema_version":1,"display_name":"x","topic":{},"host":{}}"#, + "environment", + ), + ( + r#"{"schema_version":1,"environment":"metal","topic":{},"host":{}}"#, + "display_name", + ), + ( + r#"{"schema_version":1,"environment":"metal","display_name":"x","host":{}}"#, + "topic", + ), + ( + r#"{"schema_version":1,"environment":"metal","display_name":"x","topic":{}}"#, + "host", + ), + ] { + let err = TopicInstallBundle::from_json(body).expect_err(missing); + assert!( + matches!(err, BundleError::Parse(ref m) if m.contains(missing)), + "{missing}: {err}" + ); + } + } + + #[test] + fn digest_expectations_are_exactly_lowercase_and_unpadded() { + let upper = HEX.to_ascii_uppercase(); + for bad in [ + format!("sha256:{upper}"), + format!("sha256: {HEX}"), + format!("sha256:{HEX} "), + format!(" sha256:{HEX}"), + format!("sha256:{}", &HEX[..63]), + format!("sha256:{HEX}0"), + format!("SHA256:{HEX}"), + HEX.to_owned(), + ] { + for field in ["rlm_image_digest", "experiment_image_digest", "pack_digest"] { + let mut bundle = tb4(); + match field { + "rlm_image_digest" => bundle.host.rlm_image_digest = Some(bad.clone()), + "experiment_image_digest" => { + bundle.host.experiment_image_digest = Some(bad.clone()); + } + _ => bundle.host.pack_digest = Some(bad.clone()), + } + assert!( + matches!( + bundle.validate_shape(), + Err(BundleError::BadDigest { field: f, .. }) if f == field + ), + "{field}={bad:?} must be refused, not silently accepted" + ); + } + } + tb4().validate_shape().expect("lowercase hex validates"); + assert!(is_lower_hex64(HEX)); + assert!(!is_lower_hex64(&upper)); + assert!(!is_lower_hex64(&format!(" {HEX}"))); + } + + /// The host block may only agree with the document. + #[test] + fn a_host_expectation_that_contradicts_the_document_is_refused() { + let other = format!("sha256:{}", "cd".repeat(32)); + let mut bundle = tb4(); + bundle.host.pack_digest = Some(other.clone()); + let err = bundle.validate_shape().expect_err("contradicting pack"); + assert!( + matches!( + err, + BundleError::HostContradictsDocument { + field: "pack_digest", + ref got, + .. + } if *got == other + ), + "{err:?}" + ); + assert!(err.to_string().contains("contradicts"), "{err}"); + } + + #[test] + fn a_runner_and_its_pack_travel_together() { + let mut no_pack = tb4(); + no_pack.host.pack_digest = None; + let err = no_pack.validate_shape().expect_err("runner without pack"); + assert!( + matches!(err, BundleError::RunnerWithoutPack { ref runner_id } + if runner_id == "rlm_fc_in_guest_harbor"), + "{err:?}" + ); + assert!(err.to_string().contains("never invented"), "{err}"); + + // A pack pinned for a topic that selects no runner is refused: the + // bundle would stage something nothing reads. + let mut orphan = tb4(); + orphan + .topic + .constraints + .params + .remove(proof_experiment::PARAM_RUNNER); + orphan + .topic + .constraints + .params + .remove(proof_experiment::PARAM_PACK_DIGEST); + assert!(matches!( + orphan.validate_shape(), + Err(BundleError::PackWithoutRunner) + )); + + // No runner, no pack: the harvest-family shape is legal. + let mut harvest = tb4(); + harvest + .topic + .constraints + .params + .remove(proof_experiment::PARAM_RUNNER); + harvest + .topic + .constraints + .params + .remove(proof_experiment::PARAM_PACK_DIGEST); + harvest.host.pack_digest = None; + harvest.topic.metric.family = MetricFamily::Nll; + harvest.topic.metric.custom_id = String::new(); + harvest.topic.metric.primary = proof_task::PRIMARY_HOLDOUT_NLL.into(); + harvest.host.custom_ids_entry = None; + harvest + .validate_shape() + .expect("a topic may select no runner"); + let plan = harvest.plan(InstallEnvironment::Metal).expect("plan"); + assert!(plan.runner_id.is_none()); + assert!(plan.pack_dir_env.is_none()); + } + + /// A malformed binding in the document is refused, never ignored: a topic + /// that half-selects a backend must not install on another path. + #[test] + fn a_malformed_document_binding_is_refused() { + let mut bundle = tb4(); + bundle.topic.constraints.params.insert( + proof_experiment::PARAM_PACK_DIGEST.into(), + "not-a-digest".into(), + ); + let err = bundle.validate_shape().expect_err("bad pack digest"); + assert!(matches!(err, BundleError::Binding(_)), "{err:?}"); + + // A runner id that is not a custom-id shape is refused by the shared + // binding reader rather than silently treated as "no runner". + let mut bad_runner = tb4(); + bad_runner + .topic + .constraints + .params + .insert(proof_experiment::PARAM_RUNNER.into(), "Not A Runner".into()); + assert!(matches!( + bad_runner.validate_shape(), + Err(BundleError::Binding(_)) + )); + + // Dropping the runner but leaving its pack behind is the half-selected + // shape: nothing runs the pack, so the bundle refuses rather than + // staging it. + let mut no_runner = tb4(); + no_runner + .topic + .constraints + .params + .remove(proof_experiment::PARAM_RUNNER); + assert!(matches!( + no_runner.validate_shape(), + Err(BundleError::PackWithoutRunner) + )); + } + + #[test] + fn an_open_custom_topic_needs_its_id_registered_in_the_host_block() { + let mut bundle = tb4(); + bundle.host.custom_ids_entry = Some("some_other_metric".into()); + let err = bundle.validate_shape().expect_err("id not registered"); + assert!( + matches!(err, BundleError::CustomIdNotRegistered { ref custom_id } + if custom_id == "tbench"), + "{err:?}" + ); + assert!(err.to_string().contains("503"), "{err}"); + + // The id list is parsed the same way `proof-challenge` parses it. + bundle.host.custom_ids_entry = Some("other_metric, tbench ,third".into()); + bundle.validate_shape().expect("one entry is enough"); + assert_eq!( + bundle.registered_custom(), + ["other_metric", "tbench", "third"] + ); + assert!(parse_custom_ids(" ").is_empty()); + } + + #[test] + fn environments_are_exactly_the_two_install_targets() { + assert_eq!(INSTALL_ENVIRONMENTS, ["staging", "metal"]); + for (word, want) in [ + ("staging", InstallEnvironment::Staging), + ("METAL", InstallEnvironment::Metal), + (" metal ", InstallEnvironment::Metal), + ] { + assert_eq!(word.parse::().expect(word), want); + } + assert!("prod".parse::().is_err()); + assert_eq!(InstallEnvironment::Staging.as_str(), "staging"); + } + + #[test] + fn a_bundle_for_another_target_is_refused_not_coerced() { + let mut bundle = tb4(); + bundle.environment = InstallEnvironment::Staging; + let err = bundle + .plan(InstallEnvironment::Metal) + .expect_err("staging bundle on metal"); + assert!( + matches!( + err, + BundleError::EnvironmentMismatch { + bundle: InstallEnvironment::Staging, + requested: InstallEnvironment::Metal, + } + ), + "{err:?}" + ); + assert!(err.to_string().contains("staging"), "{err}"); + } + + #[test] + fn the_digest_ignores_formatting_and_key_order() { + let bundle = tb4(); + let body = serde_json::to_string(&bundle).expect("json"); + let reparsed = TopicInstallBundle::from_json(&body).expect("parse"); + assert_eq!( + bundle.digest().expect("digest"), + reparsed.digest().expect("digest"), + "a round trip is the same bundle" + ); + + // Any real change is a different install, so a different digest. + let mut changed = bundle.clone(); + changed.host.custom_ids_entry = Some("tbench,extra".into()); + assert_ne!( + bundle.digest().expect("a"), + changed.digest().expect("changed"), + "a changed bundle must not hash the same" + ); + let mut renamed = bundle; + renamed.display_name = "Other".into(); + assert_ne!( + reparsed.digest().expect("b"), + renamed.digest().expect("renamed"), + "the label is part of the identity" + ); + } + + /// `pack_dir` names a directory, so it is checked as a path — and it never + /// carries the digest, which travels in the document. + #[test] + fn pack_dir_is_a_path_not_a_digest() { + for bad in ["", "relative/packs", "/var/../etc", "sha256:abc"] { + let mut bundle = tb4(); + bundle.host.pack_dir = Some(bad.into()); + assert!( + matches!(bundle.validate_shape(), Err(BundleError::BadPackDir(_))), + "{bad:?} must be refused as a pack dir" + ); + } + let mut no_dir = tb4(); + no_dir.host.pack_dir = None; + no_dir.validate_shape().expect("a pack dir is optional"); + let plan = no_dir.plan(InstallEnvironment::Metal).expect("plan"); + assert!( + !plan.host_env.iter().any(|e| e.name == ENV_PACK_DIR), + "no directory declared means no pack-dir env line: {:?}", + plan.host_env + ); + + // With a directory, the value is the path and the digest is only in + // the explanation. + let plan = tb4().plan(InstallEnvironment::Metal).expect("plan"); + let pack = plan + .host_env + .iter() + .find(|e| e.name == ENV_PACK_DIR) + .expect("pack dir line"); + assert_eq!(pack.value, "/var/lib/proof/packs"); + assert!(!pack.value.starts_with("sha256:"), "{pack:?}"); + assert!(pack.why.contains(&digest()), "{pack:?}"); + } + + #[test] + fn a_plan_is_serialisable_for_the_dry_run_json_output() { + let plan = tb4().plan(InstallEnvironment::Metal).expect("plan"); + let body = serde_json::to_string(&plan).expect("json"); + assert!(body.contains(r#""topic_id":"tb4""#), "{body}"); + assert!( + body.contains(r#""publish_route":"POST /v1/admin/proof/topics""#), + "{body}" + ); + assert!(body.contains(r#""environment":"metal""#), "{body}"); + let round: TopicInstallPlan = serde_json::from_str(&body).expect("round trip"); + assert_eq!(round, plan); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5850d3fb2..31e79d733 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,6 +75,7 @@ terminates in the host reverse proxy, not in the gateway process. | `proof-challenge` | **Master-only:** signed topics, holdout loading, evaluation orchestration. Library payout is a sum of WTA/discovery topic masses; `ProofEmitter` polls every `PROOF_EMIT_POLL_SECS` (default 120), signs exact-`E` leaves when someone scored, otherwise covers `E` with `ChallengeInternal` (persisted scored-epoch watermark; gateway refuses burn-over-score) | | `proof-vm-orchestrator` | **Host with a working `/dev/kvm`** — production: a dedicated DO droplet (`g-8vcpu-32gb`, nyc1, nested `/dev/kvm`) on the VPC, never colocated on the CP; staging: colocation on the CP droplet with nested `/dev/kvm` is an allowed exception, proven on `cortex-staging` (fragile → provision the dedicated droplet if the boot fails); never Lium: Firecracker + jailer agent behind HTTPS + a bearer file. One RLM microVM per Proof topic from the digest the control plane pins, sister miner guest with no network per paid run, host-stamped `sandboxed` / `flops_used`; for topics whose signed params select an in-guest runner, one dedicated experiment microVM per paid job under configurable caps (lock 16 vCPU / 32 GiB, disk ≥ 16 GiB), pinned pack staged over vsock, destroyed after a scored job and retained on the host after a failed one. Client side is `proof-vm-fc::FirecrackerOrchestrator`; runbooks [`runbooks/proof-vm-orchestrator.md`](runbooks/proof-vm-orchestrator.md), [`runbooks/proof-experiment-vms.md`](runbooks/proof-experiment-vms.md) | | `updater` | Digest-pinned rollouts via `docker-socket-proxy` (master) | +| `proof-admin` | Operator CLI for Proof topic installs (dynamic-topics **P0 skeleton**): `topic validate` (the same acceptance `POST /v1/admin/proof/topics` runs), `topic install --dry-run` (prints that publish call + the host env), `topic list` / `topic show` (read-only view of the existing `proof_topic_version` rows). Adds **no** table and **no** route; a real install and `enable` / `disable` / `seal` exit 3 (not implemented in this slice). The CLI **hands control to the topic's RLM**: the bundle's opaque `rlm` section (rules / migrations / apis / submission_format / scoring) is carried verbatim and never interpreted, so no topic behavior is compiled into challenge, gateway, or orchestrator code. Locked defaults: first slug `tb4` with temporary alias `tbench` (`proof_topic_alias`), shared challenge DB + `topic_id` discriminant, metal installs Owner-only behind `--owner-metal-ack` with staging first | | `trustroot` | Offline keygen / sign / verify for owner-signed TOML | | `bundle` | SCALE types, seal, verify (`PROTOCOL_VERSION`) | | `aggregate` | Integer aggregation (Hamilton house 65535) | diff --git a/docs/COMPLETENESS.md b/docs/COMPLETENESS.md index bbf3aa997..7e687c281 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -81,6 +81,7 @@ specs (`DESIGN_CHALLENGE.md`, `PRISM.md`) remain for `xtask` gates. Leftover | Inference offer | **v0** | Digest-pinned RLM **judge** backend (`proof-eval` / harvest call it). Pin `[inference]` defaults plus schema v1 / ceilings / modes / commitment. `config_commitment` hashes config knobs **and** `provider.base_url`; a topic that spoofs origin is **503** before lattice. Topic `require_judge_offer_commitment` is optional and not a miner bind. Live `InferenceOffer` is operator state. Auth is `PROOF_INFERENCE_API_KEY_FILE` staged as harvest `teacher.env` (never git, never `/v1/status`). Missing/closed/judge down / missing key → `can_score=false` / 503. No baked Qwen; architecture ≠ HF stays retired. | | Eval executor offer | **v0** | `crates/proof-executor`: live `1x` `EvalExecutorOffer` (Lium template, `machine_shape`, `max_proof_deadline_s`, digest, `config_commitment`, status) — a sibling of the judge offer, not the same document. Pin ceilings `eval_executor_schema_version` / `gpu_class = "1x"` / `max_proof_deadline_s_ceiling = 7200` / optional `allowed_lium_template_prefixes` / `eval_executor_commitment_alg`. Public on `GET /v1/status` + `GET /v1/proof/executor`; rotated via `POST /v1/admin/proof/executor` (in-memory until restart; boot from `PROOF_EVAL_EXECUTOR_OFFER_FILE`). Topic tighten-only `eval_executor.{require_offer_commitment, max_proof_deadline_s}`, no per-topic `machine_id`. Lium path: missing/closed/shape ≠ `1x` → `can_score=false` / 503; harvest rents the offer's digest-scoped template (raw Lium UUIDs refused under any allowlist; the resolver binds the template to `eval_image@digest`) at exactly `1x` (`rent_gpu_count ≠ 1` aborts pre-rent) and holds the run to the deadline (the deadline is the pod `timeout`, never clamped by the host fallback; harvest wait = deadline + grace; wrapper-cut run → 503 + `stdout_tail`, external SIGKILL named separately). `PROOF_HARVEST_TEMPLATE_ID` / `_GPU_COUNT` / `_DEADLINE_SECS` hot-swap under the pin ceilings; refused when the topic pins the offer commitment; the run request and row stamp the commitment of what actually ran. Sim does not consult it. No live Lium rent in CI. | | Topics | **done** | sr25519 under the `proof` trust-root key (`base-proof-topic-v1`). Admin `POST /v1/admin/proof/topics`. A topic must be sealed to `open`. | +| Topic installs (dynamic topics P0) | **skeleton** | `bins/proof-admin` wraps the **existing** publish path rather than adding a registry: `topic validate` runs the same acceptance `POST /v1/admin/proof/topics` runs (`TopicDocument::validate` + `verify_signature`, against `config/proof-pin.toml`), `topic install --dry-run` prints that publish call plus the host env, and `topic list` / `topic show` are a read-only view of the existing `proof_topic_version` rows (`RlmStore::latest_topics`, migration `0020`). Bundle schema v1 (`crates/proof-topic-bundle`) carries the signed document verbatim plus a `host` block that must **agree** with it (a contradiction is a reject); runner/pack/custom-id bindings are the document's own `constraints.params`. **Schema: `0024_proof_topic_alias.sql` only** — it adds `proof_topic_alias` plus a `BEFORE INSERT`/`UPDATE` trigger pair that fails closed when an alias would shadow a published slug (**publish-path integrity, not scoring math**); it does not `ALTER` or `DROP` anything, and `0020` tables keep their columns, keys, and grants. **No route change, no scoring change** — a real install is not implemented (exit 3), and `topic enable` / `disable` / `seal` are stubs (a topic's lifecycle is the document's `status`). No route change (P1), allocator change (P2), full install (P3), or removal of the compiled-in topic bindings (P4). Locked defaults: first topic slug **`tb4`** with temporary alias **`tbench`** (`proof_topic_alias`, migration `0024` — a row carries only `alias → topic_id`, so it cannot drift from the topic), shared challenge DB with a `topic_id` discriminant, and **metal `--env metal` is Owner-only behind `--owner-metal-ack` with staging first** (staging is never gated). `tbench` is both the alias and the runner registry's custom id; the alias is temporary, the custom id is the scoring binding. **Topics are RLM-owned:** the bundle's `rlm` section (rules / migrations / apis / submission_format / scoring) is handed to the RLM verbatim and Rust never interprets it; two guard tests fail the build if a topic id or a topic-specific rule/metric/format appears in the bundle crate's or the CLI's logic. | | Holdout | **done** | Per-topic operator file (`PROOF_HOLDOUT_FILE`). Commitment in the topic document, never in the pin. `xtask proof-holdout --topic-id`. | | Live harvest | **partial** | `crates/proof-harvest` over `harvest-pod` stages `request.json`, `teacher.env`, `PROOF_PROXY_MODEL_DIR`, and `PROOF_HOLDOUT_STORE`. `PROOF_FORCE_SIM` is local-only. Live rent still needs a republished proof-eval digest (current pin still has the invalid HF default) plus operator-staged proxy dir + holdout shards. | | Configured allocation | **8000 bps** | Proof-weighted 20%/80% regardless of digest. Payout splits equally across currently `open` topics, then `wta` or `discovery`. Empty digest / missing evaluation prerequisites still fail closed. | diff --git a/docs/PROOF.md b/docs/PROOF.md index e7ac783d5..b717e1b95 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -195,6 +195,134 @@ Ship order: control plane (payout schema) → proof-eval image + digest pin path: [`deploy/scripts/proof-operator-path.sh`](../deploy/scripts/proof-operator-path.sh). Empty digest stays 503 (never invent a sha256). +## Topic install bundles (`proof-admin`, P0 skeleton) + +A topic already has **one** home: the operator-signed document published +through `POST /v1/admin/proof/topics` and persisted in `proof_topic_version` +(migration [`0020`](../crates/db/migrations/0020_proof_rlm.sql)). Its +bindings are signed topic data too — `constraints.params` carries the in-guest +runner and its pinned pack digest, and the image pins are operator env. There +is no second topic table and no second route. + +What `bins/proof-admin` adds is the **procedure**: a bundle that names the +signed document plus the host env that must agree with it, `validate` that +runs the same acceptance the publish route runs, and `install --dry-run` that +prints the exact publish call and env lines without touching anything. + +```bash +# Check a bundle. Runs the same checks the publish route runs; writes nothing. +proof-admin topic validate --bundle /root/.base-secrets/proof/tb4.json \ + --pin config/proof-pin.toml + +# Resolve the publish call and host env. Touches nothing. +proof-admin topic install --bundle …/tb4.json --env metal --dry-run + +# Read what is installed (a read-only view of proof_topic_version): +BASE_DATABASE_URL=… proof-admin topic list +BASE_DATABASE_URL=… proof-admin topic show tb4 +``` + +The bundle carries the signed `topic` document verbatim, so every binding has +exactly one copy in the system. Its `host` block is what the master must be +configured with: `rlm_image_digest` (`PROOF_RLM_VM_IMAGE_DIGEST`), +`experiment_image_digest` (`PROOF_EXPERIMENT_VM_IMAGE_DIGEST`), +`custom_ids_entry` (`PROOF_VM_RUNNER_CUSTOM_IDS`, which must register an open +custom topic's `metric.custom_id` or it answers **503**), and `pack_dir` +(`PROOF_VM_AGENT_EXPERIMENT_PACK_DIR`, a **directory** — the host re-hashes +the tar it finds there against the document's pin, so the directory never +carries a digest). + +**The document wins.** A host expectation that disagrees with the signed +document is a reject, not a silent override: the signature is what the +scoring path trusts, so an operator env saying otherwise would run something +other than what was signed. A runner without a `pack_digest` is refused, as +is a pack no runner reads. Every digest is `sha256:<64 lowercase hex>` or +absent, never invented. Unknown keys are refused at parse. + +### The RLM owns topic behavior + +Topics are **RLM-based and autonomous**. The admin CLI's job is to **hand +control to the topic's RLM** — it asks the RLM to install and set the topic +up. The bundle's `rlm` section is what gets handed over, and it owns +everything topic-specific: + +| RLM-owned | What it is | +|-----------|------------| +| `rules` | the anti-cheat rules the RLM ticks before any paid inference | +| `migrations` | the SQL migrations the topic's install needs | +| `apis` | the APIs the topic exposes | +| `submission_format` | the shape a miner submits | +| `scoring` | how the topic scores | + +**Rust never interprets any of it.** The bundle crate checks the section's +*shape* (an object or array, bounded) and carries it byte-for-byte; it does +not know what a rule, a migration, an API, a submission format, or a scoring +function means. Nothing topic-specific is compiled into `proof-challenge`, +the gateway, the orchestrator, or this CLI — no `if topic == "tb4"` branch, +no rule list, no metric, no submit format. A topic's behavior travels in its +signed document and its RLM section. + +The seed slug `tb4` and its temporary alias `tbench` are **strings** that +appear in test fixtures and operator examples. They are never a condition in +logic, and two tests fail the build if that changes: one over the bundle +crate's non-test source, one over the CLI's. + +The install plan prints the hand-off first and the RLM's own lifecycle steps +(`provision -> propose_rules -> baseline`, the existing `TopicSetup` driver), +then the publish call and the host env. The CLI does not run those steps and +does not read the section — it reports what the RLM will be asked to do. + +### Locked defaults + +| Default | Value | Where | +|---------|-------|-------| +| First topic slug | **`tb4`** | the signed document's `id` | +| Temporary alias | **`tbench`** | `proof_topic_alias` row `tbench → tb4` (migration `0024`) | +| Storage | **shared challenge DB**, `topic_id` discriminant | `proof_topic_version` (no per-topic schema) | +| Metal install | **Owner-only, staging first** | `--owner-metal-ack` gate | +| Custom id | `tbench` | the document's `metric.custom_id` | + +**Schema:** `0024_proof_topic_alias.sql` is the only change in this slice. It +adds `proof_topic_alias` and a `BEFORE INSERT`/`UPDATE` trigger pair that makes +an alias collision with a published slug fail closed in both directions — a +**publish-path integrity guard, not scoring math**: it cannot change a score, +a payout, or a sealed vector. It does not `ALTER` or `DROP` anything, and the +`0020` tables keep their columns, keys, and grants. + +`tbench` is two different things and they are not the same mapping: it is the +topic's **alias** (`show tbench` resolves to `tb4`) and also the runner +registry's **custom id** (`PROOF_VM_RUNNER_CUSTOM_IDS=tbench`). The alias is +temporary — retire it with `proof-admin topic alias rm tbench` once miner +links move — while the custom id is the scoring binding and stays. + +```bash +proof-admin topic alias set tbench --topic tb4 # the locked default +proof-admin topic alias list --topic tb4 +proof-admin topic show tbench # resolves to tb4 +proof-admin topic alias rm tbench # retire the temporary alias +``` + +An alias carries only `alias → topic_id`: no name, no pins, no status. It +cannot drift from the topic it names, and retiring it changes nothing about +the topic. It must name a topic that already has a published version — +fail-closed in the store, so a stale alias resolves to *nothing* rather than +to an empty document. + +**Metal is Owner-only and staging goes first.** `--env metal` is refused +unless `--owner-metal-ack` is passed, which asserts both that an Owner +authorized the install and that staging has passed for that bundle. The gate +is an operator assertion, not a verified precondition: it exists so a live +target cannot be reached by a default or a copy-pasted staging command. +`--env staging` is never gated. + +**P0 scope — what this does not do.** `install` **prints** the publish call; +it does not perform it, because publishing needs the operator bearer, which +stays on the host. `topic enable`, `topic disable`, and `topic seal` exit +**3** with a "not implemented in this slice" message — a topic's lifecycle is +the signed document's `status`, so the answer is to re-sign and re-publish. +There is no route change (P1), no allocator change (P2), no full install +(P3), and no removal of the compiled-in topic bindings (P4). + ## Metric families | Family | Primary | Win |