From f35522de0d5291174f197666eb3a543a719a9a3b Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:06:48 +0000 Subject: [PATCH 01/17] feat(proof): topics table + proof-admin install skeleton (P0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic-topics slice 1 (Arch pins): a per-topic install registry and the operator CLI that writes it. No behavior change — nothing on a scoring path reads the new table, no route and no allocator changes, and the compiled-in topic bindings stay (P4). - db: migration 0024 adds `proof_topic` to the shared challenge DB, keyed by `topic_id` (the discriminant), with `display_name`, `version`, `environment`, `runner_id`, `aliases`, `enabled`, `config` JSONB, `pin_rlm`, `pin_experiment`, `pack_digest`, `n_concurrent`, `sealed_custom_value`, `schema_version`, the bundle verbatim + its digest, and timestamps. Shape CHECKs guard the slug, the `sha256:<64 hex>` pins, a finite baseline, and the alias array. Mutable (enable/disable, re-install, seal) so `base_app` gets SELECT/INSERT/UPDATE and never DELETE: a topic is disabled, never dropped. - db::topics: upsert / list / get over runtime sqlx (no compile-time DB), plus DB-gated integration tests (install, ordering, re-install keeps created_at and does not silently disable, CHECK refusals, app-role grants). - crates/proof-topic-bundle: the topic install bundle schema v1 — parse, validate, canonical digest, install plan. Unknown keys are refused, every pin is `sha256:<64 hex>` or absent (never invented), and an in-guest `runner_id` without a `pack_digest` is refused. - bins/proof-admin: `topic validate` (no writes), `topic install [--dry-run]` (dry-run needs no database), `topic list`, `topic show`. Installing writes `enabled = false`; `topic enable` / `disable` / `seal` exit 3 with a clear "not implemented in this slice". Process-level tests cover validate, dry-run, the env mismatch, the missing-database path, and the stubs. - docs: PROOF.md § Topic install bundles, COMPLETENESS row, ARCHITECTURE bin. Arch defaults: first topic slug `tb4` with alias `tbench`; shared DB with a `topic_id` discriminant. Alias resolution, routes (P1), the allocator (P2), the full install (P3), and removing the hardcoded bindings (P4) are later slices. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- Cargo.lock | 24 + bins/proof-admin/Cargo.toml | 24 + bins/proof-admin/src/main.rs | 557 +++++++++++++ bins/proof-admin/tests/cli.rs | 369 +++++++++ crates/db/migrations/0024_proof_topics.sql | 106 +++ crates/db/src/lib.rs | 10 + crates/db/src/topics.rs | 294 +++++++ crates/db/tests/topics.rs | 370 +++++++++ crates/proof-topic-bundle/Cargo.toml | 20 + crates/proof-topic-bundle/src/lib.rs | 881 +++++++++++++++++++++ docs/ARCHITECTURE.md | 3 +- docs/COMPLETENESS.md | 1 + docs/PROOF.md | 39 + 13 files changed, 2697 insertions(+), 1 deletion(-) create mode 100644 bins/proof-admin/Cargo.toml create mode 100644 bins/proof-admin/src/main.rs create mode 100644 bins/proof-admin/tests/cli.rs create mode 100644 crates/db/migrations/0024_proof_topics.sql create mode 100644 crates/db/src/topics.rs create mode 100644 crates/db/tests/topics.rs create mode 100644 crates/proof-topic-bundle/Cargo.toml create mode 100644 crates/proof-topic-bundle/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ed54368c3..ed8df6cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3681,6 +3681,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proof-admin-bin" +version = "0.1.0" +dependencies = [ + "clap", + "db", + "proof-topic-bundle", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "proof-canon" version = "0.1.0" @@ -4020,6 +4032,18 @@ dependencies = [ "toml", ] +[[package]] +name = "proof-topic-bundle" +version = "0.1.0" +dependencies = [ + "hex", + "proof-canon", + "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..ae9ee5d5e --- /dev/null +++ b/bins/proof-admin/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "proof-admin-bin" +description = "Proof operator CLI: topic install bundle validate / install (dry-run) / list / show, plus fail-closed stubs" +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-topic-bundle = { path = "../../crates/proof-topic-bundle" } +serde = { version = "1", features = ["derive"] } +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..0f75b2593 --- /dev/null +++ b/bins/proof-admin/src/main.rs @@ -0,0 +1,557 @@ +//! `proof-admin` — Proof operator CLI for dynamic topics. +//! +//! P0 skeleton of the dynamic-topics admin path. It validates and installs a +//! **topic install bundle** (the JSON record that carries a topic's runner, +//! image and pack pins, concurrency, and enable flag), lists and shows what is +//! installed, and refuses the operations that belong to later slices with a +//! clear "not implemented" rather than a half-working guess. +//! +//! What this binary does **not** do, deliberately: +//! +//! - It never enables a topic. `topic install` writes a row with +//! `enabled = false`; `topic enable` / `topic disable` / `topic seal` are +//! fail-closed stubs (exit code 3) for the later slices. +//! - It touches no route, no allocator, and no scoring path. Nothing in this +//! repository reads `proof_topic` yet, so an install cannot move a score. +//! - It removes none of the compiled-in bindings the current live topic uses; +//! that is the last slice. +//! +//! `topic install --dry-run` needs no database at all: it parses, validates, +//! and prints the resolved plan. A real install needs `BASE_DATABASE_URL` +//! (or `BASE_DATABASE_URL_FILE`) and writes one disabled row. +//! +//! 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 db::{NewTopic, PgPool, TopicRow}; +use proof_topic_bundle::{InstallEnvironment, TopicInstallBundle, TopicInstallPlan}; + +/// Successful run. +const EXIT_OK: u8 = 0; +/// A command failed (bad bundle, 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: topic install bundles, topic list/show", + long_about = "proof-admin manages Proof topic installs (dynamic-topics P0 skeleton). + +Validate a bundle without touching anything: + proof-admin topic validate --bundle tb4.json + +Resolve an install without a database: + proof-admin topic install --bundle tb4.json --env metal --dry-run + +Install it (writes one DISABLED row; enabling is a later slice): + BASE_DATABASE_URL=... proof-admin topic install --bundle tb4.json --env metal + +Nothing here enables a topic, opens a route, or changes how a score is +computed. `topic enable`, `topic disable`, and `topic seal` exit 3 with a +'not implemented in this slice' message." +)] +struct Cli { + /// Postgres URL. 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 topic install bundle. Reads the file, writes nothing. + Validate { + /// Bundle JSON. + #[arg(long, value_name = "PATH")] + bundle: PathBuf, + }, + /// Install a topic bundle. `--dry-run` resolves and prints it; a real + /// install writes one disabled row and needs a database. + 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, + /// Resolve and print the install plan without touching a database. + #[arg(long)] + dry_run: bool, + }, + /// List installed topics. An empty table prints nothing and exits 0. + List, + /// Show one installed topic by its exact `topic_id`. + Show { + /// Topic slug. Aliases are not resolved in this slice. + topic_id: String, + }, + /// 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, + }, +} + +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, database error). + Error(String), +} + +async fn run(cli: Cli) -> Result<(), Failure> { + // Split the global options from the subcommand so both can be borrowed + // without a partial move of `Cli`. + 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, + } +} + +/// 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, +} + +async fn run_topic(opts: &Options, cmd: &TopicCmd) -> Result<(), Failure> { + match cmd { + TopicCmd::Validate { bundle } => cmd_validate(opts, bundle), + TopicCmd::Install { + bundle, + env, + dry_run, + } => cmd_install(opts, bundle, env, *dry_run).await, + TopicCmd::List => cmd_list(opts).await, + TopicCmd::Show { topic_id } => cmd_show(opts, topic_id).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, + )), + } +} + +/// 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: topics table \ + + admin CLI skeleton). Nothing was changed. Enabling, disabling, and sealing a topic \ + are later slices; installing a topic today writes a disabled row that no scoring path \ + reads yet." + )) +} + +/// Read and validate a bundle file. Shared by `validate` and `install`. +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()))) +} + +/// 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) -> Result<(), Failure> { + let bundle = load_bundle(path)?; + bundle + .validate() + .map_err(|e| Failure::Error(format!("{}: {e}", path.display())))?; + let digest = bundle.digest().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, + "schema_version": bundle.schema_version, + "version": bundle.version, + "environment": bundle.environment.as_str(), + "aliases": bundle.aliases, + "bundle_digest": digest, + }); + println!( + "{}", + serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into()) + ); + return Ok(()); + } + println!("bundle {} is valid", path.display()); + println!(" topic_id {}", bundle.topic_id); + println!(" schema_version {}", bundle.schema_version); + println!(" version {}", bundle.version); + println!(" environment {}", bundle.environment); + println!(" aliases {}", join_or_dash(&bundle.aliases)); + println!(" bundle_digest {digest}"); + println!(); + println!("Nothing was written. Install with `proof-admin topic install --bundle … --env …`."); + Ok(()) +} + +async fn cmd_install(opts: &Options, path: &Path, env: &str, dry_run: bool) -> Result<(), Failure> { + let bundle = load_bundle(path)?; + let requested = parse_env(env)?; + let plan = bundle + .plan(requested) + .map_err(|e| Failure::Error(format!("{}: {e}", path.display())))?; + + if dry_run { + if opts.json { + print_json(&plan)?; + return Ok(()); + } + print_plan(&plan); + println!(); + println!("Dry run: nothing was written and no database was touched."); + return Ok(()); + } + + let pool = connect(opts).await?; + let bundle_value = serde_json::to_value(&bundle) + .map_err(|e| Failure::Error(format!("serialize bundle: {e}")))?; + let row = new_topic(&plan, &bundle_value); + db::upsert_topic(&pool, &row) + .await + .map_err(|e| Failure::Error(format!("install {}: {e}", plan.topic_id)))?; + + if opts.json { + let body = serde_json::json!({ + "ok": true, + "installed": true, + "topic_id": plan.topic_id, + "environment": plan.environment.as_str(), + "bundle_digest": plan.bundle_digest, + "enabled": false, + }); + println!( + "{}", + serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into()) + ); + return Ok(()); + } + print_plan(&plan); + println!(); + println!( + "Installed {} (DISABLED). Enabling is a later slice: nothing scores this topic yet.", + plan.topic_id + ); + Ok(()) +} + +/// Borrow the plan's fields as the row to write. `bundle` is the file +/// verbatim: what an operator reviews is what the row keeps. +fn new_topic<'a>(plan: &'a TopicInstallPlan, bundle: &'a serde_json::Value) -> NewTopic<'a> { + NewTopic { + topic_id: &plan.topic_id, + display_name: &plan.display_name, + version: clamp_i32(plan.version), + environment: plan.environment.as_str(), + runner_id: &plan.runner_id, + aliases: &plan.aliases, + config: &plan.config, + pin_rlm: &plan.pin_rlm, + pin_experiment: &plan.pin_experiment, + pack_digest: &plan.pack_digest, + n_concurrent: clamp_i32(plan.n_concurrent), + sealed_custom_value: plan.sealed_custom_value, + schema_version: clamp_i32(plan.schema_version), + bundle, + bundle_digest: &plan.bundle_digest, + } +} + +/// A `u32` that fits the row's `INTEGER` columns. The bundle's own bounds keep +/// every value far below `i32::MAX`, so this only guards the cast. +fn clamp_i32(v: u32) -> i32 { + i32::try_from(v).unwrap_or(i32::MAX) +} + +async fn cmd_list(opts: &Options) -> Result<(), Failure> { + let pool = connect(opts).await?; + let rows = db::list_topics(&pool) + .await + .map_err(|e| Failure::Error(format!("list topics: {e}")))?; + if opts.json { + let body: Vec = rows.iter().map(topic_json).collect(); + println!( + "{}", + serde_json::to_string_pretty(&body).unwrap_or_else(|_| "[]".into()) + ); + 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!("Nothing is enabled by this CLI in this slice; see `proof-admin topic --help`."); + Ok(()) +} + +async fn cmd_show(opts: &Options, topic_id: &str) -> Result<(), Failure> { + let pool = connect(opts).await?; + let row = db::get_topic(&pool, topic_id) + .await + .map_err(|e| Failure::Error(format!("show {topic_id}: {e}")))?; + let Some(row) = row else { + return Err(Failure::Error(format!( + "no installed topic {topic_id:?}. Aliases are not resolved in this slice; \ + use `proof-admin topic list` to see the exact ids." + ))); + }; + if opts.json { + print_json(&topic_json(&row))?; + return Ok(()); + } + print_row(&row); + Ok(()) +} + +/// Open the database, or explain which variable to set. +async fn connect(opts: &Options) -> Result { + let url = database_url(opts)?; + db::connect(&url) + .await + .map_err(|e| Failure::Error(format!("connect: {e}"))) +} + +/// `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 { + 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(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(trimmed.to_owned()) + } + (None, None) => Err(Failure::Usage( + "this command needs a database: set BASE_DATABASE_URL (or BASE_DATABASE_URL_FILE). \ + `topic validate` and `topic install --dry-run` need no database." + .into(), + )), + } +} + +fn print_plan(plan: &TopicInstallPlan) { + println!("topic install plan"); + println!(" topic_id {}", plan.topic_id); + println!(" display_name {}", plan.display_name); + println!(" version {}", plan.version); + println!(" environment {}", plan.environment); + println!(" aliases {}", join_or_dash(&plan.aliases)); + println!(" runner_id {}", dash_if_empty(&plan.runner_id)); + println!(" pin_rlm {}", dash_if_empty(&plan.pin_rlm)); + println!( + " pin_experiment {}", + dash_if_empty(&plan.pin_experiment) + ); + println!(" pack_digest {}", dash_if_empty(&plan.pack_digest)); + println!(" n_concurrent {}", plan.n_concurrent); + println!( + " sealed_custom_value {}", + plan.sealed_custom_value + .map_or_else(|| "-".to_owned(), |v| v.to_string()) + ); + println!(" schema_version {}", plan.schema_version); + println!(" bundle_digest {}", plan.bundle_digest); + println!( + " enabled {} (install never enables)", + plan.enabled + ); +} + +fn print_row(row: &TopicRow) { + println!("topic {}", row.topic_id); + println!(" display_name {}", row.display_name); + println!(" version {}", row.version); + println!(" environment {}", row.environment); + println!(" aliases {}", join_or_dash(&row.aliases)); + println!(" enabled {}", row.enabled); + println!(" runner_id {}", dash_if_empty(&row.runner_id)); + println!(" pin_rlm {}", dash_if_empty(&row.pin_rlm)); + println!(" pin_experiment {}", dash_if_empty(&row.pin_experiment)); + println!(" pack_digest {}", dash_if_empty(&row.pack_digest)); + println!(" n_concurrent {}", row.n_concurrent); + println!( + " sealed_custom_value {}", + row.sealed_custom_value + .map_or_else(|| "-".to_owned(), |v| v.to_string()) + ); + println!(" schema_version {}", row.schema_version); + println!(" bundle_digest {}", row.bundle_digest); + println!(" config {}", compact(&row.config)); + println!(" created_at {}", row.created_at); + println!(" updated_at {}", row.updated_at); +} + +/// One-line summary for `topic list`. +fn summarize(row: &TopicRow) -> String { + let state = if row.enabled { "enabled" } else { "disabled" }; + let runner = dash_if_empty(&row.runner_id); + let aliases = if row.aliases.is_empty() { + String::new() + } else { + format!(" (aliases: {})", row.aliases.join(", ")) + }; + format!( + "{:<24} v{:<3} {:<7} {:<8} runner={}{}", + row.topic_id, row.version, row.environment, state, runner, aliases + ) +} + +fn topic_json(row: &TopicRow) -> serde_json::Value { + serde_json::json!({ + "topic_id": row.topic_id, + "display_name": row.display_name, + "version": row.version, + "environment": row.environment, + "aliases": row.aliases, + "enabled": row.enabled, + "runner_id": row.runner_id, + "pin_rlm": row.pin_rlm, + "pin_experiment": row.pin_experiment, + "pack_digest": row.pack_digest, + "n_concurrent": row.n_concurrent, + "sealed_custom_value": row.sealed_custom_value, + "schema_version": row.schema_version, + "bundle_digest": row.bundle_digest, + "config": row.config, + "created_at": row.created_at, + "updated_at": row.updated_at, + }) +} + +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 join_or_dash(items: &[String]) -> String { + if items.is_empty() { + "-".to_owned() + } else { + items.join(", ") + } +} + +fn dash_if_empty(s: &str) -> String { + if s.is_empty() { + "-".to_owned() + } else { + s.to_owned() + } +} + +fn compact(value: &serde_json::Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "{}".into()) +} diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs new file mode 100644 index 000000000..85aee2431 --- /dev/null +++ b/bins/proof-admin/tests/cli.rs @@ -0,0 +1,369 @@ +//! Process-level tests for `proof-admin` (dynamic-topics P0). +//! +//! Everything here runs without a database, a network, or a metal key: +//! `topic validate` and `topic install --dry-run` are the two commands P0 +//! promises to make work end to end, and the stubs must fail closed with +//! exit code 3 rather than doing something partial. +//! +//! The real install path is covered against Postgres in +//! `crates/db/tests/topics.rs` (schema and upsert) and by the same +//! `upsert_topic` call this binary makes; these tests assert that the binary +//! never *reaches* a database unless it was asked to and given one. + +#![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, missing database, ...). +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; + +const HEX: &str = "abababababababababababababababababababababababababababababababab"; + +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_bundle(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, body).expect("write bundle"); + path +} + +/// The Arch default bundle: slug `tb4`, alias `tbench`. +fn tb4_json(environment: &str) -> String { + format!( + r#"{{ + "schema_version": 1, + "topic_id": "tb4", + "display_name": "Terminal-Bench 4", + "version": 1, + "environment": "{environment}", + "aliases": ["tbench"], + "runner_id": "rlm_fc_in_guest_harbor", + "pin_rlm": "sha256:{HEX}", + "pin_experiment": "sha256:{HEX}", + "pack_digest": "sha256:{HEX}", + "n_concurrent": 2, + "config": {{"task_slice": "tb4-first-15"}} +}}"# + ) +} + +/// 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) +} + +#[test] +fn validate_accepts_the_arch_default_bundle_and_writes_nothing() { + let dir = workdir("validate-ok"); + let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); + let out = run(&["topic", "validate", "--bundle", bundle.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", + "aliases tbench", + "bundle_digest sha256:", + "Nothing was written", + ] { + assert!(body.contains(needle), "missing {needle:?} in:\n{body}"); + } + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn validate_reports_the_offending_key_and_writes_nothing() { + let dir = workdir("validate-bad"); + // An unknown key is refused rather than ignored: a binding this build + // cannot name is a binding nothing enforces. + let unknown = tb4_json("metal").replace("\"version\": 1,", "\"task_slice\": \"x\","); + let bundle = write_bundle(&dir, "unknown.json", &unknown); + let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + assert_eq!(code(&out), EXIT_ERROR, "stderr={}", stderr(&out)); + assert!( + stderr(&out).contains("task_slice"), + "stderr must name the unknown key: {}", + stderr(&out) + ); + assert!( + stdout(&out).is_empty(), + "a failure prints nothing to stdout" + ); + + // A runner with no pack cannot be installed, so it does not validate. + let no_pack = tb4_json("metal").replace(&format!(",\n \"pack_digest\": \"sha256:{HEX}\""), ""); + let bundle = write_bundle(&dir, "no-pack.json", &no_pack); + let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + assert_eq!(code(&out), EXIT_ERROR); + assert!( + stderr(&out).contains("pack_digest is required"), + "stderr={}", + stderr(&out) + ); + + // An invented digest is refused by name. + let bad_digest = tb4_json("metal").replace(&format!("sha256:{HEX}"), "sha256:abc"); + let bundle = write_bundle(&dir, "bad-digest.json", &bad_digest); + let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + assert_eq!(code(&out), EXIT_ERROR); + assert!( + stderr(&out).contains("64 lowercase hex"), + "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_bundle(&dir, "tb4.json", &tb4_json("metal")); + let out = run(&[ + "--json", + "topic", + "validate", + "--bundle", + bundle.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["aliases"][0], "tbench"); + assert!( + parsed["bundle_digest"] + .as_str() + .unwrap_or_default() + .starts_with("sha256:"), + "{parsed}" + ); + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn dry_run_install_resolves_a_disabled_plan_without_a_database() { + let dir = workdir("dry-run"); + let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + "--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", + "aliases tbench", + "runner_id rlm_fc_in_guest_harbor", + "n_concurrent 2", + "enabled false", + "nothing was written and no database was touched", + ] { + assert!(body.contains(needle), "missing {needle:?} in:\n{body}"); + } + 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_bundle(&dir, "tb4.json", &tb4_json("staging")); + let out = run(&[ + "--json", + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "staging", + "--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["enabled"], false, "a plan never enables"); + assert_eq!(parsed["n_concurrent"], 2); + assert_eq!(parsed["schema_version"], 1); + 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_bundle(&dir, "tb4.json", &tb4_json("metal")); + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "staging", + "--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(); +} + +#[test] +fn a_real_install_without_a_database_is_a_usage_error_not_a_write() { + let dir = workdir("no-db"); + let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); + let out = run(&[ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + ]); + assert_eq!(code(&out), EXIT_USAGE, "stderr={}", stderr(&out)); + assert!( + stderr(&out).contains("needs a database"), + "stderr={}", + stderr(&out) + ); + fs::remove_dir_all(&dir).ok(); +} + +#[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 bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); + let url_file = dir.join("url.txt"); + fs::write(&url_file, "postgres://example/db").expect("write url file"); + let out = Command::new(env!("CARGO_BIN_EXE_proof-admin")) + .args([ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + "--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}" + ); +} diff --git a/crates/db/migrations/0024_proof_topics.sql b/crates/db/migrations/0024_proof_topics.sql new file mode 100644 index 000000000..3eea72a5d --- /dev/null +++ b/crates/db/migrations/0024_proof_topics.sql @@ -0,0 +1,106 @@ +-- Proof topics: the installed-topic registry (dynamic-topics P0 skeleton). +-- +-- Until now a Proof topic existed only as a signed document (`proof_topic_version`, +-- migration 0020) plus a set of host env vars. The dynamic-topics work moves the +-- per-topic bindings — which runner, which RLM image, which experiment pack, how +-- much concurrency, whether the topic is live — into the shared challenge DB, +-- keyed by `topic_id`. This table is that home. P0 lands the table and the admin +-- CLI skeleton; nothing reads it on a scoring path yet (no route change, no +-- allocator change), so adding it cannot move a score. +-- +-- Relationship to `proof_topic_version`: that table is the append-only journal of +-- *signed documents* (a re-sign is a new version). This table is the single +-- current *install* row per topic — what the operator installed, from which +-- bundle, and whether it is enabled. `topic_id` is the discriminant and the +-- primary key: one row per topic, replaced in place on re-install. +-- +-- The pin/binding columns mirror bindings that today travel in the signed +-- topic's `constraints.params` or in operator env (`in_guest_benchmark_runner`, +-- `experiment_pack_digest`, `PROOF_RLM_VM_IMAGE_DIGEST`, the experiment guest +-- image). They are install state here, not a second scoring contract: P0 writes +-- nothing and no scoring path reads them. Empty string means "not pinned", which +-- every later slice must read as fail-closed (an unpinned topic never boots), +-- never as "use a default". +-- +-- `sealed_custom_value` stays NULL until the seal path measures the baseline. A +-- topic with no sealed value cannot be enabled, because nobody is paid for +-- beating a number nobody measured. +-- +-- `aliases` is the Arch default for the first topic: the slug is `tb4` and +-- `tbench` is an alias, so old miner links keep resolving to one row rather +-- than two topics that could drift apart. Nothing resolves an alias yet (P0 +-- has no route change); the column exists so the later slice does not need a +-- second migration. +-- +-- No secrets: no key, token, or credential column. The CHECKs are shape guards +-- (slug, `sha256:<64 hex>`, finite baseline), never authentication. +-- +-- Mutable table (enable/disable, re-install, seal), so `base_app` gets UPDATE — +-- but not DELETE: a topic is disabled, never dropped, so the install history a +-- bundle digest pins stays readable. + +CREATE TABLE proof_topic ( + topic_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + version INTEGER NOT NULL, + environment TEXT NOT NULL, -- staging | metal (install target) + runner_id TEXT NOT NULL DEFAULT '', -- in-guest runner id, '' = none + aliases TEXT[] NOT NULL DEFAULT '{}', -- extra slugs the topic answers to + enabled BOOLEAN NOT NULL DEFAULT FALSE, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + pin_rlm TEXT NOT NULL DEFAULT '', -- sha256: RLM VM image + pin_experiment TEXT NOT NULL DEFAULT '', -- sha256: experiment guest image + pack_digest TEXT NOT NULL DEFAULT '', -- sha256: experiment pack tar + n_concurrent INTEGER NOT NULL DEFAULT 1, + sealed_custom_value DOUBLE PRECISION, -- NULL until the baseline is sealed + schema_version INTEGER NOT NULL, -- install bundle schema version + bundle JSONB NOT NULL, -- the validated bundle, verbatim + bundle_digest TEXT NOT NULL, -- sha256: over canonical bundle + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT proof_topic_id_check CHECK (topic_id ~ '^[a-z0-9][a-z0-9-]{1,62}$'), + CONSTRAINT proof_topic_display_name_check CHECK (char_length(display_name) BETWEEN 1 AND 128), + CONSTRAINT proof_topic_version_pos CHECK (version >= 1), + CONSTRAINT proof_topic_environment_check CHECK (environment IN ('staging', 'metal')), + CONSTRAINT proof_topic_runner_id_check + CHECK (runner_id = '' OR runner_id ~ '^[a-z0-9][a-z0-9_-]{1,63}$'), + -- Aliases are topic slugs too, and a topic is never its own alias. The + -- joined form is the only element-wise regex a CHECK can carry; a stray + -- comma still has to match the slug pattern on both sides, so it cannot + -- smuggle a malformed element in. + CONSTRAINT proof_topic_aliases_bound CHECK (cardinality(aliases) <= 8), + CONSTRAINT proof_topic_aliases_shape CHECK ( + cardinality(aliases) = 0 + OR array_to_string(aliases, ',') ~ '^[a-z0-9][a-z0-9-]{1,62}(,[a-z0-9][a-z0-9-]{1,62})*$' + ), + CONSTRAINT proof_topic_aliases_not_self CHECK (NOT (topic_id = ANY (aliases))), + CONSTRAINT proof_topic_pin_rlm_check + CHECK (pin_rlm = '' OR pin_rlm ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT proof_topic_pin_experiment_check + CHECK (pin_experiment = '' OR pin_experiment ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT proof_topic_pack_digest_check + CHECK (pack_digest = '' OR pack_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT proof_topic_n_concurrent_pos CHECK (n_concurrent >= 1), + CONSTRAINT proof_topic_config_object CHECK (jsonb_typeof(config) = 'object'), + -- A baseline nobody measured is not a baseline: NaN / ±Infinity are refused + -- here as well as in the bundle schema, because a non-finite value would + -- silently lose every comparison the payout rule makes. + CONSTRAINT proof_topic_sealed_value_finite CHECK ( + sealed_custom_value IS NULL + OR ( + sealed_custom_value <> 'NaN'::float8 + AND sealed_custom_value <> 'Infinity'::float8 + AND sealed_custom_value <> '-Infinity'::float8 + ) + ), + CONSTRAINT proof_topic_schema_version_pos CHECK (schema_version >= 1), + CONSTRAINT proof_topic_bundle_digest_check CHECK (bundle_digest ~ '^sha256:[0-9a-f]{64}$') +); + +-- The only read a later slice needs on the hot path: "the enabled topics". +CREATE INDEX ix_proof_topic_enabled ON proof_topic (enabled, topic_id); + +-- Alias lookup ("is this slug a topic?") is a GIN scan, not a table walk. +CREATE INDEX ix_proof_topic_aliases ON proof_topic USING GIN (aliases); + +GRANT SELECT, INSERT, UPDATE ON TABLE proof_topic TO base_app; diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 578835b53..8e12e8d4e 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -12,11 +12,20 @@ //! //! `challenge_backends` stores operational routing only. It must never gain //! signing-key columns; keys live in owner-signed `config/challenges.toml`. +//! +//! # Proof topics +//! +//! `proof_topic` (migration `0024_proof_topics.sql`) is the per-topic install +//! registry the dynamic-topics work adds: runner id, image and pack pins, +//! concurrency, and the enable flag, keyed by `topic_id`. It is install state, +//! not the scoring contract — that stays the operator-signed document in +//! `proof_topic_version`. See [`topics`]. #![forbid(unsafe_code)] pub mod prism_store; mod store; +pub mod topics; use std::str::FromStr; use std::time::Duration; @@ -34,6 +43,7 @@ pub use store::{ AttestationRecord, EpochBundleRecord, MinerEndpointRow, NewAttestation, NewEpochBundle, NewMinerEndpoint, NewRawWeight, RawWeightRecord, RECEIPT_PK_LEN, }; +pub use topics::{get_topic, list_topics, upsert_topic, NewTopic, TopicRow}; /// Tables that the application role may insert into but never update. pub const APPEND_ONLY_TABLES: &[&str] = diff --git a/crates/db/src/topics.rs b/crates/db/src/topics.rs new file mode 100644 index 000000000..67e4a9e2f --- /dev/null +++ b/crates/db/src/topics.rs @@ -0,0 +1,294 @@ +//! Typed persistence for the Proof topic install registry +//! (`0024_proof_topics.sql`). +//! +//! One row per topic, keyed by `topic_id`. This is install state — which +//! runner, which image and pack pins, how much concurrency, whether the topic +//! is live — not the scoring contract, which stays the operator-signed topic +//! document in `proof_topic_version`. +//! +//! Runtime `sqlx::query` (no compile-time database), matching +//! `proof-rlm-store`: the table's shape lives in the migration and in the +//! `CHECK`s there, and these queries are checked against it in +//! `tests/topics.rs`. +//! +//! Nothing here enables a topic. [`upsert_topic`] writes `enabled = FALSE` on +//! insert and leaves the column untouched on conflict, because installing a +//! topic and opening it are different operator actions; the enable path is a +//! later slice (P1+) and is a fail-closed stub in the CLI today. + +use serde_json::Value; +use sqlx::{PgPool, Row}; + +use crate::DbError; + +/// One `proof_topic` row. +#[derive(Debug, Clone, PartialEq)] +pub struct TopicRow { + /// Topic slug (primary key). + pub topic_id: String, + /// Human label. + pub display_name: String, + /// Install version. + pub version: i32, + /// Install target (`staging` | `metal`). + pub environment: String, + /// In-guest runner id, empty when the topic selects none. + pub runner_id: String, + /// Extra slugs the topic answers to. + pub aliases: Vec, + /// Whether the topic is live. Always `false` in P0. + pub enabled: bool, + /// Opaque per-topic operator config. + pub config: Value, + /// RLM VM image pin (`sha256:`), empty when unpinned. + pub pin_rlm: String, + /// Experiment guest image pin, empty when unpinned. + pub pin_experiment: String, + /// Experiment pack digest, empty when absent. + pub pack_digest: String, + /// Concurrency bound. + pub n_concurrent: i32, + /// Sealed baseline primary, `None` until measured. + pub sealed_custom_value: Option, + /// Install bundle schema version. + pub schema_version: i32, + /// The validated bundle, verbatim. + pub bundle: Value, + /// `sha256:` over the canonical bundle. + pub bundle_digest: String, + /// Row creation instant (RFC 3339, UTC). + pub created_at: String, + /// Last write instant (RFC 3339, UTC). + pub updated_at: String, +} + +/// An install to write. Borrowed so a caller can hand over a parsed plan +/// without cloning it field by field. +#[derive(Debug, Clone)] +pub struct NewTopic<'a> { + /// Topic slug (primary key). + pub topic_id: &'a str, + /// Human label. + pub display_name: &'a str, + /// Install version (`>= 1`). + pub version: i32, + /// Install target (`staging` | `metal`). + pub environment: &'a str, + /// In-guest runner id, empty when none. + pub runner_id: &'a str, + /// Extra slugs. + pub aliases: &'a [String], + /// Opaque per-topic config (must be a JSON object). + pub config: &'a Value, + /// RLM image pin, empty when unpinned. + pub pin_rlm: &'a str, + /// Experiment guest image pin, empty when unpinned. + pub pin_experiment: &'a str, + /// Experiment pack digest, empty when absent. + pub pack_digest: &'a str, + /// Concurrency bound (`>= 1`). + pub n_concurrent: i32, + /// Sealed baseline primary, `None` until measured. + pub sealed_custom_value: Option, + /// Install bundle schema version. + pub schema_version: i32, + /// The validated bundle, verbatim. + pub bundle: &'a Value, + /// `sha256:` over the canonical bundle. + pub bundle_digest: &'a str, +} + +/// Timestamps as RFC 3339 UTC text, so no consumer needs a time crate to +/// print a row and the two columns stay comparable as strings. +const ROW_COLUMNS: &str = "\ + topic_id, display_name, version, environment, runner_id, aliases, enabled, \ + config, pin_rlm, pin_experiment, pack_digest, n_concurrent, sealed_custom_value, \ + schema_version, bundle, bundle_digest, \ + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS created_at, \ + to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS updated_at"; + +fn row_to_topic(row: &sqlx::postgres::PgRow) -> Result { + Ok(TopicRow { + topic_id: row.try_get("topic_id")?, + display_name: row.try_get("display_name")?, + version: row.try_get("version")?, + environment: row.try_get("environment")?, + runner_id: row.try_get("runner_id")?, + aliases: row.try_get("aliases")?, + enabled: row.try_get("enabled")?, + config: row.try_get("config")?, + pin_rlm: row.try_get("pin_rlm")?, + pin_experiment: row.try_get("pin_experiment")?, + pack_digest: row.try_get("pack_digest")?, + n_concurrent: row.try_get("n_concurrent")?, + sealed_custom_value: row.try_get("sealed_custom_value")?, + schema_version: row.try_get("schema_version")?, + bundle: row.try_get("bundle")?, + bundle_digest: row.try_get("bundle_digest")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }) +} + +/// Write one topic install. +/// +/// A re-install of the same `topic_id` replaces the install fields and bumps +/// `updated_at`; `created_at` keeps the first install's instant. `enabled` is +/// set `FALSE` on insert and deliberately **not** touched on conflict: an +/// install is not an enable, and a re-install of a live topic must not +/// silently drop it out of scoring either. The enable/disable path is a later +/// slice. +/// +/// # Errors +/// +/// Propagates sqlx errors, including the row's `CHECK` violations (slug, +/// digest shape, non-finite baseline, empty config, ...). +pub async fn upsert_topic(pool: &PgPool, topic: &NewTopic<'_>) -> Result<(), DbError> { + sqlx::query( + "INSERT INTO proof_topic ( + topic_id, display_name, version, environment, runner_id, aliases, + config, pin_rlm, pin_experiment, pack_digest, n_concurrent, + sealed_custom_value, schema_version, bundle, bundle_digest + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (topic_id) DO UPDATE SET + display_name = EXCLUDED.display_name, + version = EXCLUDED.version, + environment = EXCLUDED.environment, + runner_id = EXCLUDED.runner_id, + aliases = EXCLUDED.aliases, + config = EXCLUDED.config, + pin_rlm = EXCLUDED.pin_rlm, + pin_experiment = EXCLUDED.pin_experiment, + pack_digest = EXCLUDED.pack_digest, + n_concurrent = EXCLUDED.n_concurrent, + sealed_custom_value = EXCLUDED.sealed_custom_value, + schema_version = EXCLUDED.schema_version, + bundle = EXCLUDED.bundle, + bundle_digest = EXCLUDED.bundle_digest, + updated_at = now()", + ) + .bind(topic.topic_id) + .bind(topic.display_name) + .bind(topic.version) + .bind(topic.environment) + .bind(topic.runner_id) + .bind(topic.aliases) + .bind(topic.config) + .bind(topic.pin_rlm) + .bind(topic.pin_experiment) + .bind(topic.pack_digest) + .bind(topic.n_concurrent) + .bind(topic.sealed_custom_value) + .bind(topic.schema_version) + .bind(topic.bundle) + .bind(topic.bundle_digest) + .execute(pool) + .await?; + Ok(()) +} + +/// Every installed topic, ordered by `topic_id`. +/// +/// An empty table is an empty vector, not an error: P0 ships before any topic +/// is installed, and `topic list` has to say so rather than fail. +/// +/// # Errors +/// +/// Propagates sqlx query and decode errors. +pub async fn list_topics(pool: &PgPool) -> Result, DbError> { + let sql = format!("SELECT {ROW_COLUMNS} FROM proof_topic ORDER BY topic_id"); + let rows = sqlx::query(&sql).fetch_all(pool).await?; + rows.iter().map(row_to_topic).collect() +} + +/// One installed topic by slug, or `None`. +/// +/// Looks up `topic_id` only. Aliases are stored for a later slice and are not +/// resolved here, so `show tbench` on a row whose id is `tb4` is a miss — the +/// CLI says so instead of guessing which row was meant. +/// +/// # Errors +/// +/// Propagates sqlx query and decode errors. +pub async fn get_topic(pool: &PgPool, topic_id: &str) -> Result, DbError> { + let sql = format!("SELECT {ROW_COLUMNS} FROM proof_topic WHERE topic_id = $1"); + let row = sqlx::query(&sql) + .bind(topic_id) + .fetch_optional(pool) + .await?; + row.as_ref().map(row_to_topic).transpose() +} + +#[cfg(test)] +mod unit_tests { + use super::*; + + const MIGRATION: &str = include_str!("../migrations/0024_proof_topics.sql"); + + /// The selected columns are what [`row_to_topic`] reads: a column added to + /// one side only is a decode error at runtime, so both lists are pinned. + #[test] + fn the_column_list_covers_every_decoded_field() { + for column in [ + "topic_id", + "display_name", + "version", + "environment", + "runner_id", + "aliases", + "enabled", + "config", + "pin_rlm", + "pin_experiment", + "pack_digest", + "n_concurrent", + "sealed_custom_value", + "schema_version", + "bundle", + "bundle_digest", + "created_at", + "updated_at", + ] { + assert!(ROW_COLUMNS.contains(column), "missing column {column}"); + } + } + + #[test] + fn timestamps_are_formatted_as_utc_rfc3339_text() { + assert!(ROW_COLUMNS.contains("AT TIME ZONE 'UTC'"), "{ROW_COLUMNS}"); + assert!(!ROW_COLUMNS.contains("now()"), "reads never write"); + } + + /// A topic is disabled, never dropped: the app role may write and update + /// the install, and must not be able to delete the row a bundle digest + /// pins. The integration test proves the runtime refusal; this pins the + /// migration's grant without needing a database. + #[test] + fn the_app_role_may_write_and_update_but_never_delete() { + assert!( + MIGRATION.contains("GRANT SELECT, INSERT, UPDATE ON TABLE proof_topic TO base_app;"), + "the install is mutable (enable/disable, re-install, seal)" + ); + for forbidden in [ + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE proof_topic", + "GRANT DELETE ON TABLE proof_topic", + "GRANT ALL ON TABLE proof_topic", + ] { + assert!( + !MIGRATION.contains(forbidden), + "the app role must never delete a topic: {forbidden}" + ); + } + } + + /// Nothing here may enable a topic: the column exists, and the upsert + /// deliberately leaves it alone on conflict. + #[test] + fn installing_never_enables() { + assert!(!ROW_COLUMNS.contains("enabled = TRUE"), "reads never write"); + assert!( + MIGRATION.contains("enabled BOOLEAN NOT NULL DEFAULT FALSE"), + "a fresh install starts disabled" + ); + } +} diff --git a/crates/db/tests/topics.rs b/crates/db/tests/topics.rs new file mode 100644 index 000000000..38b383dfe --- /dev/null +++ b/crates/db/tests/topics.rs @@ -0,0 +1,370 @@ +//! Integration tests for the Proof topic install registry (migration 0024). +//! +//! Runs against an isolated migrated schema when `DATABASE_URL` is set (the +//! same gating as the other `crates/db/tests`) and is skipped otherwise, so +//! default CI without Postgres stays green. +//! +//! Scenarios: +//! - S1 happy: install a topic, read it back, list it +//! - S2 edge: empty table lists as empty; an unknown id is `None`, not an error +//! - S3 edge: re-install replaces install fields, keeps `created_at`, and does +//! not change `enabled` +//! - S4 fail-closed: the table's own `CHECK`s refuse a malformed row +//! - S5 role: `base_app` may write and update, never delete + +#![cfg(feature = "testing")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::too_many_lines)] + +use db::{get_topic, list_topics, upsert_topic, NewTopic, TestPool}; +use serde_json::json; + +/// Returns `false` when `DATABASE_URL` is unset so default CI (no Postgres) skips. +fn database_url_present() -> bool { + std::env::var_os("DATABASE_URL").is_some() +} + +const HEX: &str = "abababababababababababababababababababababababababababababababab"; + +fn digest() -> String { + format!("sha256:{HEX}") +} + +/// Owns every borrowed field so a test can mutate one and still hand the row +/// to [`upsert_topic`] without fighting temporary lifetimes. +struct Fixture { + aliases: Vec, + config: serde_json::Value, + pin_rlm: String, + pin_experiment: String, + pack_digest: String, + bundle: serde_json::Value, + bundle_digest: String, +} + +impl Fixture { + fn new() -> Self { + let digest = digest(); + Self { + aliases: vec!["tbench".to_owned()], + config: json!({}), + pin_rlm: digest.clone(), + pin_experiment: digest.clone(), + pack_digest: digest.clone(), + bundle: json!({ "schema_version": 1, "topic_id": "tb4" }), + bundle_digest: digest, + } + } + + fn row(&self) -> NewTopic<'_> { + NewTopic { + topic_id: "tb4", + display_name: "Terminal-Bench 4", + version: 1, + environment: "metal", + runner_id: "rlm_fc_in_guest_harbor", + aliases: &self.aliases, + config: &self.config, + pin_rlm: &self.pin_rlm, + pin_experiment: &self.pin_experiment, + pack_digest: &self.pack_digest, + n_concurrent: 2, + sealed_custom_value: None, + schema_version: 1, + bundle: &self.bundle, + bundle_digest: &self.bundle_digest, + } + } +} + +/// A runner-less row (the harvest-family shape), for ordering probes. +fn harvest_row<'a>(f: &'a Fixture, topic_id: &'a str) -> NewTopic<'a> { + NewTopic { + topic_id, + runner_id: "", + pack_digest: "", + ..f.row() + } +} + +#[tokio::test] +async fn s1_install_read_back_and_list() { + if !database_url_present() { + return; + } + let tp: TestPool = db::test_pool().await.expect("test_pool"); + let pool = tp.pool(); + + assert!( + list_topics(pool).await.expect("empty list").is_empty(), + "a fresh schema has no topics; that is empty, not an error" + ); + assert!(get_topic(pool, "tb4").await.expect("miss").is_none()); + + let fixture = Fixture::new(); + upsert_topic(pool, &fixture.row()).await.expect("install"); + + let row = get_topic(pool, "tb4").await.expect("get").expect("row"); + assert_eq!(row.topic_id, "tb4"); + assert_eq!(row.display_name, "Terminal-Bench 4"); + assert_eq!(row.version, 1); + assert_eq!(row.environment, "metal"); + assert_eq!(row.runner_id, "rlm_fc_in_guest_harbor"); + assert_eq!(row.aliases, fixture.aliases); + assert!(!row.enabled, "an install never enables a topic"); + assert_eq!(row.config, json!({})); + assert_eq!(row.pin_rlm, fixture.pin_rlm); + assert_eq!(row.pin_experiment, fixture.pin_experiment); + assert_eq!(row.pack_digest, fixture.pack_digest); + assert_eq!(row.n_concurrent, 2); + assert!(row.sealed_custom_value.is_none(), "unsealed stays NULL"); + assert_eq!(row.schema_version, 1); + assert_eq!(row.bundle, fixture.bundle); + assert_eq!(row.bundle_digest, fixture.bundle_digest); + assert!(row.created_at.ends_with('Z'), "{}", row.created_at); + assert!(row.updated_at.ends_with('Z'), "{}", row.updated_at); + assert_eq!(row.created_at, row.updated_at, "one write, one instant"); + + let listed = list_topics(pool).await.expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0], row); + + tp.drop_schema().await.expect("drop"); +} + +#[tokio::test] +async fn s2_list_is_ordered_by_topic_id() { + if !database_url_present() { + return; + } + let tp = db::test_pool().await.expect("test_pool"); + let pool = tp.pool(); + + let fixture = Fixture::new(); + for id in ["zeta", "alpha", "mid"] { + upsert_topic(pool, &harvest_row(&fixture, id)) + .await + .expect("install"); + } + let ids: Vec = list_topics(pool) + .await + .expect("list") + .into_iter() + .map(|r| r.topic_id) + .collect(); + assert_eq!(ids, ["alpha", "mid", "zeta"]); + + tp.drop_schema().await.expect("drop"); +} + +#[tokio::test] +async fn s3_reinstall_replaces_the_install_and_keeps_the_first_created_at() { + if !database_url_present() { + return; + } + let tp = db::test_pool().await.expect("test_pool"); + let pool = tp.pool(); + + let fixture = Fixture::new(); + upsert_topic(pool, &fixture.row()) + .await + .expect("first install"); + let first = get_topic(pool, "tb4").await.expect("get").expect("row"); + + // An operator opens the topic by hand (the enable path is a later slice, + // so the test writes the column directly to prove the re-install rule). + sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") + .execute(pool) + .await + .expect("enable"); + + let next_bundle = json!({ "v": 2 }); + let next_digest = format!("sha256:{}", "cd".repeat(32)); + let second = Fixture { + bundle: next_bundle.clone(), + bundle_digest: next_digest.clone(), + ..Fixture::new() + }; + upsert_topic( + pool, + &NewTopic { + version: 2, + n_concurrent: 4, + sealed_custom_value: Some(0.42), + ..second.row() + }, + ) + .await + .expect("re-install"); + + let row = get_topic(pool, "tb4").await.expect("get").expect("row"); + assert_eq!(row.version, 2, "the install version advances"); + assert_eq!(row.n_concurrent, 4); + assert_eq!(row.sealed_custom_value, Some(0.42)); + assert_eq!(row.bundle, next_bundle); + assert_eq!(row.bundle_digest, next_digest); + assert_eq!( + row.created_at, first.created_at, + "created_at belongs to the first install" + ); + assert!( + row.enabled, + "a re-install must not silently disable a live topic" + ); + assert_eq!( + list_topics(pool).await.expect("list").len(), + 1, + "one row per topic_id" + ); + + tp.drop_schema().await.expect("drop"); +} + +#[tokio::test] +async fn s4_the_schema_refuses_a_malformed_row() { + if !database_url_present() { + return; + } + let tp = db::test_pool().await.expect("test_pool"); + let pool = tp.pool(); + + let fixture = Fixture::new(); + + // Each probe mutates one field into a shape the schema must refuse. + for (label, mutate) in [ + ( + "uppercase topic id", + Box::new(|r: &mut NewTopic<'_>| r.topic_id = "TB4") as Box)>, + ), + ( + "underscore topic id", + Box::new(|r: &mut NewTopic<'_>| r.topic_id = "tb_4"), + ), + ( + "empty display name", + Box::new(|r: &mut NewTopic<'_>| r.display_name = ""), + ), + ( + "zero version", + Box::new(|r: &mut NewTopic<'_>| r.version = 0), + ), + ( + "unknown environment", + Box::new(|r: &mut NewTopic<'_>| r.environment = "prod"), + ), + ( + "zero concurrency", + Box::new(|r: &mut NewTopic<'_>| r.n_concurrent = 0), + ), + ( + "bare-hex pin", + Box::new(|r: &mut NewTopic<'_>| r.pin_rlm = HEX), + ), + ( + "short pack digest", + Box::new(|r: &mut NewTopic<'_>| r.pack_digest = "sha256:abc"), + ), + ( + "bare bundle digest", + Box::new(|r: &mut NewTopic<'_>| r.bundle_digest = HEX), + ), + ( + "non-finite baseline", + Box::new(|r: &mut NewTopic<'_>| r.sealed_custom_value = Some(f64::NAN)), + ), + ( + "zero schema version", + Box::new(|r: &mut NewTopic<'_>| r.schema_version = 0), + ), + ( + "bad runner id", + Box::new(|r: &mut NewTopic<'_>| r.runner_id = "Runner With Spaces"), + ), + ] { + let mut row = fixture.row(); + mutate(&mut row); + upsert_topic(pool, &row) + .await + .expect_err(&format!("{label} must be refused by the schema")); + } + + assert!( + list_topics(pool).await.expect("list").is_empty(), + "no refused probe may leave a row" + ); + + // The alias array is checked element-wise, including self-aliasing. + for (label, alias_list) in [ + ("malformed alias", vec!["Bad Alias".to_owned()]), + ("self alias", vec!["tb4".to_owned()]), + ] { + let bad_aliases = Fixture { + aliases: alias_list, + ..Fixture::new() + }; + let err = upsert_topic(pool, &bad_aliases.row()) + .await + .expect_err(label); + let msg = err.to_string(); + assert!( + msg.contains("aliases") || msg.contains("check constraint"), + "{label}: {msg}" + ); + } + + // A non-object config is refused by the schema too. + let list_config = Fixture { + config: json!([1, 2]), + ..Fixture::new() + }; + upsert_topic(pool, &list_config.row()) + .await + .expect_err("config must be an object"); + + tp.drop_schema().await.expect("drop"); +} + +#[tokio::test] +async fn s5_app_role_writes_and_updates_but_never_deletes() { + if !database_url_present() { + return; + } + let tp = db::test_pool().await.expect("test_pool"); + + let fixture = Fixture::new(); + let app = tp.app_pool().await.expect("app_pool"); + upsert_topic(&app, &fixture.row()) + .await + .expect("app role installs a topic"); + assert!( + get_topic(&app, "tb4").await.expect("get").is_some(), + "app role reads its own install" + ); + + sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") + .execute(&app) + .await + .expect("app role may enable (the enable path is a later slice)"); + + // The shared test harness grants the app role DELETE on every table and + // revokes it only for `APPEND_ONLY_TABLES`. `proof_topic` is mutable but + // deliberately not append-only, so restore the privilege set the migration + // alone grants (SELECT, INSERT, UPDATE) before asserting the refusal. + // That the migration never grants DELETE is pinned without a database in + // `crates/db/src/topics.rs`. + sqlx::query("REVOKE DELETE ON TABLE proof_topic FROM base_app") + .execute(tp.pool()) + .await + .expect("restore the migration's grants"); + + let err = sqlx::query("DELETE FROM proof_topic WHERE topic_id = 'tb4'") + .execute(&app) + .await + .expect_err("a topic is disabled, never dropped"); + let msg = err.to_string(); + assert!( + msg.contains("permission denied") || msg.contains("42501"), + "{msg}" + ); + + tp.drop_schema().await.expect("drop"); +} diff --git a/crates/proof-topic-bundle/Cargo.toml b/crates/proof-topic-bundle/Cargo.toml new file mode 100644 index 000000000..45d1ecc78 --- /dev/null +++ b/crates/proof-topic-bundle/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "proof-topic-bundle" +description = "Proof topic install bundle: the JSON an operator installs a topic from (schema, shape checks, 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" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +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..355b17ba5 --- /dev/null +++ b/crates/proof-topic-bundle/src/lib.rs @@ -0,0 +1,881 @@ +//! Proof **topic install bundle**: the JSON document an operator installs a +//! topic from. +//! +//! A topic's *scoring contract* is its signed topic document (see +//! `proof-task`). The *install* is a separate operator record: which runner +//! the topic names, which RLM and experiment images and which experiment pack +//! it is pinned to, how much concurrency it may use, and whether it is live. +//! This crate is the shape of that record, the checks it has to pass before +//! anything is written, and the canonical digest a later slice can pin. +//! +//! P0 scope (dynamic-topics skeleton): parse, validate, digest, and describe. +//! This crate never touches the database, the network, or the filesystem, and +//! it never enables anything. The CLI that drives it (`bins/proof-admin`) +//! writes a row with `enabled = false` and nothing in this repository reads +//! that row on a scoring path yet — the routes (P1), the allocator (P2), the +//! full install (P3), and the removal of the compiled-in `tbench` bindings +//! (P4) are later slices. +//! +//! Three rules carry the fail-closed posture: +//! +//! - **Unknown keys are refused.** A binding this build does not understand +//! is a binding nothing enforces, so `deny_unknown_fields` rejects it at +//! parse rather than installing a topic that half-works. +//! - **A digest is never invented.** Every pin is `sha256:<64 hex>` or it is +//! absent; absent means "not pinned", which every later slice must read as +//! fail-closed (an unpinned topic never boots), never as a default. +//! - **A runner without a pack is refused.** An in-guest runner with nothing +//! to run is a job that cannot score, so the pair travels together or not +//! at all — the same rule the signed topic's `constraints.params` carries. + +#![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 serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Only accepted `schema_version`. +pub const BUNDLE_SCHEMA_VERSION: u32 = 1; + +/// Longest legal `display_name`. +pub const MAX_DISPLAY_NAME_LEN: usize = 128; + +/// Most aliases one topic may carry. +pub const MAX_ALIASES: usize = 8; + +/// Largest canonical `config` object, in bytes. +pub const MAX_CONFIG_BYTES: usize = 16 * 1024; + +/// Install targets, in the order the CLI offers them. +pub const INSTALL_ENVIRONMENTS: [&str; 2] = ["staging", "metal"]; + +/// 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; 13] = [ + "aliases", + "config", + "display_name", + "environment", + "n_concurrent", + "pack_digest", + "pin_experiment", + "pin_rlm", + "runner_id", + "schema_version", + "sealed_custom_value", + "topic_id", + "version", +]; + +/// Keys with no `serde` default: a bundle that omits one is a parse error +/// naming the field, never an empty string that fails later. +pub const REQUIRED_BUNDLE_KEYS: [&str; 5] = [ + "display_name", + "environment", + "schema_version", + "topic_id", + "version", +]; + +/// Prefix of every pin digest. +pub const DIGEST_PREFIX: &str = "sha256:"; + +/// 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`), which is also the DB value. + #[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, + }, + /// `topic_id` is not `[a-z0-9][a-z0-9-]{1,62}`. + #[error("topic_id {0:?} must match [a-z0-9][a-z0-9-]{{1,62}} (a hyphen slug)")] + BadTopicId(String), + /// `display_name` is empty or oversized. + #[error("display_name must be 1..={MAX_DISPLAY_NAME_LEN} chars")] + BadDisplayName, + /// `version` is zero. + #[error("version must be >= 1")] + BadVersion, + /// More aliases than the bound allows. + #[error("aliases carries {0}, at most {MAX_ALIASES} are allowed")] + TooManyAliases(usize), + /// An alias is not a slug, repeats, or names the topic itself. + #[error("alias {alias:?}: {why}")] + BadAlias { + /// The offending alias. + alias: String, + /// What is wrong. + why: &'static str, + }, + /// `runner_id` is not `[a-z0-9][a-z0-9_-]{1,63}`. + #[error("runner_id {0:?} must match [a-z0-9][a-z0-9_-]{{1,63}}")] + BadRunnerId(String), + /// A pin is not `sha256:<64 hex>`. + #[error("{field} {got:?} is not {DIGEST_PREFIX}<64 lowercase hex>")] + BadDigest { + /// Which pin (`pin_rlm`, `pin_experiment`, `pack_digest`). + field: &'static str, + /// What the bundle said. + got: String, + }, + /// An in-guest runner with no pack to run. + #[error( + "runner_id {runner_id:?} names an in-guest runner, so pack_digest is required \ + (sha256:<64 hex> of the pack tar staged on the KVM host; never invented)" + )] + RunnerWithoutPack { + /// The runner the bundle named. + runner_id: String, + }, + /// A pack nothing runs. + #[error("pack_digest is set but runner_id is absent: a pack no runner reads is dead weight")] + PackWithoutRunner, + /// `n_concurrent` is zero. + #[error("n_concurrent must be >= 1")] + BadConcurrency, + /// `sealed_custom_value` is not finite. + #[error("sealed_custom_value {0} is not finite; a baseline must be a measured number")] + NonFiniteSealedValue(f64), + /// `config` is not a JSON object. + #[error("config must be a JSON object, got {0}")] + ConfigNotObject(&'static str), + /// `config` is larger than the bound. + #[error("config is {0} bytes of canonical JSON, at most {MAX_CONFIG_BYTES} are allowed")] + ConfigTooLarge(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, + }, +} + +/// One topic install bundle, as written by an operator. +/// +/// Required keys are not defaulted, so a missing `topic_id` is a parse error +/// naming the field rather than an empty string that fails later. Optional +/// keys default to the fail-closed reading: no aliases, no runner, no pins, +/// one concurrent job, no sealed baseline, empty config. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TopicInstallBundle { + /// Must equal [`BUNDLE_SCHEMA_VERSION`]. + pub schema_version: u32, + /// Topic slug (`[a-z0-9][a-z0-9-]{1,62}`). The Arch default for the first + /// topic is `tb4`. + pub topic_id: String, + /// Human label for operator output. + pub display_name: String, + /// Monotonic install version for this topic (a re-sign is a new version). + pub version: u32, + /// Install target this bundle was written for. + pub environment: InstallEnvironment, + /// Extra slugs the topic answers to. The Arch default is `["tbench"]` + /// for topic `tb4`, so old miner links resolve to one row. + #[serde(default)] + pub aliases: Vec, + /// In-guest runner id, if the topic selects one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runner_id: Option, + /// `sha256:` of the RLM VM image, or absent when not pinned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pin_rlm: Option, + /// `sha256:` of the experiment guest image, or absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pin_experiment: Option, + /// `sha256:` of the experiment pack tar, or absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pack_digest: Option, + /// Jobs of this topic that may run at once. + #[serde(default = "default_n_concurrent")] + pub n_concurrent: u32, + /// The sealed baseline primary, once measured. Absent until the seal path + /// has a number; a topic with no sealed value cannot be enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sealed_custom_value: Option, + /// Opaque per-topic operator config. Stored verbatim; this crate only + /// checks that it is a bounded JSON object. + #[serde(default = "default_config")] + pub config: Value, +} + +fn default_n_concurrent() -> u32 { + 1 +} + +fn default_config() -> Value { + Value::Object(serde_json::Map::new()) +} + +impl Default for TopicInstallBundle { + fn default() -> Self { + Self { + schema_version: BUNDLE_SCHEMA_VERSION, + topic_id: String::new(), + display_name: String::new(), + version: 1, + environment: InstallEnvironment::Staging, + aliases: Vec::new(), + runner_id: None, + pin_rlm: None, + pin_experiment: None, + pack_digest: None, + n_concurrent: default_n_concurrent(), + sealed_custom_value: None, + config: default_config(), + } + } +} + +/// The resolved install: what a `--dry-run` prints and what a real install +/// writes. `enabled` is always `false` — installing a topic never opens it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TopicInstallPlan { + /// Topic slug (the row's primary key). + pub topic_id: String, + /// Human label. + pub display_name: String, + /// Install version. + pub version: u32, + /// Install target. + pub environment: InstallEnvironment, + /// Extra slugs, sorted and de-duplicated. + pub aliases: Vec, + /// In-guest runner id, empty when the topic selects none. + pub runner_id: String, + /// RLM image pin, empty when unpinned. + pub pin_rlm: String, + /// Experiment guest image pin, empty when unpinned. + pub pin_experiment: String, + /// Experiment pack digest, empty when absent. + pub pack_digest: String, + /// Concurrency bound. + pub n_concurrent: u32, + /// Sealed baseline primary, when the bundle carries one. + pub sealed_custom_value: Option, + /// Bundle schema version. + pub schema_version: u32, + /// The opaque per-topic operator config, verbatim. + pub config: Value, + /// `sha256:` over the canonical bundle. + pub bundle_digest: String, + /// Always `false` on install. A topic is enabled by an operator action + /// that P0 does not implement. + pub enabled: bool, +} + +fn is_digest(s: &str) -> bool { + s.strip_prefix(DIGEST_PREFIX) + .is_some_and(proof_canon::is_hex64) +} + +impl TopicInstallBundle { + /// Parse a bundle body (JSON only). + /// + /// Unknown keys are refused here: a binding this build cannot name is a + /// binding it cannot enforce. + pub fn from_json(body: &str) -> Result { + serde_json::from_str(body).map_err(|e| BundleError::Parse(e.to_string())) + } + + /// Shape checks: ids, pins, cross-field rules, config bounds. + /// + /// Every value checked here is stored as given — nothing is normalised, + /// substituted, or defaulted into existence. + pub fn validate(&self) -> Result<(), BundleError> { + if self.schema_version != BUNDLE_SCHEMA_VERSION { + return Err(BundleError::WrongSchema { + got: self.schema_version, + want: BUNDLE_SCHEMA_VERSION, + }); + } + if !proof_canon::is_slug(&self.topic_id) { + return Err(BundleError::BadTopicId(self.topic_id.clone())); + } + let name = self.display_name.trim(); + if name.is_empty() || name.chars().count() > MAX_DISPLAY_NAME_LEN { + return Err(BundleError::BadDisplayName); + } + if self.version == 0 { + return Err(BundleError::BadVersion); + } + if self.aliases.len() > MAX_ALIASES { + return Err(BundleError::TooManyAliases(self.aliases.len())); + } + for alias in &self.aliases { + let why = if !proof_canon::is_slug(alias) { + "must match [a-z0-9][a-z0-9-]{1,62}" + } else if alias == &self.topic_id { + "an alias of the topic id itself is not an alias" + } else if self.aliases.iter().filter(|a| *a == alias).count() > 1 { + "duplicate alias" + } else { + continue; + }; + return Err(BundleError::BadAlias { + alias: alias.clone(), + why, + }); + } + if let Some(id) = self.runner_id.as_deref() { + if !proof_canon::is_custom_id(id) { + return Err(BundleError::BadRunnerId(id.to_owned())); + } + } + for (field, value) in [ + ("pin_rlm", self.pin_rlm.as_deref()), + ("pin_experiment", self.pin_experiment.as_deref()), + ("pack_digest", self.pack_digest.as_deref()), + ] { + if let Some(v) = value { + if !is_digest(v) { + return Err(BundleError::BadDigest { + field, + got: v.to_owned(), + }); + } + } + } + if self.runner_id.is_some() && self.pack_digest.is_none() { + return Err(BundleError::RunnerWithoutPack { + runner_id: self.runner_id.clone().unwrap_or_default(), + }); + } + if self.pack_digest.is_some() && self.runner_id.is_none() { + return Err(BundleError::PackWithoutRunner); + } + if self.n_concurrent == 0 { + return Err(BundleError::BadConcurrency); + } + if let Some(v) = self.sealed_custom_value { + if !v.is_finite() { + return Err(BundleError::NonFiniteSealedValue(v)); + } + } + match &self.config { + Value::Object(_) => {} + other => { + return Err(BundleError::ConfigNotObject(json_kind(other))); + } + } + let canonical = self.canonical()?; + if canonical.len() > MAX_CONFIG_BYTES { + return Err(BundleError::ConfigTooLarge(canonical.len())); + } + Ok(()) + } + + /// 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()?; + if self.environment != requested { + return Err(BundleError::EnvironmentMismatch { + bundle: self.environment, + requested, + }); + } + let mut aliases = self.aliases.clone(); + aliases.sort_unstable(); + aliases.dedup(); + Ok(TopicInstallPlan { + topic_id: self.topic_id.clone(), + display_name: self.display_name.trim().to_owned(), + version: self.version, + environment: self.environment, + aliases, + runner_id: self.runner_id.clone().unwrap_or_default(), + pin_rlm: self.pin_rlm.clone().unwrap_or_default(), + pin_experiment: self.pin_experiment.clone().unwrap_or_default(), + pack_digest: self.pack_digest.clone().unwrap_or_default(), + n_concurrent: self.n_concurrent, + sealed_custom_value: self.sealed_custom_value, + schema_version: self.schema_version, + config: self.config.clone(), + bundle_digest: self.digest()?, + enabled: false, + }) + } +} + +fn json_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "an array", + Value::Object(_) => "an object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX: &str = "abababababababababababababababababababababababababababababababab"; + + fn digest() -> String { + format!("{DIGEST_PREFIX}{HEX}") + } + + /// The Arch default for the first topic: slug `tb4`, alias `tbench`. + fn tb4() -> TopicInstallBundle { + TopicInstallBundle { + topic_id: "tb4".into(), + display_name: "Terminal-Bench 4".into(), + environment: InstallEnvironment::Metal, + aliases: vec!["tbench".into()], + runner_id: Some("rlm_fc_in_guest_harbor".into()), + pack_digest: Some(digest()), + pin_rlm: Some(digest()), + pin_experiment: Some(digest()), + n_concurrent: 2, + ..TopicInstallBundle::default() + } + } + + #[test] + fn the_arch_default_topic_validates_and_plans_disabled() { + let bundle = tb4(); + bundle.validate().expect("tb4 validates"); + let plan = bundle.plan(InstallEnvironment::Metal).expect("plan"); + assert_eq!(plan.topic_id, "tb4"); + assert_eq!(plan.aliases, ["tbench"]); + assert_eq!(plan.environment, InstallEnvironment::Metal); + assert!(!plan.enabled, "install never enables a topic"); + assert!(plan.bundle_digest.starts_with(DIGEST_PREFIX)); + assert_eq!(plan.bundle_digest.len(), DIGEST_PREFIX.len() + 64); + } + + #[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 unknown_keys_are_refused_at_parse() { + let body = r#"{ + "schema_version": 1, "topic_id": "tb4", "display_name": "x", + "version": 1, "environment": "metal", "task_slice": "tb4-first-15" + }"#; + let err = TopicInstallBundle::from_json(body).expect_err("unknown key"); + assert!( + matches!(err, BundleError::Parse(ref m) if m.contains("task_slice")), + "{err}" + ); + } + + #[test] + fn required_keys_are_named_when_absent() { + for (body, missing) in [ + ( + r#"{"schema_version":1,"display_name":"x","version":1,"environment":"metal"}"#, + "topic_id", + ), + ( + r#"{"schema_version":1,"topic_id":"tb4","version":1,"environment":"metal"}"#, + "display_name", + ), + ( + r#"{"schema_version":1,"topic_id":"tb4","display_name":"x","environment":"metal"}"#, + "version", + ), + ( + r#"{"schema_version":1,"topic_id":"tb4","display_name":"x","version":1}"#, + "environment", + ), + ] { + let err = TopicInstallBundle::from_json(body).expect_err(missing); + assert!( + matches!(err, BundleError::Parse(ref m) if m.contains(missing)), + "{missing}: {err}" + ); + } + } + + #[test] + fn ids_pins_and_bounds_are_checked() { + let mut bundle = tb4(); + bundle.topic_id = "TB4".into(); + assert!(matches!(bundle.validate(), Err(BundleError::BadTopicId(_)))); + bundle = tb4(); + bundle.topic_id = "tbench_tb4".into(); + assert!(matches!(bundle.validate(), Err(BundleError::BadTopicId(_)))); + bundle = tb4(); + bundle.display_name = " ".into(); + assert!(matches!( + bundle.validate(), + Err(BundleError::BadDisplayName) + )); + bundle = tb4(); + bundle.version = 0; + assert!(matches!(bundle.validate(), Err(BundleError::BadVersion))); + bundle = tb4(); + bundle.n_concurrent = 0; + assert!(matches!( + bundle.validate(), + Err(BundleError::BadConcurrency) + )); + bundle = tb4(); + bundle.schema_version = 2; + assert!(matches!( + bundle.validate(), + Err(BundleError::WrongSchema { got: 2, want: 1 }) + )); + } + + #[test] + fn a_digest_is_never_invented() { + for bad in ["", "abc", HEX, "sha256:", "sha256:zz", "sha512:dead"] { + let mut bundle = tb4(); + bundle.pin_rlm = Some(bad.into()); + assert!( + matches!( + bundle.validate(), + Err(BundleError::BadDigest { + field: "pin_rlm", + .. + }) + ), + "{bad:?} must be refused" + ); + } + // Absent is the only alternative to a well-formed digest. + let mut unpinned = tb4(); + unpinned.pin_rlm = None; + unpinned.pin_experiment = None; + unpinned + .validate() + .expect("unpinned is legal, not defaulted"); + } + + #[test] + fn a_runner_and_its_pack_travel_together() { + let mut no_pack = tb4(); + no_pack.pack_digest = None; + let err = no_pack.validate().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}"); + + let mut orphan = tb4(); + orphan.runner_id = None; + assert!(matches!( + orphan.validate(), + Err(BundleError::PackWithoutRunner) + )); + + let mut bad_id = tb4(); + bad_id.runner_id = Some("Runner With Spaces".into()); + assert!(matches!( + bad_id.validate(), + Err(BundleError::BadRunnerId(_)) + )); + + // No runner, no pack: the harvest-family shape is legal. + let mut harvest = tb4(); + harvest.runner_id = None; + harvest.pack_digest = None; + harvest.validate().expect("a topic may select no runner"); + } + + #[test] + fn aliases_are_slugs_unique_and_never_the_topic_id() { + let mut dup = tb4(); + dup.aliases = vec!["tbench".into(), "tbench".into()]; + assert!(matches!( + dup.validate(), + Err(BundleError::BadAlias { + why: "duplicate alias", + .. + }) + )); + + let mut self_alias = tb4(); + self_alias.aliases = vec!["tb4".into()]; + assert!(matches!( + self_alias.validate(), + Err(BundleError::BadAlias { + why: "an alias of the topic id itself is not an alias", + .. + }) + )); + + let mut malformed = tb4(); + malformed.aliases = vec!["TBench".into()]; + assert!(matches!( + malformed.validate(), + Err(BundleError::BadAlias { .. }) + )); + + let mut many = tb4(); + many.aliases = (0..=MAX_ALIASES).map(|i| format!("alias-{i}")).collect(); + assert!(matches!( + many.validate(), + Err(BundleError::TooManyAliases(9)) + )); + + // Order does not matter: the plan sorts and de-duplicates. + let mut two = tb4(); + two.aliases = vec!["zeta".into(), "alpha".into()]; + assert_eq!( + two.plan(InstallEnvironment::Metal).expect("plan").aliases, + ["alpha", "zeta"] + ); + } + + #[test] + fn a_baseline_must_be_finite_and_config_must_be_a_bounded_object() { + let mut nan = tb4(); + nan.sealed_custom_value = Some(f64::NAN); + assert!(matches!( + nan.validate(), + Err(BundleError::NonFiniteSealedValue(_)) + )); + let mut inf = tb4(); + inf.sealed_custom_value = Some(f64::INFINITY); + assert!(matches!( + inf.validate(), + Err(BundleError::NonFiniteSealedValue(_)) + )); + let mut sealed = tb4(); + sealed.sealed_custom_value = Some(0.42); + sealed.validate().expect("a finite baseline is fine"); + + let mut list = tb4(); + list.config = serde_json::json!([1, 2]); + assert!(matches!( + list.validate(), + Err(BundleError::ConfigNotObject("an array")) + )); + let mut huge = tb4(); + huge.config = serde_json::json!({ "pad": "x".repeat(MAX_CONFIG_BYTES) }); + assert!(matches!( + huge.validate(), + Err(BundleError::ConfigTooLarge(_)) + )); + let mut null = tb4(); + null.config = Value::Null; + assert!(matches!( + null.validate(), + Err(BundleError::ConfigNotObject("null")) + )); + } + + #[test] + fn defaults_are_the_fail_closed_reading() { + let body = r#"{ + "schema_version": 1, "topic_id": "tb4", "display_name": "Terminal-Bench 4", + "version": 1, "environment": "staging" + }"#; + let bundle = TopicInstallBundle::from_json(body).expect("parse"); + assert!(bundle.aliases.is_empty()); + assert!(bundle.runner_id.is_none()); + assert!(bundle.pin_rlm.is_none()); + assert!(bundle.pack_digest.is_none()); + assert_eq!(bundle.n_concurrent, 1); + assert!(bundle.sealed_custom_value.is_none()); + assert_eq!(bundle.config, Value::Object(serde_json::Map::new())); + bundle.validate().expect("defaults validate"); + } + + #[test] + fn the_digest_ignores_formatting_and_key_order() { + let compact = r#"{"schema_version":1,"topic_id":"tb4","display_name":"Terminal-Bench 4","version":1,"environment":"metal"}"#; + let spaced = r#"{ + "environment": "metal", + "version": 1, + "display_name": "Terminal-Bench 4", + "topic_id": "tb4", + "schema_version": 1 + }"#; + let a = TopicInstallBundle::from_json(compact).expect("a"); + let b = TopicInstallBundle::from_json(spaced).expect("b"); + assert_eq!(a.digest().expect("digest a"), b.digest().expect("digest b")); + + // Any real change is a different install, so a different digest. + let mut changed = a.clone(); + changed.n_concurrent = 3; + assert_ne!( + a.digest().expect("a"), + changed.digest().expect("changed"), + "a changed bundle must not hash the same" + ); + let mut renamed = a; + renamed.topic_id = "tb5".into(); + assert_ne!( + b.digest().expect("b"), + renamed.digest().expect("renamed"), + "the topic id is part of the identity" + ); + } + + #[test] + fn the_digest_is_stable_and_matches_a_pinned_vector() { + // A literal vector: if the canonical form or the digest algorithm ever + // drifts, this test fails rather than silently re-pinning every topic. + let body = r#"{"schema_version":1,"topic_id":"tb4","display_name":"Terminal-Bench 4","version":1,"environment":"metal"}"#; + let bundle = TopicInstallBundle::from_json(body).expect("parse"); + let canonical = bundle.canonical().expect("canonical"); + assert_eq!( + canonical, + r#"{"aliases":[],"config":{},"display_name":"Terminal-Bench 4","environment":"metal","n_concurrent":1,"schema_version":1,"topic_id":"tb4","version":1}"# + ); + let digest = bundle.digest().expect("digest"); + assert_eq!(digest, format!("{DIGEST_PREFIX}{}", sha256_hex(&canonical))); + } + + fn sha256_hex(s: &str) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(s.as_bytes()); + hex::encode(h.finalize()) + } + + #[test] + fn the_schema_key_list_matches_the_type() { + // A full bundle with every key set: the serialized form must carry + // exactly `BUNDLE_KEYS`, and each one must round-trip. + let bundle = TopicInstallBundle { + sealed_custom_value: Some(0.5), + ..tb4() + }; + let value = serde_json::to_value(&bundle).expect("serialize"); + let mut keys: Vec = value.as_object().expect("object").keys().cloned().collect(); + keys.sort_unstable(); + assert_eq!(keys, BUNDLE_KEYS, "the schema key list drifted"); + for key in REQUIRED_BUNDLE_KEYS { + assert!(keys.iter().any(|k| k == key), "{key} must be required"); + } + } + + #[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#""enabled":false"#), "{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..561593dbd 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`, `topic install [--dry-run]`, `topic list`, `topic show`. Writes a **disabled** row in `proof_topic`; `topic enable` / `disable` / `seal` exit 3 (not implemented in this slice). No route, allocator, or scoring path reads it yet | | `trustroot` | Offline keygen / sign / verify for owner-signed TOML | | `bundle` | SCALE types, seal, verify (`PROTOCOL_VERSION`) | | `aggregate` | Integer aggregation (Hamilton house 65535) | @@ -82,7 +83,7 @@ terminates in the host reverse proxy, not in the gateway process. | `trustroot` (lib) | Load local signed challenges/measurements; dual-accept rotation | | `base-attest-*` | Parse / replay / policy for TDX quotes (bundle measurement pin) | | `crosscheck` / `dissent` | Peer roots and three-outcome policy | -| `db` | Postgres persistence (bundles, evidence, dissent, challenge tables) | +| `db` | Postgres persistence (bundles, evidence, dissent, challenge tables, `proof_topic` install registry) | | `xtask` | loc-cap, consensus-lint, metadata-snapshot, spec / design / external-docs gates | --- diff --git a/docs/COMPLETENESS.md b/docs/COMPLETENESS.md index bbf3aa997..7dd4769f2 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** | `proof_topic` (migration `0024`, shared challenge DB, `topic_id` discriminant) plus `bins/proof-admin` (`topic validate`, `topic install [--dry-run]`, `topic list`, `topic show`). Bundle schema v1 lives in `crates/proof-topic-bundle` (unknown keys refused, pins `sha256:<64 hex>` or absent, an in-guest `runner_id` requires a `pack_digest`). **Installing writes `enabled = false` and no scoring path reads the table**, so this cannot move a score; `topic enable` / `disable` / `seal` exit 3 as not-implemented. No route change (P1), allocator change (P2), full install (P3), or removal of the compiled-in topic bindings (P4). First topic slug `tb4`, alias `tbench` (alias resolution is a later slice). | | 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..bfc207b96 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -195,6 +195,45 @@ 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 signed topic document is the *scoring contract*. The **install** is a +separate operator record: which runner the topic names, which RLM and +experiment images and which experiment pack it is pinned to, how much +concurrency it may use, and whether it is live. `bins/proof-admin` is the +operator CLI for that record, and `proof_topic` +([migration `0024`](../crates/db/migrations/0024_proof_topics.sql)) is where +it lands — one row per `topic_id`, in the shared challenge DB. + +```bash +# Check a bundle. Reads the file; writes nothing; needs no database. +proof-admin topic validate --bundle /root/.base-secrets/proof/tb4.json + +# Resolve an install without touching anything. +proof-admin topic install --bundle …/tb4.json --env metal --dry-run + +# Install it. Writes one DISABLED row; needs BASE_DATABASE_URL (or _FILE). +BASE_DATABASE_URL=… proof-admin topic install --bundle …/tb4.json --env metal +proof-admin topic list +proof-admin topic show tb4 +``` + +`--env` is `staging` or `metal` and must match the bundle's own +`environment`: a bundle written for one target is refused on the other +rather than coerced. Every pin is `sha256:<64 lowercase hex>` or absent, and +an in-guest `runner_id` without a `pack_digest` is refused, so a topic that +names a runner with nothing to run never installs. Unknown keys are refused +at parse: a binding this build cannot name is a binding nothing enforces. + +**P0 scope — what this does not do.** Installing writes `enabled = false` +and no scoring path reads `proof_topic` yet, so an install cannot move a +score. `topic enable`, `topic disable`, and `topic seal` exit **3** with a +"not implemented in this slice" message. There is no route change (P1), no +allocator change (P2), no full install (P3), and no removal of the +compiled-in topic bindings (P4). The first topic slug is **`tb4`** with +alias **`tbench`**; alias resolution is a later slice, so `topic show` +matches the exact `topic_id` today and says so when it misses. + ## Metric families | Family | Primary | Win | From df22cbbf891e94350b0235cd708588352f1f4ceb Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:29:41 +0000 Subject: [PATCH 02/17] fix(proof): address Greptile P0 findings on the topic install path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from the Greptile review of #297, each with a regression test. - digest pins (P1): accept exactly `sha256:` + 64 lowercase hex. The row's CHECK is `^sha256:[0-9a-f]{64}$`, but `proof_canon::is_hex64` trims and accepts uppercase, so `sha256:AB…` or `sha256: ab… ` validated and dry-ran and then failed a real install on the host that matters. A local `is_lower_hex64` checks the one spelling both places agree on. - numeric columns (P1): refuse `version` / `n_concurrent` above `i32::MAX` instead of clamping to it. A clamped row disagreed with the validated, digest-covered bundle an operator reviewed; out of range is now `IntColumnOverflow`, and the CLI's clamp is gone. - NULL aliases (P1): `array_to_string` drops NULL elements, so the joined-string shape check accepted `{tbench,NULL}` — and the typed reader decodes every element as a `String`, so that one row made `topic list` and `topic show` fail for the whole table. `array_position(aliases, NULL)` is the probe that holds, in its own constraint so each one fails for one reason. - reinstall status (P2): report the **persisted** state after install. A re-install deliberately leaves `enabled` alone, so telling an operator (or an automation reading `--json`) that a live topic is disabled was the mistake that produces a surprise on a live host. - config bound (P2): measure the `config` object alone, not the whole canonical bundle, and report that number in the error. Tests: bundle 18 (was 15), db topics 6 (was 5), CLI 14 (was 11) — including a DB-gated reinstall test that enables a row and asserts the re-install reports `enabled: true`. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- Cargo.lock | 1 + bins/proof-admin/Cargo.toml | 6 + bins/proof-admin/src/main.rs | 52 +++++-- bins/proof-admin/tests/cli.rs | 141 +++++++++++++++++ crates/db/migrations/0024_proof_topics.sql | 14 +- crates/db/tests/topics.rs | 53 +++++++ crates/proof-topic-bundle/src/lib.rs | 172 ++++++++++++++++++++- docs/COMPLETENESS.md | 2 +- docs/PROOF.md | 9 ++ 9 files changed, 426 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed8df6cb5..d68e53489 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3690,6 +3690,7 @@ dependencies = [ "proof-topic-bundle", "serde", "serde_json", + "sqlx", "tokio", ] diff --git a/bins/proof-admin/Cargo.toml b/bins/proof-admin/Cargo.toml index ae9ee5d5e..0f33a160f 100644 --- a/bins/proof-admin/Cargo.toml +++ b/bins/proof-admin/Cargo.toml @@ -20,5 +20,11 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +[dev-dependencies] +db = { path = "../../crates/db", features = ["testing"] } +serde_json = "1" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "json"] } +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 index 0f75b2593..5eb829b1f 100644 --- a/bins/proof-admin/src/main.rs +++ b/bins/proof-admin/src/main.rs @@ -303,14 +303,28 @@ async fn cmd_install(opts: &Options, path: &Path, env: &str, dry_run: bool) -> R .await .map_err(|e| Failure::Error(format!("install {}: {e}", plan.topic_id)))?; + // Report the **persisted** state, not the state an install would have + // written. A re-install deliberately leaves `enabled` alone, so a topic + // that was already live stays live — telling an operator it is disabled + // would be exactly the mistake that leads to a surprise on a live host. + let persisted = db::get_topic(&pool, &plan.topic_id) + .await + .map_err(|e| Failure::Error(format!("read back {}: {e}", plan.topic_id)))? + .ok_or_else(|| { + Failure::Error(format!( + "install {} reported success but the row is missing", + plan.topic_id + )) + })?; + if opts.json { let body = serde_json::json!({ "ok": true, "installed": true, - "topic_id": plan.topic_id, - "environment": plan.environment.as_str(), - "bundle_digest": plan.bundle_digest, - "enabled": false, + "topic_id": persisted.topic_id, + "environment": persisted.environment, + "bundle_digest": persisted.bundle_digest, + "enabled": persisted.enabled, }); println!( "{}", @@ -320,20 +334,31 @@ async fn cmd_install(opts: &Options, path: &Path, env: &str, dry_run: bool) -> R } print_plan(&plan); println!(); - println!( - "Installed {} (DISABLED). Enabling is a later slice: nothing scores this topic yet.", - plan.topic_id - ); + if persisted.enabled { + println!( + "Re-installed {} (still ENABLED, unchanged by this install).", + persisted.topic_id + ); + } else { + println!( + "Installed {} (DISABLED). Enabling is a later slice: nothing scores this topic yet.", + persisted.topic_id + ); + } Ok(()) } /// Borrow the plan's fields as the row to write. `bundle` is the file /// verbatim: what an operator reviews is what the row keeps. +/// +/// Every numeric field is already range-checked by +/// [`TopicInstallPlan`]'s validation, so the casts cannot truncate: the bundle +/// refuses a value that would not fit the row rather than clamping it. fn new_topic<'a>(plan: &'a TopicInstallPlan, bundle: &'a serde_json::Value) -> NewTopic<'a> { NewTopic { topic_id: &plan.topic_id, display_name: &plan.display_name, - version: clamp_i32(plan.version), + version: to_i32(plan.version), environment: plan.environment.as_str(), runner_id: &plan.runner_id, aliases: &plan.aliases, @@ -341,17 +366,16 @@ fn new_topic<'a>(plan: &'a TopicInstallPlan, bundle: &'a serde_json::Value) -> N pin_rlm: &plan.pin_rlm, pin_experiment: &plan.pin_experiment, pack_digest: &plan.pack_digest, - n_concurrent: clamp_i32(plan.n_concurrent), + n_concurrent: to_i32(plan.n_concurrent), sealed_custom_value: plan.sealed_custom_value, - schema_version: clamp_i32(plan.schema_version), + schema_version: to_i32(plan.schema_version), bundle, bundle_digest: &plan.bundle_digest, } } -/// A `u32` that fits the row's `INTEGER` columns. The bundle's own bounds keep -/// every value far below `i32::MAX`, so this only guards the cast. -fn clamp_i32(v: u32) -> i32 { +/// A `u32` the bundle's own validation proved fits an `INTEGER` column. +fn to_i32(v: u32) -> i32 { i32::try_from(v).unwrap_or(i32::MAX) } diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs index 85aee2431..9fbb29079 100644 --- a/bins/proof-admin/tests/cli.rs +++ b/bins/proof-admin/tests/cli.rs @@ -86,6 +86,96 @@ fn code(out: &Output) -> i32 { out.status.code().unwrap_or(-1) } +/// Run the binary against a real Postgres URL. +fn run_with_db(args: &[&str], database_url: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_proof-admin")) + .args(args) + .env("BASE_DATABASE_URL", database_url) + .env_remove("BASE_DATABASE_URL_FILE") + .output() + .expect("run proof-admin") +} + +/// Returns `None` when `DATABASE_URL` is unset so default CI (no Postgres) +/// skips, matching the gating in `crates/db/tests`. +fn owner_url() -> Option { + std::env::var("DATABASE_URL") + .ok() + .map(|u| u.trim().to_owned()) + .filter(|u| !u.is_empty()) +} + +/// The reported install state must be the **persisted** state. +/// +/// A re-install deliberately leaves `enabled` alone, so a topic that was +/// already live stays live. An operator (or an automation reading `--json`) +/// told it is disabled would be exactly the mistake that produces a surprise +/// on a live host. +#[tokio::test] +async fn a_reinstall_reports_the_persisted_state_not_a_guess() { + let Some(url) = owner_url() else { + return; + }; + let tp = match db::test_pool_with_url(&url).await { + Ok(tp) => tp, + Err(e) => panic!("test_pool: {e}"), + }; + // The binary talks to this schema through `search_path`, so hand it a URL + // whose connections land in the isolated test schema. + let schema = tp.schema().to_owned(); + let scoped = format!("{url}?options=-c%20search_path%3D{schema}%2Cpublic"); + + let dir = workdir("reinstall"); + let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); + let install = |json: bool| { + let mut args = vec![ + "topic", + "install", + "--bundle", + bundle.to_str().unwrap(), + "--env", + "metal", + ]; + if json { + args.insert(0, "--json"); + } + run_with_db(&args, &scoped) + }; + + // First install: the row does not exist, so it is reported disabled. + let out = install(true); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let first: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); + assert_eq!(first["enabled"], false, "{first}"); + + // An operator enables it (the enable path is a later slice, so the test + // writes the column directly). + sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") + .execute(tp.pool()) + .await + .expect("enable"); + + // Re-install: the row stays enabled, and the output must say so. + let out = install(true); + assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); + let second: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); + assert_eq!( + second["enabled"], true, + "a re-install must report the persisted state: {second}" + ); + + // The human output agrees with the JSON output. + let out = install(false); + let text = stdout(&out); + assert!( + text.contains("still ENABLED") && !text.contains("(DISABLED)"), + "human output must not claim a live topic is disabled:\n{text}" + ); + + let _ = std::fs::remove_dir_all(&dir); + tp.drop_schema().await.expect("drop"); +} + #[test] fn validate_accepts_the_arch_default_bundle_and_writes_nothing() { let dir = workdir("validate-ok"); @@ -347,6 +437,57 @@ fn enable_disable_and_seal_fail_closed_with_exit_3() { } } +#[test] +fn validate_refuses_a_noncanonical_digest_before_an_install_can_fail() { + let dir = workdir("digest-strict"); + // The row's CHECK is `^sha256:[0-9a-f]{64}$`. An uppercase or padded pin + // must be a validate-time reject, not a surprise on the host that matters. + for (label, replacement) in [ + ( + "uppercase hex", + format!("sha256:{}", HEX.to_ascii_uppercase()), + ), + ("padded", format!("sha256: {HEX}")), + ] { + let body = tb4_json("metal").replace(&format!("sha256:{HEX}"), &replacement); + let bundle = write_bundle(&dir, &format!("{}.json", label.replace(' ', "-")), &body); + let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + assert_eq!(code(&out), EXIT_ERROR, "{label}: {}", stderr(&out)); + assert!( + stderr(&out).contains("64 lowercase hex"), + "{label}: stderr={}", + stderr(&out) + ); + } + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn validate_refuses_a_numeric_column_overflow_instead_of_clamping() { + let dir = workdir("overflow"); + // `version` and `n_concurrent` land in INTEGER columns; a value that does + // not fit is a reject, never a silent rewrite of what was validated. + for (label, from, to) in [ + ("version", "\"version\": 1,", "\"version\": 4294967295,"), + ( + "concurrency", + "\"n_concurrent\": 2", + "\"n_concurrent\": 4294967295", + ), + ] { + let body = tb4_json("metal").replace(from, to); + let bundle = write_bundle(&dir, &format!("{label}.json"), &body); + let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + assert_eq!(code(&out), EXIT_ERROR, "{label}: {}", stderr(&out)); + assert!( + stderr(&out).contains("refused rather than clamped"), + "{label}: stderr={}", + stderr(&out) + ); + } + fs::remove_dir_all(&dir).ok(); +} + #[test] fn help_lists_every_p0_subcommand_and_says_what_is_not_implemented() { let out = run(&["topic", "--help"]); diff --git a/crates/db/migrations/0024_proof_topics.sql b/crates/db/migrations/0024_proof_topics.sql index 3eea72a5d..90fbffc69 100644 --- a/crates/db/migrations/0024_proof_topics.sql +++ b/crates/db/migrations/0024_proof_topics.sql @@ -64,11 +64,17 @@ CREATE TABLE proof_topic ( CONSTRAINT proof_topic_environment_check CHECK (environment IN ('staging', 'metal')), CONSTRAINT proof_topic_runner_id_check CHECK (runner_id = '' OR runner_id ~ '^[a-z0-9][a-z0-9_-]{1,63}$'), - -- Aliases are topic slugs too, and a topic is never its own alias. The - -- joined form is the only element-wise regex a CHECK can carry; a stray - -- comma still has to match the slug pattern on both sides, so it cannot - -- smuggle a malformed element in. + -- Aliases are topic slugs too, and a topic is never its own alias. + -- + -- The shape check is element-wise on purpose. `array_to_string` **drops + -- NULL elements**, so a joined-string regex would happily accept + -- `{tbench,NULL}` — and the typed reader decodes every element as a + -- `String`, so that one accepted row would make `topic list` and + -- `topic show` fail for the whole table. `array_position(..., NULL)` is + -- the NULL probe that actually holds; it is separate from the regex so + -- each constraint fails for one reason. CONSTRAINT proof_topic_aliases_bound CHECK (cardinality(aliases) <= 8), + CONSTRAINT proof_topic_aliases_no_null CHECK (array_position(aliases, NULL) IS NULL), CONSTRAINT proof_topic_aliases_shape CHECK ( cardinality(aliases) = 0 OR array_to_string(aliases, ',') ~ '^[a-z0-9][a-z0-9-]{1,62}(,[a-z0-9][a-z0-9-]{1,62})*$' diff --git a/crates/db/tests/topics.rs b/crates/db/tests/topics.rs index 38b383dfe..9ea48d491 100644 --- a/crates/db/tests/topics.rs +++ b/crates/db/tests/topics.rs @@ -323,6 +323,59 @@ async fn s4_the_schema_refuses_a_malformed_row() { tp.drop_schema().await.expect("drop"); } +/// A `NULL` inside the alias array is refused. +/// +/// `array_to_string` drops NULL elements, so a joined-string shape check +/// alone would accept `{tbench,NULL}` — and the typed reader decodes every +/// element as a `String`, so that one row would make `topic list` and +/// `topic show` fail for the whole table rather than just its own row. +#[tokio::test] +async fn s4b_a_null_alias_element_is_refused() { + if !database_url_present() { + return; + } + let tp = db::test_pool().await.expect("test_pool"); + let pool = tp.pool(); + + for label in ["NULL first", "NULL last", "NULL only"] { + let insert = match label { + "NULL first" => "INSERT INTO proof_topic \ + (topic_id, display_name, version, environment, schema_version, bundle, bundle_digest, aliases) \ + VALUES ('tb4', 'x', 1, 'metal', 1, '{}', 'sha256:' || repeat('a', 64), ARRAY[NULL, 'tbench'])", + "NULL last" => "INSERT INTO proof_topic \ + (topic_id, display_name, version, environment, schema_version, bundle, bundle_digest, aliases) \ + VALUES ('tb4', 'x', 1, 'metal', 1, '{}', 'sha256:' || repeat('a', 64), ARRAY['tbench', NULL])", + _ => "INSERT INTO proof_topic \ + (topic_id, display_name, version, environment, schema_version, bundle, bundle_digest, aliases) \ + VALUES ('tb4', 'x', 1, 'metal', 1, '{}', 'sha256:' || repeat('a', 64), ARRAY[NULL])", + }; + let err = sqlx::query(insert).execute(pool).await.expect_err(label); + let msg = err.to_string(); + assert!( + msg.contains("aliases_no_null") || msg.contains("check constraint"), + "{label}: {msg}" + ); + } + + // The shape check alone would have accepted the NULL (it is dropped by + // array_to_string), which is exactly why the separate constraint exists. + let joined: String = + sqlx::query_scalar("SELECT array_to_string(ARRAY['tbench', NULL]::text[], ',')") + .fetch_one(pool) + .await + .expect("array_to_string"); + assert_eq!( + joined, "tbench", + "the NULL is dropped, not caught, by the join" + ); + + assert!( + list_topics(pool).await.expect("list").is_empty(), + "no refused probe may leave a row" + ); + tp.drop_schema().await.expect("drop"); +} + #[tokio::test] async fn s5_app_role_writes_and_updates_but_never_deletes() { if !database_url_present() { diff --git a/crates/proof-topic-bundle/src/lib.rs b/crates/proof-topic-bundle/src/lib.rs index 355b17ba5..559e29da2 100644 --- a/crates/proof-topic-bundle/src/lib.rs +++ b/crates/proof-topic-bundle/src/lib.rs @@ -54,6 +54,14 @@ pub const MAX_ALIASES: usize = 8; /// Largest canonical `config` object, in bytes. pub const MAX_CONFIG_BYTES: usize = 16 * 1024; +/// Largest `version` / `n_concurrent` a bundle may carry. +/// +/// The row's columns are `INTEGER`, so a value above this would have to be +/// clamped on write — and a clamped row would disagree with the validated, +/// digest-covered bundle an operator reviewed. Out of range is a reject, never +/// a silent rewrite. +pub const MAX_INT_COLUMN: u32 = i32::MAX as u32; + /// Install targets, in the order the CLI offers them. pub const INSTALL_ENVIRONMENTS: [&str; 2] = ["staging", "metal"]; @@ -196,6 +204,14 @@ pub enum BundleError { /// `n_concurrent` is zero. #[error("n_concurrent must be >= 1")] BadConcurrency, + /// `version` / `n_concurrent` does not fit the row's `INTEGER` column. + #[error("{field} {got} does not fit the topic row (max {MAX_INT_COLUMN}); refused rather than clamped")] + IntColumnOverflow { + /// Which field. + field: &'static str, + /// What the bundle said. + got: u32, + }, /// `sealed_custom_value` is not finite. #[error("sealed_custom_value {0} is not finite; a baseline must be a measured number")] NonFiniteSealedValue(f64), @@ -333,8 +349,20 @@ pub struct TopicInstallPlan { } fn is_digest(s: &str) -> bool { - s.strip_prefix(DIGEST_PREFIX) - .is_some_and(proof_canon::is_hex64) + 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: a pin is stored here verbatim and the row's `CHECK` is +/// `^sha256:[0-9a-f]{64}$`, so accepting `sha256:AB…` or `sha256: ab… ` would +/// let a bundle validate and dry-run and then fail on a real install. One +/// spelling of a digest, checked the same way in both places. +fn is_lower_hex64(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } impl TopicInstallBundle { @@ -415,6 +443,14 @@ impl TopicInstallBundle { if self.n_concurrent == 0 { return Err(BundleError::BadConcurrency); } + for (field, value) in [ + ("version", self.version), + ("n_concurrent", self.n_concurrent), + ] { + if value > MAX_INT_COLUMN { + return Err(BundleError::IntColumnOverflow { field, got: value }); + } + } if let Some(v) = self.sealed_custom_value { if !v.is_finite() { return Err(BundleError::NonFiniteSealedValue(v)); @@ -426,9 +462,12 @@ impl TopicInstallBundle { return Err(BundleError::ConfigNotObject(json_kind(other))); } } - let canonical = self.canonical()?; - if canonical.len() > MAX_CONFIG_BYTES { - return Err(BundleError::ConfigTooLarge(canonical.len())); + // The bound is on the `config` object, measured on its own canonical + // form: a large-but-legal bundle elsewhere must not be blamed on a + // config that is well inside the limit. + let config_bytes = proof_canon::canonical_json(&self.config).len(); + if config_bytes > MAX_CONFIG_BYTES { + return Err(BundleError::ConfigTooLarge(config_bytes)); } Ok(()) } @@ -851,6 +890,129 @@ mod tests { hex::encode(h.finalize()) } + /// The row's `CHECK` is `^sha256:[0-9a-f]{64}$`, so anything this + /// validator accepts has to be exactly that. Accepting an uppercase or + /// padded pin would let a bundle validate and dry-run and then fail a real + /// install — the operator would find out only on the host that matters. + #[test] + fn digest_pins_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}"), + ] { + for field in ["pin_rlm", "pin_experiment", "pack_digest"] { + let mut bundle = tb4(); + match field { + "pin_rlm" => bundle.pin_rlm = Some(bad.clone()), + "pin_experiment" => bundle.pin_experiment = Some(bad.clone()), + _ => bundle.pack_digest = Some(bad.clone()), + } + assert!( + matches!( + bundle.validate(), + Err(BundleError::BadDigest { field: f, .. }) if f == field + ), + "{field}={bad:?} must be refused, not silently accepted" + ); + } + } + // The canonical spelling still validates, so this is strictness + // rather than a blanket rejection. + tb4().validate().expect("lowercase hex validates"); + assert!(is_lower_hex64(HEX)); + assert!(!is_lower_hex64(&upper)); + assert!(!is_lower_hex64(&format!(" {HEX}"))); + } + + /// The row's columns are `INTEGER`. A value that would not fit is a + /// reject, never a clamp: a clamped row would disagree with the + /// digest-covered bundle the operator reviewed. + #[test] + fn numeric_columns_out_of_range_are_refused_not_clamped() { + let mut big_version = tb4(); + big_version.version = MAX_INT_COLUMN + 1; + let err = big_version.validate().expect_err("version overflow"); + assert!( + matches!( + err, + BundleError::IntColumnOverflow { + field: "version", + got: _ + } + ), + "{err:?}" + ); + assert!( + err.to_string().contains("refused rather than clamped"), + "{err}" + ); + + let mut big_concurrency = tb4(); + big_concurrency.n_concurrent = u32::MAX; + assert!( + matches!( + big_concurrency.validate(), + Err(BundleError::IntColumnOverflow { + field: "n_concurrent", + .. + }) + ), + "n_concurrent overflow" + ); + + // The boundary itself is legal. + let mut at_limit = tb4(); + at_limit.version = MAX_INT_COLUMN; + at_limit.n_concurrent = MAX_INT_COLUMN; + at_limit.validate().expect("i32::MAX fits the column"); + assert_eq!(MAX_INT_COLUMN, i32::MAX as u32); + } + + /// The advertised limit is on the `config` object. A legal bundle with + /// long metadata must not be rejected for a small config, and the error + /// must report the config's own size rather than the bundle's. + #[test] + fn the_config_bound_measures_the_config_not_the_bundle() { + // A small config inside a large-but-legal bundle. + let mut bundle = tb4(); + bundle.display_name = "x".repeat(MAX_DISPLAY_NAME_LEN); + bundle.aliases = (0..MAX_ALIASES).map(|i| format!("alias-{i}")).collect(); + bundle.config = serde_json::json!({ "task_slice": "tb4-first-15" }); + bundle + .validate() + .expect("a small config in a large bundle is fine"); + + // Over the limit is refused, and the number is the config's own size. + let mut over = tb4(); + over.config = serde_json::json!({ "pad": "x".repeat(MAX_CONFIG_BYTES) }); + let err = over.validate().expect_err("oversized config"); + let BundleError::ConfigTooLarge(reported) = err else { + panic!("expected ConfigTooLarge, got {err:?}"); + }; + let config_bytes = proof_canon::canonical_json(&over.config).len(); + assert_eq!( + reported, config_bytes, + "the error must report the config's size" + ); + assert!(config_bytes > MAX_CONFIG_BYTES); + + // Exactly at the limit passes. + let mut at_limit = tb4(); + let overhead = proof_canon::canonical_json(&serde_json::json!({ "pad": "" })).len(); + at_limit.config = serde_json::json!({ "pad": "x".repeat(MAX_CONFIG_BYTES - overhead) }); + assert_eq!( + proof_canon::canonical_json(&at_limit.config).len(), + MAX_CONFIG_BYTES + ); + at_limit.validate().expect("exactly at the limit passes"); + } + #[test] fn the_schema_key_list_matches_the_type() { // A full bundle with every key set: the serialized form must carry diff --git a/docs/COMPLETENESS.md b/docs/COMPLETENESS.md index 7dd4769f2..0d3e9627c 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -81,7 +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** | `proof_topic` (migration `0024`, shared challenge DB, `topic_id` discriminant) plus `bins/proof-admin` (`topic validate`, `topic install [--dry-run]`, `topic list`, `topic show`). Bundle schema v1 lives in `crates/proof-topic-bundle` (unknown keys refused, pins `sha256:<64 hex>` or absent, an in-guest `runner_id` requires a `pack_digest`). **Installing writes `enabled = false` and no scoring path reads the table**, so this cannot move a score; `topic enable` / `disable` / `seal` exit 3 as not-implemented. No route change (P1), allocator change (P2), full install (P3), or removal of the compiled-in topic bindings (P4). First topic slug `tb4`, alias `tbench` (alias resolution is a later slice). | +| Topic installs (dynamic topics P0) | **skeleton** | `proof_topic` (migration `0024`, shared challenge DB, `topic_id` discriminant) plus `bins/proof-admin` (`topic validate`, `topic install [--dry-run]`, `topic list`, `topic show`). Bundle schema v1 lives in `crates/proof-topic-bundle` (unknown keys refused, pins `sha256:<64 lowercase hex>` or absent, an in-guest `runner_id` requires a `pack_digest`, `version` / `n_concurrent` over `i32::MAX` refused rather than clamped). **Installing writes `enabled = false` and no scoring path reads the table**, so this cannot move a score; `topic enable` / `disable` / `seal` exit 3 as not-implemented. No route change (P1), allocator change (P2), full install (P3), or removal of the compiled-in topic bindings (P4). First topic slug `tb4`, alias `tbench` (alias resolution is a later slice). | | 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 bfc207b96..6acc77d57 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -225,6 +225,15 @@ an in-guest `runner_id` without a `pack_digest` is refused, so a topic that names a runner with nothing to run never installs. Unknown keys are refused at parse: a binding this build cannot name is a binding nothing enforces. +A value the row cannot hold is a **reject, never a rewrite**: `version` and +`n_concurrent` above `i32::MAX` are refused rather than clamped (a clamped +row would disagree with the digest-covered bundle an operator reviewed), and +a pin is accepted only in the exact lowercase, unpadded spelling the +column's `CHECK` requires — so a bundle that validates also installs. +Re-installing reports the **persisted** state: a topic that was already live +stays live, and the output says so rather than assuming the install disabled +it. + **P0 scope — what this does not do.** Installing writes `enabled = false` and no scoring path reads `proof_topic` yet, so an install cannot move a score. `topic enable`, `topic disable`, and `topic seal` exit **3** with a From e7be57386fd91a505286708c9f2569f6231df8ef Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:44:43 +0000 Subject: [PATCH 03/17] fix(proof): report install state from the committed write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's re-review of #297: the install committed the upsert and then ran a separate `get_topic` readback. If that read failed the command returned an error for an install that had already landed, so an automation could retry and overwrite a newer concurrent install. `upsert_topic` now returns the row via `RETURNING` (reusing the same column list the reads use, so the write and the read cannot drift apart), and the CLI reports the persisted state from that value: one statement, one outcome. The returned `enabled` is still the persisted one a re-install deliberately leaves alone. Tests: `db` topics 6 → 7 with an explicit "the returned row is the persisted row" case (including a re-install that reports `enabled: true`). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- bins/proof-admin/src/main.rs | 26 +++++++-------- crates/db/src/topics.rs | 56 +++++++++++++++++++------------- crates/db/tests/topics.rs | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 38 deletions(-) diff --git a/bins/proof-admin/src/main.rs b/bins/proof-admin/src/main.rs index 5eb829b1f..2bc3ec9f7 100644 --- a/bins/proof-admin/src/main.rs +++ b/bins/proof-admin/src/main.rs @@ -299,24 +299,20 @@ async fn cmd_install(opts: &Options, path: &Path, env: &str, dry_run: bool) -> R let bundle_value = serde_json::to_value(&bundle) .map_err(|e| Failure::Error(format!("serialize bundle: {e}")))?; let row = new_topic(&plan, &bundle_value); - db::upsert_topic(&pool, &row) + // The write returns the row it committed, so the reported state and the + // write share one outcome: a failure here means the install did not land, + // and a success means the fields below are the persisted ones. A separate + // read afterwards could fail after the commit and tell a caller a + // successful install failed — which is how an automation retries and + // overwrites a newer concurrent install. + // + // The reported `enabled` is the **persisted** one, not the state an + // install would have written: a re-install deliberately leaves the column + // alone, so a topic that was already live stays live. + let persisted = db::upsert_topic(&pool, &row) .await .map_err(|e| Failure::Error(format!("install {}: {e}", plan.topic_id)))?; - // Report the **persisted** state, not the state an install would have - // written. A re-install deliberately leaves `enabled` alone, so a topic - // that was already live stays live — telling an operator it is disabled - // would be exactly the mistake that leads to a surprise on a live host. - let persisted = db::get_topic(&pool, &plan.topic_id) - .await - .map_err(|e| Failure::Error(format!("read back {}: {e}", plan.topic_id)))? - .ok_or_else(|| { - Failure::Error(format!( - "install {} reported success but the row is missing", - plan.topic_id - )) - })?; - if opts.json { let body = serde_json::json!({ "ok": true, diff --git a/crates/db/src/topics.rs b/crates/db/src/topics.rs index 67e4a9e2f..ea7394275 100644 --- a/crates/db/src/topics.rs +++ b/crates/db/src/topics.rs @@ -130,7 +130,7 @@ fn row_to_topic(row: &sqlx::postgres::PgRow) -> Result { }) } -/// Write one topic install. +/// Write one topic install and return the row as persisted. /// /// A re-install of the same `topic_id` replaces the install fields and bumps /// `updated_at`; `created_at` keeps the first install's instant. `enabled` is @@ -139,12 +139,18 @@ fn row_to_topic(row: &sqlx::postgres::PgRow) -> Result { /// silently drop it out of scoring either. The enable/disable path is a later /// slice. /// +/// The write and the reported state are **one statement**: `RETURNING` gives +/// the caller the row it just wrote, including the `enabled` it did not set. +/// A separate read afterwards could fail after the commit and leave a caller +/// believing a successful install failed — which is how an automation +/// retries and overwrites a newer concurrent install. +/// /// # Errors /// /// Propagates sqlx errors, including the row's `CHECK` violations (slug, /// digest shape, non-finite baseline, empty config, ...). -pub async fn upsert_topic(pool: &PgPool, topic: &NewTopic<'_>) -> Result<(), DbError> { - sqlx::query( +pub async fn upsert_topic(pool: &PgPool, topic: &NewTopic<'_>) -> Result { + let sql = format!( "INSERT INTO proof_topic ( topic_id, display_name, version, environment, runner_id, aliases, config, pin_rlm, pin_experiment, pack_digest, n_concurrent, @@ -165,26 +171,28 @@ pub async fn upsert_topic(pool: &PgPool, topic: &NewTopic<'_>) -> Result<(), DbE schema_version = EXCLUDED.schema_version, bundle = EXCLUDED.bundle, bundle_digest = EXCLUDED.bundle_digest, - updated_at = now()", - ) - .bind(topic.topic_id) - .bind(topic.display_name) - .bind(topic.version) - .bind(topic.environment) - .bind(topic.runner_id) - .bind(topic.aliases) - .bind(topic.config) - .bind(topic.pin_rlm) - .bind(topic.pin_experiment) - .bind(topic.pack_digest) - .bind(topic.n_concurrent) - .bind(topic.sealed_custom_value) - .bind(topic.schema_version) - .bind(topic.bundle) - .bind(topic.bundle_digest) - .execute(pool) - .await?; - Ok(()) + updated_at = now() + RETURNING {ROW_COLUMNS}" + ); + let row = sqlx::query(&sql) + .bind(topic.topic_id) + .bind(topic.display_name) + .bind(topic.version) + .bind(topic.environment) + .bind(topic.runner_id) + .bind(topic.aliases) + .bind(topic.config) + .bind(topic.pin_rlm) + .bind(topic.pin_experiment) + .bind(topic.pack_digest) + .bind(topic.n_concurrent) + .bind(topic.sealed_custom_value) + .bind(topic.schema_version) + .bind(topic.bundle) + .bind(topic.bundle_digest) + .fetch_one(pool) + .await?; + row_to_topic(&row) } /// Every installed topic, ordered by `topic_id`. @@ -227,6 +235,8 @@ mod unit_tests { /// The selected columns are what [`row_to_topic`] reads: a column added to /// one side only is a decode error at runtime, so both lists are pinned. + /// The upsert's `RETURNING` reuses the same list, so the write and the + /// read cannot drift apart either. #[test] fn the_column_list_covers_every_decoded_field() { for column in [ diff --git a/crates/db/tests/topics.rs b/crates/db/tests/topics.rs index 9ea48d491..8f191e865 100644 --- a/crates/db/tests/topics.rs +++ b/crates/db/tests/topics.rs @@ -219,6 +219,68 @@ async fn s3_reinstall_replaces_the_install_and_keeps_the_first_created_at() { tp.drop_schema().await.expect("drop"); } +/// The write returns the row it committed. +/// +/// The CLI reports install success from this value, so the write and the +/// reported state must be one statement: a separate read after the commit +/// could fail and tell a caller a successful install failed — which is how an +/// automation retries and overwrites a newer concurrent install. The returned +/// `enabled` is the **persisted** one, which a re-install deliberately leaves +/// alone. +#[tokio::test] +async fn s3b_the_upsert_returns_the_persisted_row() { + if !database_url_present() { + return; + } + let tp = db::test_pool().await.expect("test_pool"); + let pool = tp.pool(); + + let fixture = Fixture::new(); + let inserted = upsert_topic(pool, &fixture.row()).await.expect("install"); + assert_eq!(inserted.topic_id, "tb4"); + assert_eq!(inserted.version, 1); + assert_eq!(inserted.n_concurrent, 2); + assert!( + !inserted.enabled, + "a first install writes (and therefore reports) disabled" + ); + assert_eq!( + inserted, + get_topic(pool, "tb4").await.expect("get").expect("row"), + "the returned row is the persisted row" + ); + + sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") + .execute(pool) + .await + .expect("enable"); + + let reinserted = upsert_topic( + pool, + &NewTopic { + version: 2, + ..fixture.row() + }, + ) + .await + .expect("re-install"); + assert_eq!(reinserted.version, 2, "the write's own version comes back"); + assert!( + reinserted.enabled, + "the returned state is the persisted one, not what an install would write" + ); + assert_eq!( + reinserted.created_at, inserted.created_at, + "created_at still belongs to the first install" + ); + assert_eq!( + reinserted, + get_topic(pool, "tb4").await.expect("get").expect("row") + ); + + tp.drop_schema().await.expect("drop"); +} + #[tokio::test] async fn s4_the_schema_refuses_a_malformed_row() { if !database_url_present() { From 15a4b5495e75dfda4d41cab50036fea0a95f815e Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:17:46 +0000 Subject: [PATCH 04/17] refactor(proof): remap P0 onto the existing admin publish path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arch §11 hardcoding inventory: cortex scoring and its DB are already multi-topic. The hardcoding is branding/docs/FE plus the `tbench-harbor-v1` alias, not a missing topics table. So P0 must wrap the path that already exists, not invent a parallel one. Removed (the parallel registry this slice had added): - migration `0024_proof_topics.sql` and its `proof_topic` table - `db::topics` (upsert/list/get) and its integration tests Those duplicated `proof_topic_version` (migration `0020`), which is already the durable topic table, written by the scoring path. A second table would have been a second source of truth for the same facts. Added, leaning on what exists: - `RlmStore::latest_topics` — a read-only registry **view** over the same `proof_topic_version` rows (`DISTINCT ON (topic_id) … ORDER BY version DESC`), implemented by both stores and pinned by the shared contract test. No new table, no new column, no migration. - `crates/proof-topic-bundle` reworked: the bundle now carries the signed `TopicDocument` verbatim plus a `host` block that must **agree** with it. Runner id, pack digest, and custom id come from the document's own `constraints.params` (read through `proof-experiment::ExperimentBinding`), so every binding has exactly one copy. A host expectation that contradicts the document is a reject, never an override. `pack_dir` is a directory, not a digest. - `bins/proof-admin` reworked: `topic validate` runs the same acceptance `POST /v1/admin/proof/topics` runs (`TopicDocument::validate` + `verify_signature` against `config/proof-pin.toml`), and `install --dry-run` prints that publish call plus the host env. `topic list` / `topic show` read the registry view. A real install is not implemented (exit 3) because publishing needs the operator bearer, which stays on the host; `enable` / `disable` / `seal` are stubs pointing at re-sign + re-publish. KEPT: runner `rlm_fc_in_guest_harbor`, the proof-experiment anti-hardcode locks, and the signed-topic bindings. NOT P0: FE debrand and the `harbor-trials-v1` rename. Tests: bundle 15, CLI 12 (incl. a DB-gated case that persists through the scoring path's own store and reads it back through the CLI), store contract extended to the view. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- Cargo.lock | 8 +- bins/proof-admin/Cargo.toml | 8 +- bins/proof-admin/src/main.rs | 542 +++---- bins/proof-admin/tests/cli.rs | 525 ++++--- crates/db/migrations/0024_proof_topics.sql | 112 -- crates/db/src/lib.rs | 10 - crates/db/src/topics.rs | 304 ---- crates/db/tests/topics.rs | 485 ------ crates/proof-rlm-store/src/lib.rs | 25 + crates/proof-rlm-store/src/memory.rs | 20 +- crates/proof-rlm-store/src/pg.rs | 25 +- .../proof-rlm-store/tests/store_contract.rs | 23 + crates/proof-topic-bundle/Cargo.toml | 4 +- crates/proof-topic-bundle/src/lib.rs | 1332 +++++++++-------- docs/ARCHITECTURE.md | 4 +- docs/COMPLETENESS.md | 2 +- docs/PROOF.md | 82 +- 17 files changed, 1411 insertions(+), 2100 deletions(-) delete mode 100644 crates/db/migrations/0024_proof_topics.sql delete mode 100644 crates/db/src/topics.rs delete mode 100644 crates/db/tests/topics.rs diff --git a/Cargo.lock b/Cargo.lock index d68e53489..643ba84f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3686,11 +3686,15 @@ 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", - "sqlx", "tokio", ] @@ -4039,6 +4043,8 @@ version = "0.1.0" dependencies = [ "hex", "proof-canon", + "proof-experiment", + "proof-task", "serde", "serde_json", "sha2 0.10.9", diff --git a/bins/proof-admin/Cargo.toml b/bins/proof-admin/Cargo.toml index 0f33a160f..fe58da2c2 100644 --- a/bins/proof-admin/Cargo.toml +++ b/bins/proof-admin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proof-admin-bin" -description = "Proof operator CLI: topic install bundle validate / install (dry-run) / list / show, plus fail-closed stubs" +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 @@ -15,15 +15,19 @@ 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" -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "json"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [lints] diff --git a/bins/proof-admin/src/main.rs b/bins/proof-admin/src/main.rs index 2bc3ec9f7..025446edb 100644 --- a/bins/proof-admin/src/main.rs +++ b/bins/proof-admin/src/main.rs @@ -1,24 +1,31 @@ //! `proof-admin` — Proof operator CLI for dynamic topics. //! -//! P0 skeleton of the dynamic-topics admin path. It validates and installs a -//! **topic install bundle** (the JSON record that carries a topic's runner, -//! image and pack pins, concurrency, and enable flag), lists and shows what is -//! installed, and refuses the operations that belong to later slices with a -//! clear "not implemented" rather than a half-working guess. +//! P0 skeleton of the dynamic-topics admin path. It wraps the topic +//! publication procedure that **already exists** in this repository: //! -//! What this binary does **not** do, deliberately: +//! | 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. //! -//! - It never enables a topic. `topic install` writes a row with -//! `enabled = false`; `topic enable` / `topic disable` / `topic seal` are -//! fail-closed stubs (exit code 3) for the later slices. -//! - It touches no route, no allocator, and no scoring path. Nothing in this -//! repository reads `proof_topic` yet, so an install cannot move a score. -//! - It removes none of the compiled-in bindings the current live topic uses; -//! that is the last slice. +//! What this binary does **not** do, deliberately: //! -//! `topic install --dry-run` needs no database at all: it parses, validates, -//! and prints the resolved plan. A real install needs `BASE_DATABASE_URL` -//! (or `BASE_DATABASE_URL_FILE`) and writes one disabled row. +//! - 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. @@ -30,12 +37,13 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use clap::{Parser, Subcommand}; -use db::{NewTopic, PgPool, TopicRow}; +use proof_rlm_store::{MemoryRlmStore, PgRlmStore, RlmStore, TopicVersionRow}; +use proof_task::ProofPin; use proof_topic_bundle::{InstallEnvironment, TopicInstallBundle, TopicInstallPlan}; /// Successful run. const EXIT_OK: u8 = 0; -/// A command failed (bad bundle, database error, ...). +/// A command failed (bad bundle, refused document, database error). const EXIT_ERROR: u8 = 1; /// Bad usage or missing configuration. const EXIT_USAGE: u8 = 2; @@ -47,24 +55,24 @@ const EXIT_NOT_IMPLEMENTED: u8 = 3; #[command( name = "proof-admin", version, - about = "Proof operator CLI: topic install bundles, topic list/show", - long_about = "proof-admin manages Proof topic installs (dynamic-topics P0 skeleton). + 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 without touching anything: - proof-admin topic validate --bundle tb4.json +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 an install without a database: +Resolve the publish call and host env without touching anything: proof-admin topic install --bundle tb4.json --env metal --dry-run -Install it (writes one DISABLED row; enabling is a later slice): - BASE_DATABASE_URL=... proof-admin topic install --bundle tb4.json --env metal +List the installed topics (a read-only view of proof_topic_version): + proof-admin topic list -Nothing here enables a topic, opens a route, or changes how a score is -computed. `topic enable`, `topic disable`, and `topic seal` exit 3 with a -'not implemented in this slice' message." +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. Falls back to `BASE_DATABASE_URL`. + /// 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). @@ -93,14 +101,17 @@ enum Cmd { #[derive(Debug, Subcommand)] enum TopicCmd { - /// Check a topic install bundle. Reads the file, writes nothing. + /// 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, }, - /// Install a topic bundle. `--dry-run` resolves and prints it; a real - /// install writes one disabled row and needs a database. + /// 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")] @@ -108,15 +119,18 @@ enum TopicCmd { /// Install target. Must match the bundle's own `environment`. #[arg(long, value_name = "staging|metal")] env: String, - /// Resolve and print the install plan without touching a database. + /// 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, }, - /// List installed topics. An empty table prints nothing and exits 0. + /// List installed topics: a read-only view of `proof_topic_version`. List, /// Show one installed topic by its exact `topic_id`. Show { - /// Topic slug. Aliases are not resolved in this slice. + /// Topic slug. topic_id: String, }, /// Not implemented in this slice. @@ -139,6 +153,14 @@ enum TopicCmd { }, } +/// 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() @@ -175,13 +197,11 @@ enum Failure { Usage(String), /// A later slice owns this behaviour. NotImplemented(String), - /// Anything else (bad bundle, database error). + /// Anything else (bad bundle, refused document, database error). Error(String), } async fn run(cli: Cli) -> Result<(), Failure> { - // Split the global options from the subcommand so both can be borrowed - // without a partial move of `Cli`. let opts = Options { database_url: cli.database_url, database_url_file: cli.database_url_file, @@ -192,22 +212,15 @@ async fn run(cli: Cli) -> Result<(), Failure> { } } -/// 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, -} - async fn run_topic(opts: &Options, cmd: &TopicCmd) -> Result<(), Failure> { match cmd { - TopicCmd::Validate { bundle } => cmd_validate(opts, bundle), + TopicCmd::Validate { bundle, pin } => cmd_validate(opts, bundle, pin), TopicCmd::Install { bundle, env, + pin, dry_run, - } => cmd_install(opts, bundle, env, *dry_run).await, + } => cmd_install(opts, bundle, env, pin, *dry_run), TopicCmd::List => cmd_list(opts).await, TopicCmd::Show { topic_id } => cmd_show(opts, topic_id).await, TopicCmd::Enable { topic_id } => Err(not_implemented("topic enable", topic_id)), @@ -222,14 +235,13 @@ async fn run_topic(opts: &Options, cmd: &TopicCmd) -> Result<(), Failure> { /// 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: topics table \ - + admin CLI skeleton). Nothing was changed. Enabling, disabling, and sealing a topic \ - are later slices; installing a topic today writes a disabled row that no scoring path \ - reads yet." + "`{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 and validate a bundle file. Shared by `validate` and `install`. +/// 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())))?; @@ -237,155 +249,189 @@ fn load_bundle(path: &Path) -> Result { .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) -> Result<(), Failure> { +fn cmd_validate(opts: &Options, path: &Path, pin_path: &Path) -> Result<(), Failure> { let bundle = load_bundle(path)?; - bundle - .validate() - .map_err(|e| Failure::Error(format!("{}: {e}", path.display())))?; + 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, - "schema_version": bundle.schema_version, - "version": bundle.version, + "topic_id": bundle.topic.id, "environment": bundle.environment.as_str(), - "aliases": bundle.aliases, + "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, }); - println!( - "{}", - serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into()) - ); + print_json(&body)?; return Ok(()); } println!("bundle {} is valid", path.display()); - println!(" topic_id {}", bundle.topic_id); - println!(" schema_version {}", bundle.schema_version); - println!(" version {}", bundle.version); - println!(" environment {}", bundle.environment); - println!(" aliases {}", join_or_dash(&bundle.aliases)); - println!(" bundle_digest {digest}"); + 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!(); - println!("Nothing was written. Install with `proof-admin topic install --bundle … --env …`."); + println!("Checked against {}.", pin_path.display()); + println!( + "Nothing was written. Resolve the publish call with `proof-admin topic install --dry-run`." + ); Ok(()) } -async fn cmd_install(opts: &Options, path: &Path, env: &str, dry_run: bool) -> Result<(), Failure> { +fn cmd_install( + opts: &Options, + path: &Path, + env: &str, + pin_path: &Path, + dry_run: bool, +) -> Result<(), Failure> { let bundle = load_bundle(path)?; let requested = parse_env(env)?; + let pin = load_pin(pin_path)?; let plan = bundle .plan(requested) .map_err(|e| Failure::Error(format!("{}: {e}", path.display())))?; - - if dry_run { - if opts.json { - print_json(&plan)?; - return Ok(()); - } - print_plan(&plan); - println!(); - println!("Dry run: nothing was written and no database was touched."); - return Ok(()); + // 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(), + )); } - let pool = connect(opts).await?; - let bundle_value = serde_json::to_value(&bundle) - .map_err(|e| Failure::Error(format!("serialize bundle: {e}")))?; - let row = new_topic(&plan, &bundle_value); - // The write returns the row it committed, so the reported state and the - // write share one outcome: a failure here means the install did not land, - // and a success means the fields below are the persisted ones. A separate - // read afterwards could fail after the commit and tell a caller a - // successful install failed — which is how an automation retries and - // overwrites a newer concurrent install. - // - // The reported `enabled` is the **persisted** one, not the state an - // install would have written: a re-install deliberately leaves the column - // alone, so a topic that was already live stays live. - let persisted = db::upsert_topic(&pool, &row) - .await - .map_err(|e| Failure::Error(format!("install {}: {e}", plan.topic_id)))?; - if opts.json { - let body = serde_json::json!({ - "ok": true, - "installed": true, - "topic_id": persisted.topic_id, - "environment": persisted.environment, - "bundle_digest": persisted.bundle_digest, - "enabled": persisted.enabled, - }); - println!( - "{}", - serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into()) - ); + print_json(&plan)?; return Ok(()); } - print_plan(&plan); + print_plan(&plan, path, pin_path); println!(); - if persisted.enabled { - println!( - "Re-installed {} (still ENABLED, unchanged by this install).", - persisted.topic_id - ); - } else { - println!( - "Installed {} (DISABLED). Enabling is a later slice: nothing scores this topic yet.", - persisted.topic_id - ); - } + println!("Dry run: nothing was written and no host was touched."); Ok(()) } -/// Borrow the plan's fields as the row to write. `bundle` is the file -/// verbatim: what an operator reviews is what the row keeps. -/// -/// Every numeric field is already range-checked by -/// [`TopicInstallPlan`]'s validation, so the casts cannot truncate: the bundle -/// refuses a value that would not fit the row rather than clamping it. -fn new_topic<'a>(plan: &'a TopicInstallPlan, bundle: &'a serde_json::Value) -> NewTopic<'a> { - NewTopic { - topic_id: &plan.topic_id, - display_name: &plan.display_name, - version: to_i32(plan.version), - environment: plan.environment.as_str(), - runner_id: &plan.runner_id, - aliases: &plan.aliases, - config: &plan.config, - pin_rlm: &plan.pin_rlm, - pin_experiment: &plan.pin_experiment, - pack_digest: &plan.pack_digest, - n_concurrent: to_i32(plan.n_concurrent), - sealed_custom_value: plan.sealed_custom_value, - schema_version: to_i32(plan.schema_version), - bundle, - bundle_digest: &plan.bundle_digest, +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()); + println!(); + println!("1) Publish the signed document (existing route, operator bearer):"); + println!( + " curl -sS -X {} \\", + plan.publish_route.split(' ').next().unwrap_or("POST") + ); + println!(" -H \"Authorization: Bearer $PROOF_ADMIN_TOKEN\" \\"); + println!(" -H 'content-type: application/json' \\"); + println!( + " --data-binary @{} \\", + signed_document_hint(bundle_path) + ); + println!(" /challenge/proof/v1/admin/proof/topics"); + println!(); + if plan.host_env.is_empty() { + println!("2) Host env: nothing extra is required for this topic."); + } else { + println!("2) Set these on the master before the topic can score:"); + for var in &plan.host_env { + println!(" {}={}", var.name, var.value); + println!(" # {}", var.why); + } } } -/// A `u32` the bundle's own validation proved fits an `INTEGER` column. -fn to_i32(v: u32) -> i32 { - i32::try_from(v).unwrap_or(i32::MAX) +/// Where the signed document is expected to live, given the bundle path. +/// +/// The bundle carries the document inline; the publish call posts the document +/// itself, so the hint names the bundle and lets the operator extract it. This +/// never invents a path that does not exist. +fn signed_document_hint(bundle_path: &Path) -> String { + format!("", bundle_path.display()) } async fn cmd_list(opts: &Options) -> Result<(), Failure> { - let pool = connect(opts).await?; - let rows = db::list_topics(&pool) + 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(); - println!( - "{}", - serde_json::to_string_pretty(&body).unwrap_or_else(|_| "[]".into()) - ); + print_json(&body)?; return Ok(()); } if rows.is_empty() { @@ -397,21 +443,26 @@ async fn cmd_list(opts: &Options) -> Result<(), Failure> { println!(" {}", summarize(row)); } println!(); - println!("Nothing is enabled by this CLI in this slice; see `proof-admin topic --help`."); + 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 pool = connect(opts).await?; - let row = db::get_topic(&pool, topic_id) + let store = open_store(opts).await?; + let row = store + .latest_topic(topic_id) .await .map_err(|e| Failure::Error(format!("show {topic_id}: {e}")))?; - let Some(row) = row else { + let Some((version, document)) = row else { return Err(Failure::Error(format!( - "no installed topic {topic_id:?}. Aliases are not resolved in this slice; \ - use `proof-admin topic list` to see the exact ids." + "no installed topic {topic_id:?}. Use `proof-admin topic list` to see the exact ids." ))); }; + let row = TopicVersionRow { + topic_id: topic_id.to_owned(), + version, + document, + }; if opts.json { print_json(&topic_json(&row))?; return Ok(()); @@ -420,19 +471,34 @@ async fn cmd_show(opts: &Options, topic_id: &str) -> Result<(), Failure> { Ok(()) } -/// Open the database, or explain which variable to set. -async fn connect(opts: &Options) -> Result { - let url = database_url(opts)?; - db::connect(&url) +/// 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}"))) + .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 { +fn database_url(opts: &Options) -> Result, Failure> { let value = opts .database_url .as_deref() @@ -443,7 +509,7 @@ fn database_url(opts: &Options) -> Result { (Some(_), Some(_)) => Err(Failure::Usage( "set BASE_DATABASE_URL or BASE_DATABASE_URL_FILE, not both".into(), )), - (Some(url), None) => Ok(url.to_owned()), + (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())))?; @@ -451,102 +517,72 @@ fn database_url(opts: &Options) -> Result { if trimmed.is_empty() { return Err(Failure::Usage(format!("{} is empty", path.display()))); } - Ok(trimmed.to_owned()) + Ok(Some(trimmed.to_owned())) } - (None, None) => Err(Failure::Usage( - "this command needs a database: set BASE_DATABASE_URL (or BASE_DATABASE_URL_FILE). \ - `topic validate` and `topic install --dry-run` need no database." - .into(), - )), + (None, None) => Ok(None), } } -fn print_plan(plan: &TopicInstallPlan) { - println!("topic install plan"); - println!(" topic_id {}", plan.topic_id); - println!(" display_name {}", plan.display_name); - println!(" version {}", plan.version); - println!(" environment {}", plan.environment); - println!(" aliases {}", join_or_dash(&plan.aliases)); - println!(" runner_id {}", dash_if_empty(&plan.runner_id)); - println!(" pin_rlm {}", dash_if_empty(&plan.pin_rlm)); +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!( - " pin_experiment {}", - dash_if_empty(&plan.pin_experiment) + " custom_id {}", + dash_if_empty(&doc.metric.custom_id) ); - println!(" pack_digest {}", dash_if_empty(&plan.pack_digest)); - println!(" n_concurrent {}", plan.n_concurrent); + println!(" payout_mode {}", doc.payout_mode.as_str()); + println!(" valid_from_epoch {}", doc.valid_from_epoch); println!( - " sealed_custom_value {}", - plan.sealed_custom_value - .map_or_else(|| "-".to_owned(), |v| v.to_string()) + " valid_until_epoch {}", + doc.valid_until_epoch + .map_or_else(|| "-".to_owned(), |e| e.to_string()) ); - println!(" schema_version {}", plan.schema_version); - println!(" bundle_digest {}", plan.bundle_digest); + println!(" baseline_sealed {}", doc.baseline.is_sealed()); println!( - " enabled {} (install never enables)", - plan.enabled + " signature {}…", + doc.signature.get(..16).unwrap_or(doc.signature.as_str()) ); -} - -fn print_row(row: &TopicRow) { - println!("topic {}", row.topic_id); - println!(" display_name {}", row.display_name); - println!(" version {}", row.version); - println!(" environment {}", row.environment); - println!(" aliases {}", join_or_dash(&row.aliases)); - println!(" enabled {}", row.enabled); - println!(" runner_id {}", dash_if_empty(&row.runner_id)); - println!(" pin_rlm {}", dash_if_empty(&row.pin_rlm)); - println!(" pin_experiment {}", dash_if_empty(&row.pin_experiment)); - println!(" pack_digest {}", dash_if_empty(&row.pack_digest)); - println!(" n_concurrent {}", row.n_concurrent); - println!( - " sealed_custom_value {}", - row.sealed_custom_value - .map_or_else(|| "-".to_owned(), |v| v.to_string()) - ); - println!(" schema_version {}", row.schema_version); - println!(" bundle_digest {}", row.bundle_digest); - println!(" config {}", compact(&row.config)); - println!(" created_at {}", row.created_at); - println!(" updated_at {}", row.updated_at); + println!(); + println!("The signed document is the source of truth; this view reads it verbatim."); } /// One-line summary for `topic list`. -fn summarize(row: &TopicRow) -> String { - let state = if row.enabled { "enabled" } else { "disabled" }; - let runner = dash_if_empty(&row.runner_id); - let aliases = if row.aliases.is_empty() { - String::new() - } else { - format!(" (aliases: {})", row.aliases.join(", ")) - }; +fn summarize(row: &TopicVersionRow) -> String { + let doc = &row.document; format!( - "{:<24} v{:<3} {:<7} {:<8} runner={}{}", - row.topic_id, row.version, row.environment, state, runner, aliases + "{:<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) ) } -fn topic_json(row: &TopicRow) -> serde_json::Value { +/// 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, - "display_name": row.display_name, "version": row.version, - "environment": row.environment, - "aliases": row.aliases, - "enabled": row.enabled, - "runner_id": row.runner_id, - "pin_rlm": row.pin_rlm, - "pin_experiment": row.pin_experiment, - "pack_digest": row.pack_digest, - "n_concurrent": row.n_concurrent, - "sealed_custom_value": row.sealed_custom_value, - "schema_version": row.schema_version, - "bundle_digest": row.bundle_digest, - "config": row.config, - "created_at": row.created_at, - "updated_at": row.updated_at, + "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, }) } @@ -556,22 +592,10 @@ fn print_json(value: &T) -> Result<(), Failure> { Ok(()) } -fn join_or_dash(items: &[String]) -> String { - if items.is_empty() { - "-".to_owned() - } else { - items.join(", ") - } -} - fn dash_if_empty(s: &str) -> String { - if s.is_empty() { + if s.trim().is_empty() { "-".to_owned() } else { s.to_owned() } } - -fn compact(value: &serde_json::Value) -> String { - serde_json::to_string(value).unwrap_or_else(|_| "{}".into()) -} diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs index 9fbb29079..2e4bd8223 100644 --- a/bins/proof-admin/tests/cli.rs +++ b/bins/proof-admin/tests/cli.rs @@ -1,14 +1,15 @@ //! Process-level tests for `proof-admin` (dynamic-topics P0). //! -//! Everything here runs without a database, a network, or a metal key: -//! `topic validate` and `topic install --dry-run` are the two commands P0 -//! promises to make work end to end, and the stubs must fail closed with -//! exit code 3 rather than doing something partial. +//! 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. //! -//! The real install path is covered against Postgres in -//! `crates/db/tests/topics.rs` (schema and upsert) and by the same -//! `upsert_topic` call this binary makes; these tests assert that the binary -//! never *reaches* a database unless it was asked to and given one. +//! 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)] @@ -16,15 +17,13 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; -/// Exit code for a failure (bad bundle, missing database, ...). +/// 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; -const HEX: &str = "abababababababababababababababababababababababababababababababab"; - fn workdir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "proof-admin-{}-{tag}-{}", @@ -37,32 +36,12 @@ fn workdir(tag: &str) -> PathBuf { dir } -fn write_bundle(dir: &Path, name: &str, body: &str) -> PathBuf { +fn write_file(dir: &Path, name: &str, body: &str) -> PathBuf { let path = dir.join(name); - fs::write(&path, body).expect("write bundle"); + fs::write(&path, body).expect("write file"); path } -/// The Arch default bundle: slug `tb4`, alias `tbench`. -fn tb4_json(environment: &str) -> String { - format!( - r#"{{ - "schema_version": 1, - "topic_id": "tb4", - "display_name": "Terminal-Bench 4", - "version": 1, - "environment": "{environment}", - "aliases": ["tbench"], - "runner_id": "rlm_fc_in_guest_harbor", - "pin_rlm": "sha256:{HEX}", - "pin_experiment": "sha256:{HEX}", - "pack_digest": "sha256:{HEX}", - "n_concurrent": 2, - "config": {{"task_slice": "tb4-first-15"}} -}}"# - ) -} - /// 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 { @@ -86,109 +65,132 @@ fn code(out: &Output) -> i32 { out.status.code().unwrap_or(-1) } -/// Run the binary against a real Postgres URL. -fn run_with_db(args: &[&str], database_url: &str) -> Output { - Command::new(env!("CARGO_BIN_EXE_proof-admin")) - .args(args) - .env("BASE_DATABASE_URL", database_url) - .env_remove("BASE_DATABASE_URL_FILE") - .output() - .expect("run proof-admin") -} - -/// Returns `None` when `DATABASE_URL` is unset so default CI (no Postgres) -/// skips, matching the gating in `crates/db/tests`. -fn owner_url() -> Option { - std::env::var("DATABASE_URL") - .ok() - .map(|u| u.trim().to_owned()) - .filter(|u| !u.is_empty()) -} - -/// The reported install state must be the **persisted** state. +/// A bundle whose signed document matches `pin_body`'s key, so `validate` +/// exercises the real acceptance path. /// -/// A re-install deliberately leaves `enabled` alone, so a topic that was -/// already live stays live. An operator (or an automation reading `--json`) -/// told it is disabled would be exactly the mistake that produces a surprise -/// on a live host. -#[tokio::test] -async fn a_reinstall_reports_the_persisted_state_not_a_guess() { - let Some(url) = owner_url() else { - return; - }; - let tp = match db::test_pool_with_url(&url).await { - Ok(tp) => tp, - Err(e) => panic!("test_pool: {e}"), - }; - // The binary talks to this schema through `search_path`, so hand it a URL - // whose connections land in the isolated test schema. - let schema = tp.schema().to_owned(); - let scoped = format!("{url}?options=-c%20search_path%3D{schema}%2Cpublic"); - - let dir = workdir("reinstall"); - let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); - let install = |json: bool| { - let mut args = vec![ - "topic", - "install", - "--bundle", - bundle.to_str().unwrap(), - "--env", - "metal", - ]; - if json { - args.insert(0, "--json"); - } - run_with_db(&args, &scoped) +/// 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, }; - // First install: the row does not exist, so it is reported disabled. - let out = install(true); - assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); - let first: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); - assert_eq!(first["enabled"], false, "{first}"); - - // An operator enables it (the enable path is a later slice, so the test - // writes the column directly). - sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") - .execute(tp.pool()) - .await - .expect("enable"); + /// 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 + } - // Re-install: the row stays enabled, and the output must say so. - let out = install(true); - assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); - let second: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); - assert_eq!( - second["enabled"], true, - "a re-install must report the persisted state: {second}" - ); + 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) + ) + } - // The human output agrees with the JSON output. - let out = install(false); - let text = stdout(&out); - assert!( - text.contains("still ENABLED") && !text.contains("(DISABLED)"), - "human output must not claim a live topic is disabled:\n{text}" - ); + /// 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 + } - let _ = std::fs::remove_dir_all(&dir); - tp.drop_schema().await.expect("drop"); + /// 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" + } + }); + serde_json::to_string_pretty(&bundle).expect("json") + } } #[test] fn validate_accepts_the_arch_default_bundle_and_writes_nothing() { let dir = workdir("validate-ok"); - let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); - let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + 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", - "aliases tbench", - "bundle_digest sha256:", + "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}"); @@ -196,18 +198,35 @@ fn validate_accepts_the_arch_default_bundle_and_writes_nothing() { 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_reports_the_offending_key_and_writes_nothing() { - let dir = workdir("validate-bad"); - // An unknown key is refused rather than ignored: a binding this build - // cannot name is a binding nothing enforces. - let unknown = tb4_json("metal").replace("\"version\": 1,", "\"task_slice\": \"x\","); - let bundle = write_bundle(&dir, "unknown.json", &unknown); - let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); +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("task_slice"), - "stderr must name the unknown key: {}", + stderr(&out).contains("signature"), + "stderr must name the signature: {}", stderr(&out) ); assert!( @@ -215,40 +234,64 @@ fn validate_reports_the_offending_key_and_writes_nothing() { "a failure prints nothing to stdout" ); - // A runner with no pack cannot be installed, so it does not validate. - let no_pack = tb4_json("metal").replace(&format!(",\n \"pack_digest\": \"sha256:{HEX}\""), ""); - let bundle = write_bundle(&dir, "no-pack.json", &no_pack); - let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + // 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("pack_digest is required"), - "stderr={}", + stderr(&out).contains("runner_id"), + "stderr must name the unknown key: {}", stderr(&out) ); - // An invented digest is refused by name. - let bad_digest = tb4_json("metal").replace(&format!("sha256:{HEX}"), "sha256:abc"); - let bundle = write_bundle(&dir, "bad-digest.json", &bad_digest); - let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); + // 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("64 lowercase hex"), + 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_bundle(&dir, "tb4.json", &tb4_json("metal")); + 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 = @@ -256,7 +299,8 @@ fn validate_json_output_is_machine_readable() { assert_eq!(parsed["ok"], true); assert_eq!(parsed["topic_id"], "tb4"); assert_eq!(parsed["environment"], "metal"); - assert_eq!(parsed["aliases"][0], "tbench"); + assert_eq!(parsed["custom_id"], "tbench"); + assert_eq!(parsed["runner_id"], "rlm_fc_in_guest_harbor"); assert!( parsed["bundle_digest"] .as_str() @@ -267,10 +311,13 @@ fn validate_json_output_is_machine_readable() { 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_resolves_a_disabled_plan_without_a_database() { +fn dry_run_install_prints_the_existing_publish_call_and_host_env() { let dir = workdir("dry-run"); - let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); + 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", @@ -278,6 +325,8 @@ fn dry_run_install_resolves_a_disabled_plan_without_a_database() { bundle.to_str().unwrap(), "--env", "metal", + "--pin", + pin.to_str().unwrap(), "--dry-run", ]); assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); @@ -286,21 +335,37 @@ fn dry_run_install_resolves_a_disabled_plan_without_a_database() { "topic install plan", "topic_id tb4", "environment metal", - "aliases tbench", + "custom_id tbench", "runner_id rlm_fc_in_guest_harbor", - "n_concurrent 2", - "enabled false", - "nothing was written and no database was touched", + "Publish the signed document (existing route, operator bearer)", + "/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}" + ); 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_bundle(&dir, "tb4.json", &tb4_json("staging")); + 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", @@ -309,6 +374,8 @@ fn dry_run_install_json_matches_the_plan_shape() { bundle.to_str().unwrap(), "--env", "staging", + "--pin", + pin.to_str().unwrap(), "--dry-run", ]); assert_eq!(code(&out), 0, "stderr={}", stderr(&out)); @@ -316,9 +383,14 @@ fn dry_run_install_json_matches_the_plan_shape() { 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["enabled"], false, "a plan never enables"); - assert_eq!(parsed["n_concurrent"], 2); - assert_eq!(parsed["schema_version"], 1); + 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(); } @@ -326,7 +398,8 @@ fn dry_run_install_json_matches_the_plan_shape() { #[test] fn install_refuses_an_environment_the_bundle_does_not_declare() { let dir = workdir("env-mismatch"); - let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); + 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", @@ -334,6 +407,8 @@ fn install_refuses_an_environment_the_bundle_does_not_declare() { bundle.to_str().unwrap(), "--env", "staging", + "--pin", + pin.to_str().unwrap(), "--dry-run", ]); assert_eq!(code(&out), EXIT_ERROR, "stderr={}", stderr(&out)); @@ -345,10 +420,13 @@ fn install_refuses_an_environment_the_bundle_does_not_declare() { 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_without_a_database_is_a_usage_error_not_a_write() { - let dir = workdir("no-db"); - let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); +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", @@ -356,13 +434,14 @@ fn a_real_install_without_a_database_is_a_usage_error_not_a_write() { bundle.to_str().unwrap(), "--env", "metal", + "--pin", + pin.to_str().unwrap(), ]); - assert_eq!(code(&out), EXIT_USAGE, "stderr={}", stderr(&out)); - assert!( - stderr(&out).contains("needs a database"), - "stderr={}", - stderr(&out) - ); + 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(); } @@ -382,17 +461,11 @@ fn read_commands_without_a_database_are_usage_errors() { #[test] fn database_url_and_file_are_mutually_exclusive() { let dir = workdir("db-url-both"); - let bundle = write_bundle(&dir, "tb4.json", &tb4_json("metal")); - let url_file = dir.join("url.txt"); - fs::write(&url_file, "postgres://example/db").expect("write url file"); + let url_file = write_file(&dir, "url.txt", "postgres://example/db"); let out = Command::new(env!("CARGO_BIN_EXE_proof-admin")) .args([ "topic", - "install", - "--bundle", - bundle.to_str().unwrap(), - "--env", - "metal", + "list", "--database-url", "postgres://example/other", "--database-url-file", @@ -437,57 +510,6 @@ fn enable_disable_and_seal_fail_closed_with_exit_3() { } } -#[test] -fn validate_refuses_a_noncanonical_digest_before_an_install_can_fail() { - let dir = workdir("digest-strict"); - // The row's CHECK is `^sha256:[0-9a-f]{64}$`. An uppercase or padded pin - // must be a validate-time reject, not a surprise on the host that matters. - for (label, replacement) in [ - ( - "uppercase hex", - format!("sha256:{}", HEX.to_ascii_uppercase()), - ), - ("padded", format!("sha256: {HEX}")), - ] { - let body = tb4_json("metal").replace(&format!("sha256:{HEX}"), &replacement); - let bundle = write_bundle(&dir, &format!("{}.json", label.replace(' ', "-")), &body); - let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); - assert_eq!(code(&out), EXIT_ERROR, "{label}: {}", stderr(&out)); - assert!( - stderr(&out).contains("64 lowercase hex"), - "{label}: stderr={}", - stderr(&out) - ); - } - fs::remove_dir_all(&dir).ok(); -} - -#[test] -fn validate_refuses_a_numeric_column_overflow_instead_of_clamping() { - let dir = workdir("overflow"); - // `version` and `n_concurrent` land in INTEGER columns; a value that does - // not fit is a reject, never a silent rewrite of what was validated. - for (label, from, to) in [ - ("version", "\"version\": 1,", "\"version\": 4294967295,"), - ( - "concurrency", - "\"n_concurrent\": 2", - "\"n_concurrent\": 4294967295", - ), - ] { - let body = tb4_json("metal").replace(from, to); - let bundle = write_bundle(&dir, &format!("{label}.json"), &body); - let out = run(&["topic", "validate", "--bundle", bundle.to_str().unwrap()]); - assert_eq!(code(&out), EXIT_ERROR, "{label}: {}", stderr(&out)); - assert!( - stderr(&out).contains("refused rather than clamped"), - "{label}: stderr={}", - stderr(&out) - ); - } - fs::remove_dir_all(&dir).ok(); -} - #[test] fn help_lists_every_p0_subcommand_and_says_what_is_not_implemented() { let out = run(&["topic", "--help"]); @@ -508,3 +530,62 @@ fn help_lists_every_p0_subcommand_and_says_what_is_not_implemented() { "the stubs must say so in help:\n{body}" ); } + +/// 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/crates/db/migrations/0024_proof_topics.sql b/crates/db/migrations/0024_proof_topics.sql deleted file mode 100644 index 90fbffc69..000000000 --- a/crates/db/migrations/0024_proof_topics.sql +++ /dev/null @@ -1,112 +0,0 @@ --- Proof topics: the installed-topic registry (dynamic-topics P0 skeleton). --- --- Until now a Proof topic existed only as a signed document (`proof_topic_version`, --- migration 0020) plus a set of host env vars. The dynamic-topics work moves the --- per-topic bindings — which runner, which RLM image, which experiment pack, how --- much concurrency, whether the topic is live — into the shared challenge DB, --- keyed by `topic_id`. This table is that home. P0 lands the table and the admin --- CLI skeleton; nothing reads it on a scoring path yet (no route change, no --- allocator change), so adding it cannot move a score. --- --- Relationship to `proof_topic_version`: that table is the append-only journal of --- *signed documents* (a re-sign is a new version). This table is the single --- current *install* row per topic — what the operator installed, from which --- bundle, and whether it is enabled. `topic_id` is the discriminant and the --- primary key: one row per topic, replaced in place on re-install. --- --- The pin/binding columns mirror bindings that today travel in the signed --- topic's `constraints.params` or in operator env (`in_guest_benchmark_runner`, --- `experiment_pack_digest`, `PROOF_RLM_VM_IMAGE_DIGEST`, the experiment guest --- image). They are install state here, not a second scoring contract: P0 writes --- nothing and no scoring path reads them. Empty string means "not pinned", which --- every later slice must read as fail-closed (an unpinned topic never boots), --- never as "use a default". --- --- `sealed_custom_value` stays NULL until the seal path measures the baseline. A --- topic with no sealed value cannot be enabled, because nobody is paid for --- beating a number nobody measured. --- --- `aliases` is the Arch default for the first topic: the slug is `tb4` and --- `tbench` is an alias, so old miner links keep resolving to one row rather --- than two topics that could drift apart. Nothing resolves an alias yet (P0 --- has no route change); the column exists so the later slice does not need a --- second migration. --- --- No secrets: no key, token, or credential column. The CHECKs are shape guards --- (slug, `sha256:<64 hex>`, finite baseline), never authentication. --- --- Mutable table (enable/disable, re-install, seal), so `base_app` gets UPDATE — --- but not DELETE: a topic is disabled, never dropped, so the install history a --- bundle digest pins stays readable. - -CREATE TABLE proof_topic ( - topic_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - version INTEGER NOT NULL, - environment TEXT NOT NULL, -- staging | metal (install target) - runner_id TEXT NOT NULL DEFAULT '', -- in-guest runner id, '' = none - aliases TEXT[] NOT NULL DEFAULT '{}', -- extra slugs the topic answers to - enabled BOOLEAN NOT NULL DEFAULT FALSE, - config JSONB NOT NULL DEFAULT '{}'::jsonb, - pin_rlm TEXT NOT NULL DEFAULT '', -- sha256: RLM VM image - pin_experiment TEXT NOT NULL DEFAULT '', -- sha256: experiment guest image - pack_digest TEXT NOT NULL DEFAULT '', -- sha256: experiment pack tar - n_concurrent INTEGER NOT NULL DEFAULT 1, - sealed_custom_value DOUBLE PRECISION, -- NULL until the baseline is sealed - schema_version INTEGER NOT NULL, -- install bundle schema version - bundle JSONB NOT NULL, -- the validated bundle, verbatim - bundle_digest TEXT NOT NULL, -- sha256: over canonical bundle - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT proof_topic_id_check CHECK (topic_id ~ '^[a-z0-9][a-z0-9-]{1,62}$'), - CONSTRAINT proof_topic_display_name_check CHECK (char_length(display_name) BETWEEN 1 AND 128), - CONSTRAINT proof_topic_version_pos CHECK (version >= 1), - CONSTRAINT proof_topic_environment_check CHECK (environment IN ('staging', 'metal')), - CONSTRAINT proof_topic_runner_id_check - CHECK (runner_id = '' OR runner_id ~ '^[a-z0-9][a-z0-9_-]{1,63}$'), - -- Aliases are topic slugs too, and a topic is never its own alias. - -- - -- The shape check is element-wise on purpose. `array_to_string` **drops - -- NULL elements**, so a joined-string regex would happily accept - -- `{tbench,NULL}` — and the typed reader decodes every element as a - -- `String`, so that one accepted row would make `topic list` and - -- `topic show` fail for the whole table. `array_position(..., NULL)` is - -- the NULL probe that actually holds; it is separate from the regex so - -- each constraint fails for one reason. - CONSTRAINT proof_topic_aliases_bound CHECK (cardinality(aliases) <= 8), - CONSTRAINT proof_topic_aliases_no_null CHECK (array_position(aliases, NULL) IS NULL), - CONSTRAINT proof_topic_aliases_shape CHECK ( - cardinality(aliases) = 0 - OR array_to_string(aliases, ',') ~ '^[a-z0-9][a-z0-9-]{1,62}(,[a-z0-9][a-z0-9-]{1,62})*$' - ), - CONSTRAINT proof_topic_aliases_not_self CHECK (NOT (topic_id = ANY (aliases))), - CONSTRAINT proof_topic_pin_rlm_check - CHECK (pin_rlm = '' OR pin_rlm ~ '^sha256:[0-9a-f]{64}$'), - CONSTRAINT proof_topic_pin_experiment_check - CHECK (pin_experiment = '' OR pin_experiment ~ '^sha256:[0-9a-f]{64}$'), - CONSTRAINT proof_topic_pack_digest_check - CHECK (pack_digest = '' OR pack_digest ~ '^sha256:[0-9a-f]{64}$'), - CONSTRAINT proof_topic_n_concurrent_pos CHECK (n_concurrent >= 1), - CONSTRAINT proof_topic_config_object CHECK (jsonb_typeof(config) = 'object'), - -- A baseline nobody measured is not a baseline: NaN / ±Infinity are refused - -- here as well as in the bundle schema, because a non-finite value would - -- silently lose every comparison the payout rule makes. - CONSTRAINT proof_topic_sealed_value_finite CHECK ( - sealed_custom_value IS NULL - OR ( - sealed_custom_value <> 'NaN'::float8 - AND sealed_custom_value <> 'Infinity'::float8 - AND sealed_custom_value <> '-Infinity'::float8 - ) - ), - CONSTRAINT proof_topic_schema_version_pos CHECK (schema_version >= 1), - CONSTRAINT proof_topic_bundle_digest_check CHECK (bundle_digest ~ '^sha256:[0-9a-f]{64}$') -); - --- The only read a later slice needs on the hot path: "the enabled topics". -CREATE INDEX ix_proof_topic_enabled ON proof_topic (enabled, topic_id); - --- Alias lookup ("is this slug a topic?") is a GIN scan, not a table walk. -CREATE INDEX ix_proof_topic_aliases ON proof_topic USING GIN (aliases); - -GRANT SELECT, INSERT, UPDATE ON TABLE proof_topic TO base_app; diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 8e12e8d4e..578835b53 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -12,20 +12,11 @@ //! //! `challenge_backends` stores operational routing only. It must never gain //! signing-key columns; keys live in owner-signed `config/challenges.toml`. -//! -//! # Proof topics -//! -//! `proof_topic` (migration `0024_proof_topics.sql`) is the per-topic install -//! registry the dynamic-topics work adds: runner id, image and pack pins, -//! concurrency, and the enable flag, keyed by `topic_id`. It is install state, -//! not the scoring contract — that stays the operator-signed document in -//! `proof_topic_version`. See [`topics`]. #![forbid(unsafe_code)] pub mod prism_store; mod store; -pub mod topics; use std::str::FromStr; use std::time::Duration; @@ -43,7 +34,6 @@ pub use store::{ AttestationRecord, EpochBundleRecord, MinerEndpointRow, NewAttestation, NewEpochBundle, NewMinerEndpoint, NewRawWeight, RawWeightRecord, RECEIPT_PK_LEN, }; -pub use topics::{get_topic, list_topics, upsert_topic, NewTopic, TopicRow}; /// Tables that the application role may insert into but never update. pub const APPEND_ONLY_TABLES: &[&str] = diff --git a/crates/db/src/topics.rs b/crates/db/src/topics.rs deleted file mode 100644 index ea7394275..000000000 --- a/crates/db/src/topics.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! Typed persistence for the Proof topic install registry -//! (`0024_proof_topics.sql`). -//! -//! One row per topic, keyed by `topic_id`. This is install state — which -//! runner, which image and pack pins, how much concurrency, whether the topic -//! is live — not the scoring contract, which stays the operator-signed topic -//! document in `proof_topic_version`. -//! -//! Runtime `sqlx::query` (no compile-time database), matching -//! `proof-rlm-store`: the table's shape lives in the migration and in the -//! `CHECK`s there, and these queries are checked against it in -//! `tests/topics.rs`. -//! -//! Nothing here enables a topic. [`upsert_topic`] writes `enabled = FALSE` on -//! insert and leaves the column untouched on conflict, because installing a -//! topic and opening it are different operator actions; the enable path is a -//! later slice (P1+) and is a fail-closed stub in the CLI today. - -use serde_json::Value; -use sqlx::{PgPool, Row}; - -use crate::DbError; - -/// One `proof_topic` row. -#[derive(Debug, Clone, PartialEq)] -pub struct TopicRow { - /// Topic slug (primary key). - pub topic_id: String, - /// Human label. - pub display_name: String, - /// Install version. - pub version: i32, - /// Install target (`staging` | `metal`). - pub environment: String, - /// In-guest runner id, empty when the topic selects none. - pub runner_id: String, - /// Extra slugs the topic answers to. - pub aliases: Vec, - /// Whether the topic is live. Always `false` in P0. - pub enabled: bool, - /// Opaque per-topic operator config. - pub config: Value, - /// RLM VM image pin (`sha256:`), empty when unpinned. - pub pin_rlm: String, - /// Experiment guest image pin, empty when unpinned. - pub pin_experiment: String, - /// Experiment pack digest, empty when absent. - pub pack_digest: String, - /// Concurrency bound. - pub n_concurrent: i32, - /// Sealed baseline primary, `None` until measured. - pub sealed_custom_value: Option, - /// Install bundle schema version. - pub schema_version: i32, - /// The validated bundle, verbatim. - pub bundle: Value, - /// `sha256:` over the canonical bundle. - pub bundle_digest: String, - /// Row creation instant (RFC 3339, UTC). - pub created_at: String, - /// Last write instant (RFC 3339, UTC). - pub updated_at: String, -} - -/// An install to write. Borrowed so a caller can hand over a parsed plan -/// without cloning it field by field. -#[derive(Debug, Clone)] -pub struct NewTopic<'a> { - /// Topic slug (primary key). - pub topic_id: &'a str, - /// Human label. - pub display_name: &'a str, - /// Install version (`>= 1`). - pub version: i32, - /// Install target (`staging` | `metal`). - pub environment: &'a str, - /// In-guest runner id, empty when none. - pub runner_id: &'a str, - /// Extra slugs. - pub aliases: &'a [String], - /// Opaque per-topic config (must be a JSON object). - pub config: &'a Value, - /// RLM image pin, empty when unpinned. - pub pin_rlm: &'a str, - /// Experiment guest image pin, empty when unpinned. - pub pin_experiment: &'a str, - /// Experiment pack digest, empty when absent. - pub pack_digest: &'a str, - /// Concurrency bound (`>= 1`). - pub n_concurrent: i32, - /// Sealed baseline primary, `None` until measured. - pub sealed_custom_value: Option, - /// Install bundle schema version. - pub schema_version: i32, - /// The validated bundle, verbatim. - pub bundle: &'a Value, - /// `sha256:` over the canonical bundle. - pub bundle_digest: &'a str, -} - -/// Timestamps as RFC 3339 UTC text, so no consumer needs a time crate to -/// print a row and the two columns stay comparable as strings. -const ROW_COLUMNS: &str = "\ - topic_id, display_name, version, environment, runner_id, aliases, enabled, \ - config, pin_rlm, pin_experiment, pack_digest, n_concurrent, sealed_custom_value, \ - schema_version, bundle, bundle_digest, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS created_at, \ - to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS updated_at"; - -fn row_to_topic(row: &sqlx::postgres::PgRow) -> Result { - Ok(TopicRow { - topic_id: row.try_get("topic_id")?, - display_name: row.try_get("display_name")?, - version: row.try_get("version")?, - environment: row.try_get("environment")?, - runner_id: row.try_get("runner_id")?, - aliases: row.try_get("aliases")?, - enabled: row.try_get("enabled")?, - config: row.try_get("config")?, - pin_rlm: row.try_get("pin_rlm")?, - pin_experiment: row.try_get("pin_experiment")?, - pack_digest: row.try_get("pack_digest")?, - n_concurrent: row.try_get("n_concurrent")?, - sealed_custom_value: row.try_get("sealed_custom_value")?, - schema_version: row.try_get("schema_version")?, - bundle: row.try_get("bundle")?, - bundle_digest: row.try_get("bundle_digest")?, - created_at: row.try_get("created_at")?, - updated_at: row.try_get("updated_at")?, - }) -} - -/// Write one topic install and return the row as persisted. -/// -/// A re-install of the same `topic_id` replaces the install fields and bumps -/// `updated_at`; `created_at` keeps the first install's instant. `enabled` is -/// set `FALSE` on insert and deliberately **not** touched on conflict: an -/// install is not an enable, and a re-install of a live topic must not -/// silently drop it out of scoring either. The enable/disable path is a later -/// slice. -/// -/// The write and the reported state are **one statement**: `RETURNING` gives -/// the caller the row it just wrote, including the `enabled` it did not set. -/// A separate read afterwards could fail after the commit and leave a caller -/// believing a successful install failed — which is how an automation -/// retries and overwrites a newer concurrent install. -/// -/// # Errors -/// -/// Propagates sqlx errors, including the row's `CHECK` violations (slug, -/// digest shape, non-finite baseline, empty config, ...). -pub async fn upsert_topic(pool: &PgPool, topic: &NewTopic<'_>) -> Result { - let sql = format!( - "INSERT INTO proof_topic ( - topic_id, display_name, version, environment, runner_id, aliases, - config, pin_rlm, pin_experiment, pack_digest, n_concurrent, - sealed_custom_value, schema_version, bundle, bundle_digest - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - ON CONFLICT (topic_id) DO UPDATE SET - display_name = EXCLUDED.display_name, - version = EXCLUDED.version, - environment = EXCLUDED.environment, - runner_id = EXCLUDED.runner_id, - aliases = EXCLUDED.aliases, - config = EXCLUDED.config, - pin_rlm = EXCLUDED.pin_rlm, - pin_experiment = EXCLUDED.pin_experiment, - pack_digest = EXCLUDED.pack_digest, - n_concurrent = EXCLUDED.n_concurrent, - sealed_custom_value = EXCLUDED.sealed_custom_value, - schema_version = EXCLUDED.schema_version, - bundle = EXCLUDED.bundle, - bundle_digest = EXCLUDED.bundle_digest, - updated_at = now() - RETURNING {ROW_COLUMNS}" - ); - let row = sqlx::query(&sql) - .bind(topic.topic_id) - .bind(topic.display_name) - .bind(topic.version) - .bind(topic.environment) - .bind(topic.runner_id) - .bind(topic.aliases) - .bind(topic.config) - .bind(topic.pin_rlm) - .bind(topic.pin_experiment) - .bind(topic.pack_digest) - .bind(topic.n_concurrent) - .bind(topic.sealed_custom_value) - .bind(topic.schema_version) - .bind(topic.bundle) - .bind(topic.bundle_digest) - .fetch_one(pool) - .await?; - row_to_topic(&row) -} - -/// Every installed topic, ordered by `topic_id`. -/// -/// An empty table is an empty vector, not an error: P0 ships before any topic -/// is installed, and `topic list` has to say so rather than fail. -/// -/// # Errors -/// -/// Propagates sqlx query and decode errors. -pub async fn list_topics(pool: &PgPool) -> Result, DbError> { - let sql = format!("SELECT {ROW_COLUMNS} FROM proof_topic ORDER BY topic_id"); - let rows = sqlx::query(&sql).fetch_all(pool).await?; - rows.iter().map(row_to_topic).collect() -} - -/// One installed topic by slug, or `None`. -/// -/// Looks up `topic_id` only. Aliases are stored for a later slice and are not -/// resolved here, so `show tbench` on a row whose id is `tb4` is a miss — the -/// CLI says so instead of guessing which row was meant. -/// -/// # Errors -/// -/// Propagates sqlx query and decode errors. -pub async fn get_topic(pool: &PgPool, topic_id: &str) -> Result, DbError> { - let sql = format!("SELECT {ROW_COLUMNS} FROM proof_topic WHERE topic_id = $1"); - let row = sqlx::query(&sql) - .bind(topic_id) - .fetch_optional(pool) - .await?; - row.as_ref().map(row_to_topic).transpose() -} - -#[cfg(test)] -mod unit_tests { - use super::*; - - const MIGRATION: &str = include_str!("../migrations/0024_proof_topics.sql"); - - /// The selected columns are what [`row_to_topic`] reads: a column added to - /// one side only is a decode error at runtime, so both lists are pinned. - /// The upsert's `RETURNING` reuses the same list, so the write and the - /// read cannot drift apart either. - #[test] - fn the_column_list_covers_every_decoded_field() { - for column in [ - "topic_id", - "display_name", - "version", - "environment", - "runner_id", - "aliases", - "enabled", - "config", - "pin_rlm", - "pin_experiment", - "pack_digest", - "n_concurrent", - "sealed_custom_value", - "schema_version", - "bundle", - "bundle_digest", - "created_at", - "updated_at", - ] { - assert!(ROW_COLUMNS.contains(column), "missing column {column}"); - } - } - - #[test] - fn timestamps_are_formatted_as_utc_rfc3339_text() { - assert!(ROW_COLUMNS.contains("AT TIME ZONE 'UTC'"), "{ROW_COLUMNS}"); - assert!(!ROW_COLUMNS.contains("now()"), "reads never write"); - } - - /// A topic is disabled, never dropped: the app role may write and update - /// the install, and must not be able to delete the row a bundle digest - /// pins. The integration test proves the runtime refusal; this pins the - /// migration's grant without needing a database. - #[test] - fn the_app_role_may_write_and_update_but_never_delete() { - assert!( - MIGRATION.contains("GRANT SELECT, INSERT, UPDATE ON TABLE proof_topic TO base_app;"), - "the install is mutable (enable/disable, re-install, seal)" - ); - for forbidden in [ - "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE proof_topic", - "GRANT DELETE ON TABLE proof_topic", - "GRANT ALL ON TABLE proof_topic", - ] { - assert!( - !MIGRATION.contains(forbidden), - "the app role must never delete a topic: {forbidden}" - ); - } - } - - /// Nothing here may enable a topic: the column exists, and the upsert - /// deliberately leaves it alone on conflict. - #[test] - fn installing_never_enables() { - assert!(!ROW_COLUMNS.contains("enabled = TRUE"), "reads never write"); - assert!( - MIGRATION.contains("enabled BOOLEAN NOT NULL DEFAULT FALSE"), - "a fresh install starts disabled" - ); - } -} diff --git a/crates/db/tests/topics.rs b/crates/db/tests/topics.rs deleted file mode 100644 index 8f191e865..000000000 --- a/crates/db/tests/topics.rs +++ /dev/null @@ -1,485 +0,0 @@ -//! Integration tests for the Proof topic install registry (migration 0024). -//! -//! Runs against an isolated migrated schema when `DATABASE_URL` is set (the -//! same gating as the other `crates/db/tests`) and is skipped otherwise, so -//! default CI without Postgres stays green. -//! -//! Scenarios: -//! - S1 happy: install a topic, read it back, list it -//! - S2 edge: empty table lists as empty; an unknown id is `None`, not an error -//! - S3 edge: re-install replaces install fields, keeps `created_at`, and does -//! not change `enabled` -//! - S4 fail-closed: the table's own `CHECK`s refuse a malformed row -//! - S5 role: `base_app` may write and update, never delete - -#![cfg(feature = "testing")] -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::too_many_lines)] - -use db::{get_topic, list_topics, upsert_topic, NewTopic, TestPool}; -use serde_json::json; - -/// Returns `false` when `DATABASE_URL` is unset so default CI (no Postgres) skips. -fn database_url_present() -> bool { - std::env::var_os("DATABASE_URL").is_some() -} - -const HEX: &str = "abababababababababababababababababababababababababababababababab"; - -fn digest() -> String { - format!("sha256:{HEX}") -} - -/// Owns every borrowed field so a test can mutate one and still hand the row -/// to [`upsert_topic`] without fighting temporary lifetimes. -struct Fixture { - aliases: Vec, - config: serde_json::Value, - pin_rlm: String, - pin_experiment: String, - pack_digest: String, - bundle: serde_json::Value, - bundle_digest: String, -} - -impl Fixture { - fn new() -> Self { - let digest = digest(); - Self { - aliases: vec!["tbench".to_owned()], - config: json!({}), - pin_rlm: digest.clone(), - pin_experiment: digest.clone(), - pack_digest: digest.clone(), - bundle: json!({ "schema_version": 1, "topic_id": "tb4" }), - bundle_digest: digest, - } - } - - fn row(&self) -> NewTopic<'_> { - NewTopic { - topic_id: "tb4", - display_name: "Terminal-Bench 4", - version: 1, - environment: "metal", - runner_id: "rlm_fc_in_guest_harbor", - aliases: &self.aliases, - config: &self.config, - pin_rlm: &self.pin_rlm, - pin_experiment: &self.pin_experiment, - pack_digest: &self.pack_digest, - n_concurrent: 2, - sealed_custom_value: None, - schema_version: 1, - bundle: &self.bundle, - bundle_digest: &self.bundle_digest, - } - } -} - -/// A runner-less row (the harvest-family shape), for ordering probes. -fn harvest_row<'a>(f: &'a Fixture, topic_id: &'a str) -> NewTopic<'a> { - NewTopic { - topic_id, - runner_id: "", - pack_digest: "", - ..f.row() - } -} - -#[tokio::test] -async fn s1_install_read_back_and_list() { - if !database_url_present() { - return; - } - let tp: TestPool = db::test_pool().await.expect("test_pool"); - let pool = tp.pool(); - - assert!( - list_topics(pool).await.expect("empty list").is_empty(), - "a fresh schema has no topics; that is empty, not an error" - ); - assert!(get_topic(pool, "tb4").await.expect("miss").is_none()); - - let fixture = Fixture::new(); - upsert_topic(pool, &fixture.row()).await.expect("install"); - - let row = get_topic(pool, "tb4").await.expect("get").expect("row"); - assert_eq!(row.topic_id, "tb4"); - assert_eq!(row.display_name, "Terminal-Bench 4"); - assert_eq!(row.version, 1); - assert_eq!(row.environment, "metal"); - assert_eq!(row.runner_id, "rlm_fc_in_guest_harbor"); - assert_eq!(row.aliases, fixture.aliases); - assert!(!row.enabled, "an install never enables a topic"); - assert_eq!(row.config, json!({})); - assert_eq!(row.pin_rlm, fixture.pin_rlm); - assert_eq!(row.pin_experiment, fixture.pin_experiment); - assert_eq!(row.pack_digest, fixture.pack_digest); - assert_eq!(row.n_concurrent, 2); - assert!(row.sealed_custom_value.is_none(), "unsealed stays NULL"); - assert_eq!(row.schema_version, 1); - assert_eq!(row.bundle, fixture.bundle); - assert_eq!(row.bundle_digest, fixture.bundle_digest); - assert!(row.created_at.ends_with('Z'), "{}", row.created_at); - assert!(row.updated_at.ends_with('Z'), "{}", row.updated_at); - assert_eq!(row.created_at, row.updated_at, "one write, one instant"); - - let listed = list_topics(pool).await.expect("list"); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0], row); - - tp.drop_schema().await.expect("drop"); -} - -#[tokio::test] -async fn s2_list_is_ordered_by_topic_id() { - if !database_url_present() { - return; - } - let tp = db::test_pool().await.expect("test_pool"); - let pool = tp.pool(); - - let fixture = Fixture::new(); - for id in ["zeta", "alpha", "mid"] { - upsert_topic(pool, &harvest_row(&fixture, id)) - .await - .expect("install"); - } - let ids: Vec = list_topics(pool) - .await - .expect("list") - .into_iter() - .map(|r| r.topic_id) - .collect(); - assert_eq!(ids, ["alpha", "mid", "zeta"]); - - tp.drop_schema().await.expect("drop"); -} - -#[tokio::test] -async fn s3_reinstall_replaces_the_install_and_keeps_the_first_created_at() { - if !database_url_present() { - return; - } - let tp = db::test_pool().await.expect("test_pool"); - let pool = tp.pool(); - - let fixture = Fixture::new(); - upsert_topic(pool, &fixture.row()) - .await - .expect("first install"); - let first = get_topic(pool, "tb4").await.expect("get").expect("row"); - - // An operator opens the topic by hand (the enable path is a later slice, - // so the test writes the column directly to prove the re-install rule). - sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") - .execute(pool) - .await - .expect("enable"); - - let next_bundle = json!({ "v": 2 }); - let next_digest = format!("sha256:{}", "cd".repeat(32)); - let second = Fixture { - bundle: next_bundle.clone(), - bundle_digest: next_digest.clone(), - ..Fixture::new() - }; - upsert_topic( - pool, - &NewTopic { - version: 2, - n_concurrent: 4, - sealed_custom_value: Some(0.42), - ..second.row() - }, - ) - .await - .expect("re-install"); - - let row = get_topic(pool, "tb4").await.expect("get").expect("row"); - assert_eq!(row.version, 2, "the install version advances"); - assert_eq!(row.n_concurrent, 4); - assert_eq!(row.sealed_custom_value, Some(0.42)); - assert_eq!(row.bundle, next_bundle); - assert_eq!(row.bundle_digest, next_digest); - assert_eq!( - row.created_at, first.created_at, - "created_at belongs to the first install" - ); - assert!( - row.enabled, - "a re-install must not silently disable a live topic" - ); - assert_eq!( - list_topics(pool).await.expect("list").len(), - 1, - "one row per topic_id" - ); - - tp.drop_schema().await.expect("drop"); -} - -/// The write returns the row it committed. -/// -/// The CLI reports install success from this value, so the write and the -/// reported state must be one statement: a separate read after the commit -/// could fail and tell a caller a successful install failed — which is how an -/// automation retries and overwrites a newer concurrent install. The returned -/// `enabled` is the **persisted** one, which a re-install deliberately leaves -/// alone. -#[tokio::test] -async fn s3b_the_upsert_returns_the_persisted_row() { - if !database_url_present() { - return; - } - let tp = db::test_pool().await.expect("test_pool"); - let pool = tp.pool(); - - let fixture = Fixture::new(); - let inserted = upsert_topic(pool, &fixture.row()).await.expect("install"); - assert_eq!(inserted.topic_id, "tb4"); - assert_eq!(inserted.version, 1); - assert_eq!(inserted.n_concurrent, 2); - assert!( - !inserted.enabled, - "a first install writes (and therefore reports) disabled" - ); - assert_eq!( - inserted, - get_topic(pool, "tb4").await.expect("get").expect("row"), - "the returned row is the persisted row" - ); - - sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") - .execute(pool) - .await - .expect("enable"); - - let reinserted = upsert_topic( - pool, - &NewTopic { - version: 2, - ..fixture.row() - }, - ) - .await - .expect("re-install"); - assert_eq!(reinserted.version, 2, "the write's own version comes back"); - assert!( - reinserted.enabled, - "the returned state is the persisted one, not what an install would write" - ); - assert_eq!( - reinserted.created_at, inserted.created_at, - "created_at still belongs to the first install" - ); - assert_eq!( - reinserted, - get_topic(pool, "tb4").await.expect("get").expect("row") - ); - - tp.drop_schema().await.expect("drop"); -} - -#[tokio::test] -async fn s4_the_schema_refuses_a_malformed_row() { - if !database_url_present() { - return; - } - let tp = db::test_pool().await.expect("test_pool"); - let pool = tp.pool(); - - let fixture = Fixture::new(); - - // Each probe mutates one field into a shape the schema must refuse. - for (label, mutate) in [ - ( - "uppercase topic id", - Box::new(|r: &mut NewTopic<'_>| r.topic_id = "TB4") as Box)>, - ), - ( - "underscore topic id", - Box::new(|r: &mut NewTopic<'_>| r.topic_id = "tb_4"), - ), - ( - "empty display name", - Box::new(|r: &mut NewTopic<'_>| r.display_name = ""), - ), - ( - "zero version", - Box::new(|r: &mut NewTopic<'_>| r.version = 0), - ), - ( - "unknown environment", - Box::new(|r: &mut NewTopic<'_>| r.environment = "prod"), - ), - ( - "zero concurrency", - Box::new(|r: &mut NewTopic<'_>| r.n_concurrent = 0), - ), - ( - "bare-hex pin", - Box::new(|r: &mut NewTopic<'_>| r.pin_rlm = HEX), - ), - ( - "short pack digest", - Box::new(|r: &mut NewTopic<'_>| r.pack_digest = "sha256:abc"), - ), - ( - "bare bundle digest", - Box::new(|r: &mut NewTopic<'_>| r.bundle_digest = HEX), - ), - ( - "non-finite baseline", - Box::new(|r: &mut NewTopic<'_>| r.sealed_custom_value = Some(f64::NAN)), - ), - ( - "zero schema version", - Box::new(|r: &mut NewTopic<'_>| r.schema_version = 0), - ), - ( - "bad runner id", - Box::new(|r: &mut NewTopic<'_>| r.runner_id = "Runner With Spaces"), - ), - ] { - let mut row = fixture.row(); - mutate(&mut row); - upsert_topic(pool, &row) - .await - .expect_err(&format!("{label} must be refused by the schema")); - } - - assert!( - list_topics(pool).await.expect("list").is_empty(), - "no refused probe may leave a row" - ); - - // The alias array is checked element-wise, including self-aliasing. - for (label, alias_list) in [ - ("malformed alias", vec!["Bad Alias".to_owned()]), - ("self alias", vec!["tb4".to_owned()]), - ] { - let bad_aliases = Fixture { - aliases: alias_list, - ..Fixture::new() - }; - let err = upsert_topic(pool, &bad_aliases.row()) - .await - .expect_err(label); - let msg = err.to_string(); - assert!( - msg.contains("aliases") || msg.contains("check constraint"), - "{label}: {msg}" - ); - } - - // A non-object config is refused by the schema too. - let list_config = Fixture { - config: json!([1, 2]), - ..Fixture::new() - }; - upsert_topic(pool, &list_config.row()) - .await - .expect_err("config must be an object"); - - tp.drop_schema().await.expect("drop"); -} - -/// A `NULL` inside the alias array is refused. -/// -/// `array_to_string` drops NULL elements, so a joined-string shape check -/// alone would accept `{tbench,NULL}` — and the typed reader decodes every -/// element as a `String`, so that one row would make `topic list` and -/// `topic show` fail for the whole table rather than just its own row. -#[tokio::test] -async fn s4b_a_null_alias_element_is_refused() { - if !database_url_present() { - return; - } - let tp = db::test_pool().await.expect("test_pool"); - let pool = tp.pool(); - - for label in ["NULL first", "NULL last", "NULL only"] { - let insert = match label { - "NULL first" => "INSERT INTO proof_topic \ - (topic_id, display_name, version, environment, schema_version, bundle, bundle_digest, aliases) \ - VALUES ('tb4', 'x', 1, 'metal', 1, '{}', 'sha256:' || repeat('a', 64), ARRAY[NULL, 'tbench'])", - "NULL last" => "INSERT INTO proof_topic \ - (topic_id, display_name, version, environment, schema_version, bundle, bundle_digest, aliases) \ - VALUES ('tb4', 'x', 1, 'metal', 1, '{}', 'sha256:' || repeat('a', 64), ARRAY['tbench', NULL])", - _ => "INSERT INTO proof_topic \ - (topic_id, display_name, version, environment, schema_version, bundle, bundle_digest, aliases) \ - VALUES ('tb4', 'x', 1, 'metal', 1, '{}', 'sha256:' || repeat('a', 64), ARRAY[NULL])", - }; - let err = sqlx::query(insert).execute(pool).await.expect_err(label); - let msg = err.to_string(); - assert!( - msg.contains("aliases_no_null") || msg.contains("check constraint"), - "{label}: {msg}" - ); - } - - // The shape check alone would have accepted the NULL (it is dropped by - // array_to_string), which is exactly why the separate constraint exists. - let joined: String = - sqlx::query_scalar("SELECT array_to_string(ARRAY['tbench', NULL]::text[], ',')") - .fetch_one(pool) - .await - .expect("array_to_string"); - assert_eq!( - joined, "tbench", - "the NULL is dropped, not caught, by the join" - ); - - assert!( - list_topics(pool).await.expect("list").is_empty(), - "no refused probe may leave a row" - ); - tp.drop_schema().await.expect("drop"); -} - -#[tokio::test] -async fn s5_app_role_writes_and_updates_but_never_deletes() { - if !database_url_present() { - return; - } - let tp = db::test_pool().await.expect("test_pool"); - - let fixture = Fixture::new(); - let app = tp.app_pool().await.expect("app_pool"); - upsert_topic(&app, &fixture.row()) - .await - .expect("app role installs a topic"); - assert!( - get_topic(&app, "tb4").await.expect("get").is_some(), - "app role reads its own install" - ); - - sqlx::query("UPDATE proof_topic SET enabled = TRUE WHERE topic_id = 'tb4'") - .execute(&app) - .await - .expect("app role may enable (the enable path is a later slice)"); - - // The shared test harness grants the app role DELETE on every table and - // revokes it only for `APPEND_ONLY_TABLES`. `proof_topic` is mutable but - // deliberately not append-only, so restore the privilege set the migration - // alone grants (SELECT, INSERT, UPDATE) before asserting the refusal. - // That the migration never grants DELETE is pinned without a database in - // `crates/db/src/topics.rs`. - sqlx::query("REVOKE DELETE ON TABLE proof_topic FROM base_app") - .execute(tp.pool()) - .await - .expect("restore the migration's grants"); - - let err = sqlx::query("DELETE FROM proof_topic WHERE topic_id = 'tb4'") - .execute(&app) - .await - .expect_err("a topic is disabled, never dropped"); - let msg = err.to_string(); - assert!( - msg.contains("permission denied") || msg.contains("42501"), - "{msg}" - ); - - tp.drop_schema().await.expect("drop"); -} diff --git a/crates/proof-rlm-store/src/lib.rs b/crates/proof-rlm-store/src/lib.rs index 996c65cbe..cb0856df8 100644 --- a/crates/proof-rlm-store/src/lib.rs +++ b/crates/proof-rlm-store/src/lib.rs @@ -100,6 +100,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 +197,15 @@ 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>; + /// 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..5cab333bb 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)] @@ -64,6 +64,24 @@ 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_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..2be4584ec 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,29 @@ 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_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..9c206f26b 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,26 @@ 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" + ); + // Rules: v1 from the document, v2 from the RLM, gaps refused. let v1 = rules(); assert!(store.current_rules(&t.id).await.unwrap().is_none()); diff --git a/crates/proof-topic-bundle/Cargo.toml b/crates/proof-topic-bundle/Cargo.toml index 45d1ecc78..610602035 100644 --- a/crates/proof-topic-bundle/Cargo.toml +++ b/crates/proof-topic-bundle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proof-topic-bundle" -description = "Proof topic install bundle: the JSON an operator installs a topic from (schema, shape checks, canonical digest, install plan)" +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 @@ -11,6 +11,8 @@ 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 = "1" sha2 = "0.10" diff --git a/crates/proof-topic-bundle/src/lib.rs b/crates/proof-topic-bundle/src/lib.rs index 559e29da2..b29931757 100644 --- a/crates/proof-topic-bundle/src/lib.rs +++ b/crates/proof-topic-bundle/src/lib.rs @@ -1,32 +1,31 @@ -//! Proof **topic install bundle**: the JSON document an operator installs a -//! topic from. +//! Proof **topic install bundle**: the operator procedure that publishes one +//! signed topic. //! -//! A topic's *scoring contract* is its signed topic document (see -//! `proof-task`). The *install* is a separate operator record: which runner -//! the topic names, which RLM and experiment images and which experiment pack -//! it is pinned to, how much concurrency it may use, and whether it is live. -//! This crate is the shape of that record, the checks it has to pass before -//! anything is written, and the canonical digest a later slice can pin. +//! 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. //! -//! P0 scope (dynamic-topics skeleton): parse, validate, digest, and describe. -//! This crate never touches the database, the network, or the filesystem, and -//! it never enables anything. The CLI that drives it (`bins/proof-admin`) -//! writes a row with `enabled = false` and nothing in this repository reads -//! that row on a scoring path yet — the routes (P1), the allocator (P2), the -//! full install (P3), and the removal of the compiled-in `tbench` bindings -//! (P4) are later slices. +//! What was missing is the *procedure*: which signed document, which install +//! target, and which host env must agree with it before the topic can run. +//! That is this bundle. It **references** the document and **cross-checks** +//! the host expectations against it; it never restates a binding in a second +//! place that could drift. //! //! Three rules carry the fail-closed posture: //! -//! - **Unknown keys are refused.** A binding this build does not understand -//! is a binding nothing enforces, so `deny_unknown_fields` rejects it at -//! parse rather than installing a topic that half-works. -//! - **A digest is never invented.** Every pin is `sha256:<64 hex>` or it is -//! absent; absent means "not pinned", which every later slice must read as -//! fail-closed (an unpinned topic never boots), never as a default. -//! - **A runner without a pack is refused.** An in-guest runner with nothing -//! to run is a job that cannot score, so the pair travels together or not -//! at all — the same rule the signed topic's `constraints.params` carries. +//! - **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( @@ -39,8 +38,9 @@ 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; /// Only accepted `schema_version`. pub const BUNDLE_SCHEMA_VERSION: u32 = 1; @@ -48,56 +48,61 @@ pub const BUNDLE_SCHEMA_VERSION: u32 = 1; /// Longest legal `display_name`. pub const MAX_DISPLAY_NAME_LEN: usize = 128; -/// Most aliases one topic may carry. -pub const MAX_ALIASES: usize = 8; - -/// Largest canonical `config` object, in bytes. -pub const MAX_CONFIG_BYTES: usize = 16 * 1024; - -/// Largest `version` / `n_concurrent` a bundle may carry. -/// -/// The row's columns are `INTEGER`, so a value above this would have to be -/// clamped on write — and a clamped row would disagree with the validated, -/// digest-covered bundle an operator reviewed. Out of range is a reject, never -/// a silent rewrite. -pub const MAX_INT_COLUMN: u32 = i32::MAX as u32; - -/// Install targets, in the order the CLI offers them. -pub const INSTALL_ENVIRONMENTS: [&str; 2] = ["staging", "metal"]; - /// 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; 13] = [ - "aliases", - "config", +pub const BUNDLE_KEYS: [&str; 5] = [ "display_name", "environment", - "n_concurrent", - "pack_digest", - "pin_experiment", - "pin_rlm", - "runner_id", + "host", "schema_version", - "sealed_custom_value", - "topic_id", - "version", + "topic", ]; /// Keys with no `serde` default: a bundle that omits one is a parse error -/// naming the field, never an empty string that fails later. +/// naming the field, never an empty value that fails later. pub const REQUIRED_BUNDLE_KEYS: [&str; 5] = [ "display_name", "environment", + "host", "schema_version", - "topic_id", - "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", ]; -/// Prefix of every pin 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"; + +/// 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 @@ -113,7 +118,7 @@ pub enum InstallEnvironment { } impl InstallEnvironment { - /// Wire word (`staging` / `metal`), which is also the DB value. + /// Wire word (`staging` / `metal`). #[must_use] pub const fn as_str(self) -> &'static str { match self { @@ -158,69 +163,61 @@ pub enum BundleError { /// What this build reads. want: u32, }, - /// `topic_id` is not `[a-z0-9][a-z0-9-]{1,62}`. - #[error("topic_id {0:?} must match [a-z0-9][a-z0-9-]{{1,62}} (a hyphen slug)")] - BadTopicId(String), /// `display_name` is empty or oversized. #[error("display_name must be 1..={MAX_DISPLAY_NAME_LEN} chars")] BadDisplayName, - /// `version` is zero. - #[error("version must be >= 1")] - BadVersion, - /// More aliases than the bound allows. - #[error("aliases carries {0}, at most {MAX_ALIASES} are allowed")] - TooManyAliases(usize), - /// An alias is not a slug, repeats, or names the topic itself. - #[error("alias {alias:?}: {why}")] - BadAlias { - /// The offending alias. - alias: String, - /// What is wrong. - why: &'static str, - }, - /// `runner_id` is not `[a-z0-9][a-z0-9_-]{1,63}`. - #[error("runner_id {0:?} must match [a-z0-9][a-z0-9_-]{{1,63}}")] - BadRunnerId(String), - /// A pin is not `sha256:<64 hex>`. - #[error("{field} {got:?} is not {DIGEST_PREFIX}<64 lowercase hex>")] + /// A host expectation is not `sha256:<64 lowercase hex>`. + #[error("host.{field} {got:?} is not {DIGEST_PREFIX}<64 lowercase hex>")] BadDigest { - /// Which pin (`pin_rlm`, `pin_experiment`, `pack_digest`). + /// Which expectation (`rlm_image_digest`, `experiment_image_digest`, `pack_digest`). field: &'static str, /// What the bundle said. got: String, }, - /// An in-guest runner with no pack to run. + /// The document selects an in-guest runner but nothing pins its pack. #[error( - "runner_id {runner_id:?} names an in-guest runner, so pack_digest is required \ - (sha256:<64 hex> of the pack tar staged on the KVM host; never invented)" + "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 bundle named. + /// The runner the document selected. runner_id: String, }, - /// A pack nothing runs. - #[error("pack_digest is set but runner_id is absent: a pack no runner reads is dead weight")] + /// 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, - /// `n_concurrent` is zero. - #[error("n_concurrent must be >= 1")] - BadConcurrency, - /// `version` / `n_concurrent` does not fit the row's `INTEGER` column. - #[error("{field} {got} does not fit the topic row (max {MAX_INT_COLUMN}); refused rather than clamped")] - IntColumnOverflow { - /// Which field. + /// 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: u32, + 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, }, - /// `sealed_custom_value` is not finite. - #[error("sealed_custom_value {0} is not finite; a baseline must be a measured number")] - NonFiniteSealedValue(f64), - /// `config` is not a JSON object. - #[error("config must be a JSON object, got {0}")] - ConfigNotObject(&'static str), - /// `config` is larger than the bound. - #[error("config is {0} bytes of canonical JSON, at most {MAX_CONFIG_BYTES} are allowed")] - ConfigTooLarge(usize), + /// `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), /// The canonical form could not be built. #[error("canonicalize bundle: {0}")] Canonicalize(String), @@ -234,118 +231,98 @@ pub enum BundleError { }, } -/// One topic install bundle, as written by an operator. +/// The operator env that must agree with the signed document. /// -/// Required keys are not defaulted, so a missing `topic_id` is a parse error -/// naming the field rather than an empty string that fails later. Optional -/// keys default to the fail-closed reading: no aliases, no runner, no pins, -/// one concurrent job, no sealed baseline, empty config. +/// 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, +} + +/// 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, - /// Topic slug (`[a-z0-9][a-z0-9-]{1,62}`). The Arch default for the first - /// topic is `tb4`. - pub topic_id: String, - /// Human label for operator output. - pub display_name: String, - /// Monotonic install version for this topic (a re-sign is a new version). - pub version: u32, /// Install target this bundle was written for. pub environment: InstallEnvironment, - /// Extra slugs the topic answers to. The Arch default is `["tbench"]` - /// for topic `tb4`, so old miner links resolve to one row. - #[serde(default)] - pub aliases: Vec, - /// In-guest runner id, if the topic selects one. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub runner_id: Option, - /// `sha256:` of the RLM VM image, or absent when not pinned. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pin_rlm: Option, - /// `sha256:` of the experiment guest image, or absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pin_experiment: Option, - /// `sha256:` of the experiment pack tar, or absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pack_digest: Option, - /// Jobs of this topic that may run at once. - #[serde(default = "default_n_concurrent")] - pub n_concurrent: u32, - /// The sealed baseline primary, once measured. Absent until the seal path - /// has a number; a topic with no sealed value cannot be enabled. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sealed_custom_value: Option, - /// Opaque per-topic operator config. Stored verbatim; this crate only - /// checks that it is a bounded JSON object. - #[serde(default = "default_config")] - pub config: Value, -} - -fn default_n_concurrent() -> u32 { - 1 -} - -fn default_config() -> Value { - Value::Object(serde_json::Map::new()) + /// 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, } -impl Default for TopicInstallBundle { - fn default() -> Self { - Self { - schema_version: BUNDLE_SCHEMA_VERSION, - topic_id: String::new(), - display_name: String::new(), - version: 1, - environment: InstallEnvironment::Staging, - aliases: Vec::new(), - runner_id: None, - pin_rlm: None, - pin_experiment: None, - pack_digest: None, - n_concurrent: default_n_concurrent(), - sealed_custom_value: None, - config: default_config(), - } - } +/// 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 a real install -/// writes. `enabled` is always `false` — installing a topic never opens it. +/// 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 row's primary key). + /// Topic slug (the document's `id`). pub topic_id: String, /// Human label. pub display_name: String, - /// Install version. - pub version: u32, /// Install target. pub environment: InstallEnvironment, - /// Extra slugs, sorted and de-duplicated. - pub aliases: Vec, - /// In-guest runner id, empty when the topic selects none. - pub runner_id: String, - /// RLM image pin, empty when unpinned. - pub pin_rlm: String, - /// Experiment guest image pin, empty when unpinned. - pub pin_experiment: String, - /// Experiment pack digest, empty when absent. - pub pack_digest: String, - /// Concurrency bound. - pub n_concurrent: u32, - /// Sealed baseline primary, when the bundle carries one. - pub sealed_custom_value: Option, - /// Bundle schema version. - pub schema_version: u32, - /// The opaque per-topic operator config, verbatim. - pub config: Value, + /// 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, + /// 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, - /// Always `false` on install. A topic is enabled by an operator action - /// that P0 does not implement. - pub enabled: bool, } fn is_digest(s: &str) -> bool { @@ -355,73 +332,61 @@ fn is_digest(s: &str) -> bool { /// Exactly 64 **lowercase** hex characters, with no surrounding whitespace. /// /// Deliberately stricter than `proof_canon::is_hex64`, which trims and accepts -/// uppercase: a pin is stored here verbatim and the row's `CHECK` is -/// `^sha256:[0-9a-f]{64}$`, so accepting `sha256:AB…` or `sha256: ab… ` would -/// let a bundle validate and dry-run and then fail on a real install. One -/// spelling of a digest, checked the same way in both places. +/// 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 binding this build cannot name is a - /// binding it cannot enforce. + /// 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: ids, pins, cross-field rules, config bounds. + /// Shape checks that need no pin: schema, label, digest spellings, and the + /// cross-checks between the host block and the signed document. /// - /// Every value checked here is stored as given — nothing is normalised, - /// substituted, or defaulted into existence. - pub fn validate(&self) -> Result<(), BundleError> { + /// 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, }); } - if !proof_canon::is_slug(&self.topic_id) { - return Err(BundleError::BadTopicId(self.topic_id.clone())); - } let name = self.display_name.trim(); if name.is_empty() || name.chars().count() > MAX_DISPLAY_NAME_LEN { return Err(BundleError::BadDisplayName); } - if self.version == 0 { - return Err(BundleError::BadVersion); - } - if self.aliases.len() > MAX_ALIASES { - return Err(BundleError::TooManyAliases(self.aliases.len())); - } - for alias in &self.aliases { - let why = if !proof_canon::is_slug(alias) { - "must match [a-z0-9][a-z0-9-]{1,62}" - } else if alias == &self.topic_id { - "an alias of the topic id itself is not an alias" - } else if self.aliases.iter().filter(|a| *a == alias).count() > 1 { - "duplicate alias" - } else { - continue; - }; - return Err(BundleError::BadAlias { - alias: alias.clone(), - why, - }); - } - if let Some(id) = self.runner_id.as_deref() { - if !proof_canon::is_custom_id(id) { - return Err(BundleError::BadRunnerId(id.to_owned())); - } - } for (field, value) in [ - ("pin_rlm", self.pin_rlm.as_deref()), - ("pin_experiment", self.pin_experiment.as_deref()), - ("pack_digest", self.pack_digest.as_deref()), + ("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) { @@ -432,46 +397,88 @@ impl TopicInstallBundle { } } } - if self.runner_id.is_some() && self.pack_digest.is_none() { - return Err(BundleError::RunnerWithoutPack { - runner_id: self.runner_id.clone().unwrap_or_default(), - }); - } - if self.pack_digest.is_some() && self.runner_id.is_none() { - return Err(BundleError::PackWithoutRunner); - } - if self.n_concurrent == 0 { - return Err(BundleError::BadConcurrency); + self.check_pack_dir()?; + 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())) } - for (field, value) in [ - ("version", self.version), - ("n_concurrent", self.n_concurrent), - ] { - if value > MAX_INT_COLUMN { - return Err(BundleError::IntColumnOverflow { field, got: value }); + } + + /// 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(), + }); + } } - } - if let Some(v) = self.sealed_custom_value { - if !v.is_finite() { - return Err(BundleError::NonFiniteSealedValue(v)); + // 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) => {} } - match &self.config { - Value::Object(_) => {} - other => { - return Err(BundleError::ConfigNotObject(json_kind(other))); + // 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(), + }); + } } } - // The bound is on the `config` object, measured on its own canonical - // form: a large-but-legal bundle elsewhere must not be blamed on a - // config that is well inside the limit. - let config_bytes = proof_canon::canonical_json(&self.config).len(); - if config_bytes > MAX_CONFIG_BYTES { - return Err(BundleError::ConfigTooLarge(config_bytes)); - } 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. @@ -493,50 +500,79 @@ impl TopicInstallBundle { /// 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()?; + self.validate_shape()?; if self.environment != requested { return Err(BundleError::EnvironmentMismatch { bundle: self.environment, requested, }); } - let mut aliases = self.aliases.clone(); - aliases.sort_unstable(); - aliases.dedup(); + 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(), + topic_id: self.topic.id.clone(), display_name: self.display_name.trim().to_owned(), - version: self.version, environment: self.environment, - aliases, - runner_id: self.runner_id.clone().unwrap_or_default(), - pin_rlm: self.pin_rlm.clone().unwrap_or_default(), - pin_experiment: self.pin_experiment.clone().unwrap_or_default(), - pack_digest: self.pack_digest.clone().unwrap_or_default(), - n_concurrent: self.n_concurrent, - sealed_custom_value: self.sealed_custom_value, - schema_version: self.schema_version, - config: self.config.clone(), + 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(), + host_env, + pack_dir_env: binding.as_ref().map(|_| ENV_PACK_DIR.to_owned()), bundle_digest: self.digest()?, - enabled: false, }) } } -fn json_kind(value: &Value) -> &'static str { - match value { - Value::Null => "null", - Value::Bool(_) => "a boolean", - Value::Number(_) => "a number", - Value::String(_) => "a string", - Value::Array(_) => "an array", - Value::Object(_) => "an object", - } -} - #[cfg(test)] mod tests { use super::*; + use proof_task::{ + default_adamw, holdout_commitment, synthetic_holdout, MetricSpec, PayoutMode, STRATUM_SIZE, + }; const HEX: &str = "abababababababababababababababababababababababababababababababab"; @@ -544,78 +580,155 @@ mod tests { format!("{DIGEST_PREFIX}{HEX}") } - /// The Arch default for the first topic: slug `tb4`, alias `tbench`. + /// 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 { - topic_id: "tb4".into(), - display_name: "Terminal-Bench 4".into(), + schema_version: BUNDLE_SCHEMA_VERSION, environment: InstallEnvironment::Metal, - aliases: vec!["tbench".into()], - runner_id: Some("rlm_fc_in_guest_harbor".into()), - pack_digest: Some(digest()), - pin_rlm: Some(digest()), - pin_experiment: Some(digest()), - n_concurrent: 2, - ..TopicInstallBundle::default() + 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()), + }, } } #[test] - fn the_arch_default_topic_validates_and_plans_disabled() { + fn the_arch_default_bundle_plans_against_the_existing_admin_route() { let bundle = tb4(); - bundle.validate().expect("tb4 validates"); + bundle.validate_shape().expect("validates"); let plan = bundle.plan(InstallEnvironment::Metal).expect("plan"); assert_eq!(plan.topic_id, "tb4"); - assert_eq!(plan.aliases, ["tbench"]); assert_eq!(plan.environment, InstallEnvironment::Metal); - assert!(!plan.enabled, "install never enables a topic"); + 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 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), + 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_eq!(word.parse::().expect(word), want); + assert!( + !keys.iter().any(|k| k.as_str() == forbidden), + "the bundle must not duplicate topic data: {forbidden}" + ); } - 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}"); + 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, "topic_id": "tb4", "display_name": "x", - "version": 1, "environment": "metal", "task_slice": "tb4-first-15" + "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("task_slice")), + 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}" ); } @@ -624,20 +737,24 @@ mod tests { fn required_keys_are_named_when_absent() { for (body, missing) in [ ( - r#"{"schema_version":1,"display_name":"x","version":1,"environment":"metal"}"#, - "topic_id", + r#"{"environment":"metal","display_name":"x","topic":{},"host":{}}"#, + "schema_version", + ), + ( + r#"{"schema_version":1,"display_name":"x","topic":{},"host":{}}"#, + "environment", ), ( - r#"{"schema_version":1,"topic_id":"tb4","version":1,"environment":"metal"}"#, + r#"{"schema_version":1,"environment":"metal","topic":{},"host":{}}"#, "display_name", ), ( - r#"{"schema_version":1,"topic_id":"tb4","display_name":"x","environment":"metal"}"#, - "version", + r#"{"schema_version":1,"environment":"metal","display_name":"x","host":{}}"#, + "topic", ), ( - r#"{"schema_version":1,"topic_id":"tb4","display_name":"x","version":1}"#, - "environment", + r#"{"schema_version":1,"environment":"metal","display_name":"x","topic":{}}"#, + "host", ), ] { let err = TopicInstallBundle::from_json(body).expect_err(missing); @@ -649,66 +766,68 @@ mod tests { } #[test] - fn ids_pins_and_bounds_are_checked() { - let mut bundle = tb4(); - bundle.topic_id = "TB4".into(); - assert!(matches!(bundle.validate(), Err(BundleError::BadTopicId(_)))); - bundle = tb4(); - bundle.topic_id = "tbench_tb4".into(); - assert!(matches!(bundle.validate(), Err(BundleError::BadTopicId(_)))); - bundle = tb4(); - bundle.display_name = " ".into(); - assert!(matches!( - bundle.validate(), - Err(BundleError::BadDisplayName) - )); - bundle = tb4(); - bundle.version = 0; - assert!(matches!(bundle.validate(), Err(BundleError::BadVersion))); - bundle = tb4(); - bundle.n_concurrent = 0; - assert!(matches!( - bundle.validate(), - Err(BundleError::BadConcurrency) - )); - bundle = tb4(); - bundle.schema_version = 2; - assert!(matches!( - bundle.validate(), - Err(BundleError::WrongSchema { got: 2, want: 1 }) - )); + 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_digest_is_never_invented() { - for bad in ["", "abc", HEX, "sha256:", "sha256:zz", "sha512:dead"] { - let mut bundle = tb4(); - bundle.pin_rlm = Some(bad.into()); - assert!( - matches!( - bundle.validate(), - Err(BundleError::BadDigest { - field: "pin_rlm", - .. - }) - ), - "{bad:?} must be refused" - ); - } - // Absent is the only alternative to a well-formed digest. - let mut unpinned = tb4(); - unpinned.pin_rlm = None; - unpinned.pin_experiment = None; - unpinned - .validate() - .expect("unpinned is legal, not defaulted"); + 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.pack_digest = None; - let err = no_pack.validate().expect_err("runner without pack"); + 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"), @@ -716,318 +835,206 @@ mod tests { ); 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.runner_id = None; + orphan + .topic + .constraints + .params + .remove(proof_experiment::PARAM_RUNNER); + orphan + .topic + .constraints + .params + .remove(proof_experiment::PARAM_PACK_DIGEST); assert!(matches!( - orphan.validate(), + orphan.validate_shape(), Err(BundleError::PackWithoutRunner) )); - let mut bad_id = tb4(); - bad_id.runner_id = Some("Runner With Spaces".into()); - assert!(matches!( - bad_id.validate(), - Err(BundleError::BadRunnerId(_)) - )); - // No runner, no pack: the harvest-family shape is legal. let mut harvest = tb4(); - harvest.runner_id = None; - harvest.pack_digest = None; - harvest.validate().expect("a topic may select no runner"); + 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 aliases_are_slugs_unique_and_never_the_topic_id() { - let mut dup = tb4(); - dup.aliases = vec!["tbench".into(), "tbench".into()]; - assert!(matches!( - dup.validate(), - Err(BundleError::BadAlias { - why: "duplicate alias", - .. - }) - )); - - let mut self_alias = tb4(); - self_alias.aliases = vec!["tb4".into()]; - assert!(matches!( - self_alias.validate(), - Err(BundleError::BadAlias { - why: "an alias of the topic id itself is not an alias", - .. - }) - )); - - let mut malformed = tb4(); - malformed.aliases = vec!["TBench".into()]; - assert!(matches!( - malformed.validate(), - Err(BundleError::BadAlias { .. }) - )); - - let mut many = tb4(); - many.aliases = (0..=MAX_ALIASES).map(|i| format!("alias-{i}")).collect(); - assert!(matches!( - many.validate(), - Err(BundleError::TooManyAliases(9)) - )); - - // Order does not matter: the plan sorts and de-duplicates. - let mut two = tb4(); - two.aliases = vec!["zeta".into(), "alpha".into()]; - assert_eq!( - two.plan(InstallEnvironment::Metal).expect("plan").aliases, - ["alpha", "zeta"] + 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(), ); - } - - #[test] - fn a_baseline_must_be_finite_and_config_must_be_a_bounded_object() { - let mut nan = tb4(); - nan.sealed_custom_value = Some(f64::NAN); - assert!(matches!( - nan.validate(), - Err(BundleError::NonFiniteSealedValue(_)) - )); - let mut inf = tb4(); - inf.sealed_custom_value = Some(f64::INFINITY); + 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!( - inf.validate(), - Err(BundleError::NonFiniteSealedValue(_)) + bad_runner.validate_shape(), + Err(BundleError::Binding(_)) )); - let mut sealed = tb4(); - sealed.sealed_custom_value = Some(0.42); - sealed.validate().expect("a finite baseline is fine"); - let mut list = tb4(); - list.config = serde_json::json!([1, 2]); - assert!(matches!( - list.validate(), - Err(BundleError::ConfigNotObject("an array")) - )); - let mut huge = tb4(); - huge.config = serde_json::json!({ "pad": "x".repeat(MAX_CONFIG_BYTES) }); - assert!(matches!( - huge.validate(), - Err(BundleError::ConfigTooLarge(_)) - )); - let mut null = tb4(); - null.config = Value::Null; + // 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!( - null.validate(), - Err(BundleError::ConfigNotObject("null")) + no_runner.validate_shape(), + Err(BundleError::PackWithoutRunner) )); } #[test] - fn defaults_are_the_fail_closed_reading() { - let body = r#"{ - "schema_version": 1, "topic_id": "tb4", "display_name": "Terminal-Bench 4", - "version": 1, "environment": "staging" - }"#; - let bundle = TopicInstallBundle::from_json(body).expect("parse"); - assert!(bundle.aliases.is_empty()); - assert!(bundle.runner_id.is_none()); - assert!(bundle.pin_rlm.is_none()); - assert!(bundle.pack_digest.is_none()); - assert_eq!(bundle.n_concurrent, 1); - assert!(bundle.sealed_custom_value.is_none()); - assert_eq!(bundle.config, Value::Object(serde_json::Map::new())); - bundle.validate().expect("defaults validate"); - } - - #[test] - fn the_digest_ignores_formatting_and_key_order() { - let compact = r#"{"schema_version":1,"topic_id":"tb4","display_name":"Terminal-Bench 4","version":1,"environment":"metal"}"#; - let spaced = r#"{ - "environment": "metal", - "version": 1, - "display_name": "Terminal-Bench 4", - "topic_id": "tb4", - "schema_version": 1 - }"#; - let a = TopicInstallBundle::from_json(compact).expect("a"); - let b = TopicInstallBundle::from_json(spaced).expect("b"); - assert_eq!(a.digest().expect("digest a"), b.digest().expect("digest b")); - - // Any real change is a different install, so a different digest. - let mut changed = a.clone(); - changed.n_concurrent = 3; - assert_ne!( - a.digest().expect("a"), - changed.digest().expect("changed"), - "a changed bundle must not hash the same" - ); - let mut renamed = a; - renamed.topic_id = "tb5".into(); - assert_ne!( - b.digest().expect("b"), - renamed.digest().expect("renamed"), - "the topic id is part of the identity" + 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}"); - #[test] - fn the_digest_is_stable_and_matches_a_pinned_vector() { - // A literal vector: if the canonical form or the digest algorithm ever - // drifts, this test fails rather than silently re-pinning every topic. - let body = r#"{"schema_version":1,"topic_id":"tb4","display_name":"Terminal-Bench 4","version":1,"environment":"metal"}"#; - let bundle = TopicInstallBundle::from_json(body).expect("parse"); - let canonical = bundle.canonical().expect("canonical"); + // 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!( - canonical, - r#"{"aliases":[],"config":{},"display_name":"Terminal-Bench 4","environment":"metal","n_concurrent":1,"schema_version":1,"topic_id":"tb4","version":1}"# + bundle.registered_custom(), + ["other_metric", "tbench", "third"] ); - let digest = bundle.digest().expect("digest"); - assert_eq!(digest, format!("{DIGEST_PREFIX}{}", sha256_hex(&canonical))); - } - - fn sha256_hex(s: &str) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(s.as_bytes()); - hex::encode(h.finalize()) + assert!(parse_custom_ids(" ").is_empty()); } - /// The row's `CHECK` is `^sha256:[0-9a-f]{64}$`, so anything this - /// validator accepts has to be exactly that. Accepting an uppercase or - /// padded pin would let a bundle validate and dry-run and then fail a real - /// install — the operator would find out only on the host that matters. #[test] - fn digest_pins_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}"), + 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), ] { - for field in ["pin_rlm", "pin_experiment", "pack_digest"] { - let mut bundle = tb4(); - match field { - "pin_rlm" => bundle.pin_rlm = Some(bad.clone()), - "pin_experiment" => bundle.pin_experiment = Some(bad.clone()), - _ => bundle.pack_digest = Some(bad.clone()), - } - assert!( - matches!( - bundle.validate(), - Err(BundleError::BadDigest { field: f, .. }) if f == field - ), - "{field}={bad:?} must be refused, not silently accepted" - ); - } + assert_eq!(word.parse::().expect(word), want); } - // The canonical spelling still validates, so this is strictness - // rather than a blanket rejection. - tb4().validate().expect("lowercase hex validates"); - assert!(is_lower_hex64(HEX)); - assert!(!is_lower_hex64(&upper)); - assert!(!is_lower_hex64(&format!(" {HEX}"))); + assert!("prod".parse::().is_err()); + assert_eq!(InstallEnvironment::Staging.as_str(), "staging"); } - /// The row's columns are `INTEGER`. A value that would not fit is a - /// reject, never a clamp: a clamped row would disagree with the - /// digest-covered bundle the operator reviewed. #[test] - fn numeric_columns_out_of_range_are_refused_not_clamped() { - let mut big_version = tb4(); - big_version.version = MAX_INT_COLUMN + 1; - let err = big_version.validate().expect_err("version overflow"); + 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::IntColumnOverflow { - field: "version", - got: _ + BundleError::EnvironmentMismatch { + bundle: InstallEnvironment::Staging, + requested: InstallEnvironment::Metal, } ), "{err:?}" ); - assert!( - err.to_string().contains("refused rather than clamped"), - "{err}" - ); - - let mut big_concurrency = tb4(); - big_concurrency.n_concurrent = u32::MAX; - assert!( - matches!( - big_concurrency.validate(), - Err(BundleError::IntColumnOverflow { - field: "n_concurrent", - .. - }) - ), - "n_concurrent overflow" - ); - - // The boundary itself is legal. - let mut at_limit = tb4(); - at_limit.version = MAX_INT_COLUMN; - at_limit.n_concurrent = MAX_INT_COLUMN; - at_limit.validate().expect("i32::MAX fits the column"); - assert_eq!(MAX_INT_COLUMN, i32::MAX as u32); + assert!(err.to_string().contains("staging"), "{err}"); } - /// The advertised limit is on the `config` object. A legal bundle with - /// long metadata must not be rejected for a small config, and the error - /// must report the config's own size rather than the bundle's. #[test] - fn the_config_bound_measures_the_config_not_the_bundle() { - // A small config inside a large-but-legal bundle. - let mut bundle = tb4(); - bundle.display_name = "x".repeat(MAX_DISPLAY_NAME_LEN); - bundle.aliases = (0..MAX_ALIASES).map(|i| format!("alias-{i}")).collect(); - bundle.config = serde_json::json!({ "task_slice": "tb4-first-15" }); - bundle - .validate() - .expect("a small config in a large bundle is fine"); - - // Over the limit is refused, and the number is the config's own size. - let mut over = tb4(); - over.config = serde_json::json!({ "pad": "x".repeat(MAX_CONFIG_BYTES) }); - let err = over.validate().expect_err("oversized config"); - let BundleError::ConfigTooLarge(reported) = err else { - panic!("expected ConfigTooLarge, got {err:?}"); - }; - let config_bytes = proof_canon::canonical_json(&over.config).len(); + 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!( - reported, config_bytes, - "the error must report the config's size" + bundle.digest().expect("digest"), + reparsed.digest().expect("digest"), + "a round trip is the same bundle" ); - assert!(config_bytes > MAX_CONFIG_BYTES); - // Exactly at the limit passes. - let mut at_limit = tb4(); - let overhead = proof_canon::canonical_json(&serde_json::json!({ "pad": "" })).len(); - at_limit.config = serde_json::json!({ "pad": "x".repeat(MAX_CONFIG_BYTES - overhead) }); - assert_eq!( - proof_canon::canonical_json(&at_limit.config).len(), - MAX_CONFIG_BYTES + // 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" ); - at_limit.validate().expect("exactly at the limit passes"); } + /// `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 the_schema_key_list_matches_the_type() { - // A full bundle with every key set: the serialized form must carry - // exactly `BUNDLE_KEYS`, and each one must round-trip. - let bundle = TopicInstallBundle { - sealed_custom_value: Some(0.5), - ..tb4() - }; - let value = serde_json::to_value(&bundle).expect("serialize"); - let mut keys: Vec = value.as_object().expect("object").keys().cloned().collect(); - keys.sort_unstable(); - assert_eq!(keys, BUNDLE_KEYS, "the schema key list drifted"); - for key in REQUIRED_BUNDLE_KEYS { - assert!(keys.iter().any(|k| k == key), "{key} must be required"); + 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] @@ -1035,7 +1042,10 @@ mod tests { 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#""enabled":false"#), "{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 561593dbd..b799aae28 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,7 +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`, `topic install [--dry-run]`, `topic list`, `topic show`. Writes a **disabled** row in `proof_topic`; `topic enable` / `disable` / `seal` exit 3 (not implemented in this slice). No route, allocator, or scoring path reads it yet | +| `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) | | `trustroot` | Offline keygen / sign / verify for owner-signed TOML | | `bundle` | SCALE types, seal, verify (`PROTOCOL_VERSION`) | | `aggregate` | Integer aggregation (Hamilton house 65535) | @@ -83,7 +83,7 @@ terminates in the host reverse proxy, not in the gateway process. | `trustroot` (lib) | Load local signed challenges/measurements; dual-accept rotation | | `base-attest-*` | Parse / replay / policy for TDX quotes (bundle measurement pin) | | `crosscheck` / `dissent` | Peer roots and three-outcome policy | -| `db` | Postgres persistence (bundles, evidence, dissent, challenge tables, `proof_topic` install registry) | +| `db` | Postgres persistence (bundles, evidence, dissent, challenge tables) | | `xtask` | loc-cap, consensus-lint, metadata-snapshot, spec / design / external-docs gates | --- diff --git a/docs/COMPLETENESS.md b/docs/COMPLETENESS.md index 0d3e9627c..cb6d08164 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -81,7 +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** | `proof_topic` (migration `0024`, shared challenge DB, `topic_id` discriminant) plus `bins/proof-admin` (`topic validate`, `topic install [--dry-run]`, `topic list`, `topic show`). Bundle schema v1 lives in `crates/proof-topic-bundle` (unknown keys refused, pins `sha256:<64 lowercase hex>` or absent, an in-guest `runner_id` requires a `pack_digest`, `version` / `n_concurrent` over `i32::MAX` refused rather than clamped). **Installing writes `enabled = false` and no scoring path reads the table**, so this cannot move a score; `topic enable` / `disable` / `seal` exit 3 as not-implemented. No route change (P1), allocator change (P2), full install (P3), or removal of the compiled-in topic bindings (P4). First topic slug `tb4`, alias `tbench` (alias resolution is a later slice). | +| 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`. **No new table, no new route, no behavior 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). First topic slug `tb4`, custom id `tbench`. | | 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 6acc77d57..6b325a046 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -197,51 +197,57 @@ Empty digest stays 503 (never invent a sha256). ## Topic install bundles (`proof-admin`, P0 skeleton) -A signed topic document is the *scoring contract*. The **install** is a -separate operator record: which runner the topic names, which RLM and -experiment images and which experiment pack it is pinned to, how much -concurrency it may use, and whether it is live. `bins/proof-admin` is the -operator CLI for that record, and `proof_topic` -([migration `0024`](../crates/db/migrations/0024_proof_topics.sql)) is where -it lands — one row per `topic_id`, in the shared challenge DB. +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. Reads the file; writes nothing; needs no database. -proof-admin topic validate --bundle /root/.base-secrets/proof/tb4.json +# 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 an install without touching anything. +# Resolve the publish call and host env. Touches nothing. proof-admin topic install --bundle …/tb4.json --env metal --dry-run -# Install it. Writes one DISABLED row; needs BASE_DATABASE_URL (or _FILE). -BASE_DATABASE_URL=… proof-admin topic install --bundle …/tb4.json --env metal -proof-admin topic list -proof-admin topic show tb4 +# 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 ``` -`--env` is `staging` or `metal` and must match the bundle's own -`environment`: a bundle written for one target is refused on the other -rather than coerced. Every pin is `sha256:<64 lowercase hex>` or absent, and -an in-guest `runner_id` without a `pack_digest` is refused, so a topic that -names a runner with nothing to run never installs. Unknown keys are refused -at parse: a binding this build cannot name is a binding nothing enforces. - -A value the row cannot hold is a **reject, never a rewrite**: `version` and -`n_concurrent` above `i32::MAX` are refused rather than clamped (a clamped -row would disagree with the digest-covered bundle an operator reviewed), and -a pin is accepted only in the exact lowercase, unpadded spelling the -column's `CHECK` requires — so a bundle that validates also installs. -Re-installing reports the **persisted** state: a topic that was already live -stays live, and the output says so rather than assuming the install disabled -it. - -**P0 scope — what this does not do.** Installing writes `enabled = false` -and no scoring path reads `proof_topic` yet, so an install cannot move a -score. `topic enable`, `topic disable`, and `topic seal` exit **3** with a -"not implemented in this slice" message. There is no route change (P1), no -allocator change (P2), no full install (P3), and no removal of the -compiled-in topic bindings (P4). The first topic slug is **`tb4`** with -alias **`tbench`**; alias resolution is a later slice, so `topic show` -matches the exact `topic_id` today and says so when it misses. +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. + +**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). The first topic +slug is **`tb4`**; its custom id is **`tbench`**, which is the runner-registry +id, not an alias table — `topic show` matches the exact `topic_id`. ## Metric families From c36def43abb48876d16438e1e70a116aa92e61d1 Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:31:13 +0000 Subject: [PATCH 05/17] fix(proof): print a runnable publish procedure in the dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: the dry run printed `--data-binary @`, which is not shell syntax, and substituting the bundle file would fail too — the publish route takes a `TopicDocument`, not the bundle envelope. So the advertised procedure could not actually be run. The plan now prints two real steps: 1. `jq '.topic' '' > /tmp/proof-topic-document.json && proof-admin topic validate --bundle '' --pin config/proof-pin.toml` 2. the `curl` against the existing route, reading that extracted file Paths are single-quoted for `sh` with embedded quotes escaped, so a path with a space cannot turn the printed command into a different one. The test asserts the extraction step passes `sh -n` and that no ` --- bins/proof-admin/src/main.rs | 59 ++++++++++++++++++---------- bins/proof-admin/tests/cli.rs | 27 ++++++++++++- crates/proof-topic-bundle/src/lib.rs | 3 ++ 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/bins/proof-admin/src/main.rs b/bins/proof-admin/src/main.rs index 025446edb..31d4c1aa3 100644 --- a/bins/proof-admin/src/main.rs +++ b/bins/proof-admin/src/main.rs @@ -39,7 +39,7 @@ 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}; +use proof_topic_bundle::{InstallEnvironment, TopicInstallBundle, TopicInstallPlan, PUBLISH_PATH}; /// Successful run. const EXIT_OK: u8 = 0; @@ -390,23 +390,19 @@ fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { println!(" bundle_digest {}", plan.bundle_digest); println!(" pin {}", pin_path.display()); println!(); - println!("1) Publish the signed document (existing route, operator bearer):"); - println!( - " curl -sS -X {} \\", - plan.publish_route.split(' ').next().unwrap_or("POST") - ); - println!(" -H \"Authorization: Bearer $PROOF_ADMIN_TOKEN\" \\"); - println!(" -H 'content-type: application/json' \\"); - println!( - " --data-binary @{} \\", - signed_document_hint(bundle_path) - ); - println!(" /challenge/proof/v1/admin/proof/topics"); + let (extract, publish) = publish_steps(bundle_path); + println!("1) Extract the signed document (the route takes a TopicDocument, not the bundle):"); + println!(" {extract}"); + println!(); + println!("2) Publish it (existing route, operator bearer):"); + for line in publish.lines() { + println!(" {line}"); + } println!(); if plan.host_env.is_empty() { - println!("2) Host env: nothing extra is required for this topic."); + println!("3) Host env: nothing extra is required for this topic."); } else { - println!("2) Set these on the master before the topic can score:"); + 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); @@ -414,13 +410,34 @@ fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { } } -/// Where the signed document is expected to live, given the bundle path. +/// The runnable steps that publish a bundle, as shell. +/// +/// The publish route takes a `TopicDocument`, **not** the bundle envelope, so +/// the procedure has to extract `topic` first. Both commands are printed as +/// real, copy-pasteable shell: a placeholder an operator has to hand-edit is +/// not a procedure. +fn publish_steps(bundle_path: &Path) -> (String, String) { + let bundle = shell_single_quote(&bundle_path.display().to_string()); + let extract = format!( + "jq '.topic' {bundle} > /tmp/proof-topic-document.json && \ + proof-admin topic validate --bundle {bundle} --pin config/proof-pin.toml" + ); + let publish = format!( + "curl -sS -X POST \\\n \ + -H \"Authorization: Bearer $PROOF_ADMIN_TOKEN\" \\\n \ + -H 'content-type: application/json' \\\n \ + --data-binary @/tmp/proof-topic-document.json \\\n \ + {PUBLISH_PATH}" + ); + (extract, publish) +} + +/// Single-quote a path for `sh`, escaping any embedded quote. /// -/// The bundle carries the document inline; the publish call posts the document -/// itself, so the hint names the bundle and lets the operator extract it. This -/// never invents a path that does not exist. -fn signed_document_hint(bundle_path: &Path) -> String { - format!("", bundle_path.display()) +/// 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> { diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs index 2e4bd8223..cf8f72cb1 100644 --- a/bins/proof-admin/tests/cli.rs +++ b/bins/proof-admin/tests/cli.rs @@ -337,7 +337,11 @@ fn dry_run_install_prints_the_existing_publish_call_and_host_env() { "environment metal", "custom_id tbench", "runner_id rlm_fc_in_guest_harbor", - "Publish the signed document (existing route, operator bearer)", + "Extract the signed document (the route takes a TopicDocument, not the bundle)", + "jq '.topic'", + "> /tmp/proof-topic-document.json", + "Publish it (existing route, operator bearer)", + "--data-binary @/tmp/proof-topic-document.json", "/challenge/proof/v1/admin/proof/topics", "PROOF_VM_RUNNER_CUSTOM_IDS=tbench", "PROOF_RLM_VM_IMAGE_DIGEST=sha256:", @@ -358,6 +362,27 @@ fn dry_run_install_prints_the_existing_publish_call_and_host_env() { 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: the route takes a TopicDocument, so the step extracts it. + assert!( + !body.contains(" Date: Mon, 14 Sep 2026 12:42:51 +0000 Subject: [PATCH 06/17] fix(proof): publish block uses a private mktemp dir, not a shared path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: the printed procedure wrote the extracted document to a fixed `/tmp/proof-topic-document.json` and published it in a later step. Any local process could replace that file between validation and publication, so the document the route received would not be the one the operator validated — and the route is authenticated, which makes it a real substitution path. Extraction and publication are now **one block**: PROOF_TOPIC_DIR=$(mktemp -d) \ && jq '.topic' '' > "$PROOF_TOPIC_DIR/document.json" \ && chmod 600 "$PROOF_TOPIC_DIR/document.json" \ && curl … --data-binary @"$PROOF_TOPIC_DIR/document.json" … \ && rm -rf "$PROOF_TOPIC_DIR" `mktemp -d` creates the directory 0700, the document is 0600, and the path variable and the file it names cannot drift apart because they are the same shell block. The test now asserts the private directory is used, that the fixed shared path is gone, that extraction and publication are one block, and that the block passes `sh -n`. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- bins/proof-admin/src/main.rs | 61 +++++++++++++++++++++-------------- bins/proof-admin/tests/cli.rs | 41 ++++++++++++++++------- 2 files changed, 66 insertions(+), 36 deletions(-) diff --git a/bins/proof-admin/src/main.rs b/bins/proof-admin/src/main.rs index 31d4c1aa3..8ca499edd 100644 --- a/bins/proof-admin/src/main.rs +++ b/bins/proof-admin/src/main.rs @@ -390,19 +390,17 @@ fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { println!(" bundle_digest {}", plan.bundle_digest); println!(" pin {}", pin_path.display()); println!(); - let (extract, publish) = publish_steps(bundle_path); - println!("1) Extract the signed document (the route takes a TopicDocument, not the bundle):"); - println!(" {extract}"); - println!(); - println!("2) Publish it (existing route, operator bearer):"); - for line in publish.lines() { + println!("1) 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."); + println!("2) Host env: nothing extra is required for this topic."); } else { - println!("3) Set these on the master before the topic can score:"); + println!("2) Set these on the master before the topic can score:"); for var in &plan.host_env { println!(" {}={}", var.name, var.value); println!(" # {}", var.why); @@ -410,26 +408,39 @@ fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { } } -/// The runnable steps that publish a bundle, as shell. +/// 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. Both commands are printed as -/// real, copy-pasteable shell: a placeholder an operator has to hand-edit is -/// not a procedure. -fn publish_steps(bundle_path: &Path) -> (String, String) { +/// 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()); - let extract = format!( - "jq '.topic' {bundle} > /tmp/proof-topic-document.json && \ - proof-admin topic validate --bundle {bundle} --pin config/proof-pin.toml" - ); - let publish = format!( - "curl -sS -X POST \\\n \ - -H \"Authorization: Bearer $PROOF_ADMIN_TOKEN\" \\\n \ - -H 'content-type: application/json' \\\n \ - --data-binary @/tmp/proof-topic-document.json \\\n \ - {PUBLISH_PATH}" - ); - (extract, publish) + 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. diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs index cf8f72cb1..0144b341b 100644 --- a/bins/proof-admin/tests/cli.rs +++ b/bins/proof-admin/tests/cli.rs @@ -337,11 +337,11 @@ fn dry_run_install_prints_the_existing_publish_call_and_host_env() { "environment metal", "custom_id tbench", "runner_id rlm_fc_in_guest_harbor", - "Extract the signed document (the route takes a TopicDocument, not the bundle)", + "Publish the signed document (one block; existing route, operator bearer)", "jq '.topic'", - "> /tmp/proof-topic-document.json", - "Publish it (existing route, operator bearer)", - "--data-binary @/tmp/proof-topic-document.json", + "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:", @@ -363,25 +363,44 @@ fn dry_run_install_prints_the_existing_publish_call_and_host_env() { "the token must stay a placeholder:\n{body}" ); // The procedure must be runnable shell, not a placeholder an operator has - // to hand-edit: the route takes a TopicDocument, so the step extracts it. + // to hand-edit. assert!( !body.contains(" = body .lines() - .find(|l| l.trim_start().starts_with("jq '.topic'")) - .expect("an extraction step"); - let extract = extract.trim(); + .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(extract) + .arg(&script) .status() .expect("sh -n"); assert!( status.success(), - "the printed extraction step must be valid shell: {extract}" + "the printed publish block must be valid shell: {script}" ); fs::remove_dir_all(&dir).ok(); } From 58105ce44347d9dabf8aab9db1b4bee717159d3f Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:56:41 +0000 Subject: [PATCH 07/17] feat(proof): topic alias map + Owner-only metal install gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner defaults locked: first topic slug `tb4` with temporary alias `tbench`; shared challenge DB with a `topic_id` discriminant (not a per-topic schema); metal `topic install` is Owner-only, staging first. Alias (migration `0024_proof_topic_alias.sql`): - A row is `alias -> topic_id` and nothing else: no name, no pins, no status, no document. Every one of those stays in `proof_topic_version`, so an alias cannot drift from the topic it names, and retiring it is deleting the row. This is not a second topic table. - Shape CHECKs (slug, not-self) plus a `topic_id` index. `base_app` gets DELETE here because retiring a temporary alias is the intended end state — unlike the journal tables. - `RlmStore::{put_alias, resolve_alias, aliases_for, delete_alias}`, in both stores, pinned by the shared contract test. Resolution is fail-closed on both sides: an alias whose topic has no published version resolves to **nothing** (`None`), never to an empty document, and `put_alias` refuses a topic that is not published yet. - `proof-admin topic alias set|list|rm`, and `topic show` resolves an alias to its canonical slug and says which topic it hit. Owner-only metal gate: - `--env metal` is refused (exit 2) without `--owner-metal-ack`, which asserts that an Owner authorized the install and that staging passed for that bundle. The refusal names the staging command to run first. - `--env staging` is never gated. The plan output states which gate applied (`owner_gate acknowledged` / `n/a (staging)`). Docs record the locked defaults, including that `tbench` is two different bindings: the temporary alias (retirable) and the runner registry's custom id (the scoring binding, permanent). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- bins/proof-admin/src/main.rs | 147 +++++++++++++++- bins/proof-admin/tests/cli.rs | 162 ++++++++++++++++++ .../db/migrations/0024_proof_topic_alias.sql | 45 +++++ crates/proof-rlm-store/src/lib.rs | 33 ++++ crates/proof-rlm-store/src/memory.rs | 34 ++++ crates/proof-rlm-store/src/pg.rs | 54 ++++++ .../proof-rlm-store/tests/store_contract.rs | 46 +++++ docs/ARCHITECTURE.md | 2 +- docs/COMPLETENESS.md | 2 +- docs/PROOF.md | 40 ++++- 10 files changed, 553 insertions(+), 12 deletions(-) create mode 100644 crates/db/migrations/0024_proof_topic_alias.sql diff --git a/bins/proof-admin/src/main.rs b/bins/proof-admin/src/main.rs index 8ca499edd..8cd8f8192 100644 --- a/bins/proof-admin/src/main.rs +++ b/bins/proof-admin/src/main.rs @@ -125,14 +125,25 @@ enum TopicCmd { /// 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 by its exact `topic_id`. + /// Show one installed topic. An alias resolves to its topic. Show { - /// Topic slug. + /// 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. @@ -153,6 +164,29 @@ enum TopicCmd { }, } +#[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 { @@ -220,9 +254,11 @@ async fn run_topic(opts: &Options, cmd: &TopicCmd) -> Result<(), Failure> { env, pin, dry_run, - } => cmd_install(opts, 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( @@ -232,6 +268,66 @@ async fn run_topic(opts: &Options, cmd: &TopicCmd) -> Result<(), Failure> { } } +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!( @@ -338,9 +434,23 @@ fn cmd_install( 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) @@ -389,6 +499,11 @@ fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { ); 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) Publish the signed document (one block; existing route, operator bearer):"); println!(" # The route takes a TopicDocument, not the bundle envelope, so this"); @@ -477,20 +592,38 @@ async fn cmd_list(opts: &Options) -> Result<(), Failure> { 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(topic_id) + .latest_topic(canonical) .await - .map_err(|e| Failure::Error(format!("show {topic_id}: {e}")))?; + .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." + "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: topic_id.to_owned(), + 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(()); diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs index 0144b341b..fb7c8a2b7 100644 --- a/bins/proof-admin/tests/cli.rs +++ b/bins/proof-admin/tests/cli.rs @@ -325,6 +325,7 @@ fn dry_run_install_prints_the_existing_publish_call_and_host_env() { bundle.to_str().unwrap(), "--env", "metal", + "--owner-metal-ack", "--pin", pin.to_str().unwrap(), "--dry-run", @@ -478,6 +479,7 @@ fn a_real_install_is_not_implemented_and_changes_nothing() { bundle.to_str().unwrap(), "--env", "metal", + "--owner-metal-ack", "--pin", pin.to_str().unwrap(), ]); @@ -489,6 +491,166 @@ fn a_real_install_is_not_implemented_and_changes_nothing() { 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"]] { 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..1ef80e8b0 --- /dev/null +++ b/crates/db/migrations/0024_proof_topic_alias.sql @@ -0,0 +1,45 @@ +-- 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. +-- +-- `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); + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE proof_topic_alias TO base_app; diff --git a/crates/proof-rlm-store/src/lib.rs b/crates/proof-rlm-store/src/lib.rs index cb0856df8..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 { @@ -206,6 +221,24 @@ pub trait RlmStore: Send + Sync { /// 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 5cab333bb..f7bad06c6 100644 --- a/crates/proof-rlm-store/src/memory.rs +++ b/crates/proof-rlm-store/src/memory.rs @@ -23,6 +23,7 @@ struct Inner { baselines: BTreeMap>, artefacts: BTreeMap>, promotions: BTreeMap>, + aliases: BTreeMap, } /// In-memory store. @@ -82,6 +83,39 @@ impl RlmStore for MemoryRlmStore { 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" + ))); + } + 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. + 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 2be4584ec..c8e466d26 100644 --- a/crates/proof-rlm-store/src/pg.rs +++ b/crates/proof-rlm-store/src/pg.rs @@ -218,6 +218,60 @@ impl RlmStore for PgRlmStore { Ok(out) } + async fn put_alias(&self, alias: &str, topic_id: &str) -> Result<(), StoreError> { + // 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. + if self.latest_topic(topic_id).await?.is_none() { + return Err(StoreError::Malformed(format!( + "alias {alias:?} names topic {topic_id:?}, which has no published version" + ))); + } + 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(&self.pool) + .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( + "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)", + ) + .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 9c206f26b..0ac72ba1d 100644 --- a/crates/proof-rlm-store/tests/store_contract.rs +++ b/crates/proof-rlm-store/tests/store_contract.rs @@ -48,6 +48,52 @@ async fn contract(store: &dyn RlmStore) { "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 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()); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b799aae28..f5d69238b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,7 +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) | +| `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). 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 cb6d08164..1dfa7b913 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -81,7 +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`. **No new table, no new route, no behavior 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). First topic slug `tb4`, custom id `tbench`. | +| 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`. **No new table, no new route, no behavior 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. | | 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 6b325a046..40a83d8e6 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -239,15 +239,49 @@ 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. +### Locked defaults + +| Default | Value | Where | +|---------|-------|-------| +| First topic slug | **`tb4`** | the signed document's `id` | +| Temporary alias | **`tbench`** | `proof_topic_alias` row `tbench → tb4` | +| 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` | + +`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). The first topic -slug is **`tb4`**; its custom id is **`tbench`**, which is the runner-registry -id, not an alias table — `topic show` matches the exact `topic_id`. +(P3), and no removal of the compiled-in topic bindings (P4). ## Metric families From f0a0c6ddecf62e068e15960972e67cd654e32ef2 Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:08:21 +0000 Subject: [PATCH 08/17] fix(proof): a canonical slug is never shadowed by an alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: an alias could take another published topic's canonical slug, so `topic show ` resolved through the alias and returned a *different* topic's signed document. An alias must never change what a published id means. Guarded in both directions, at three layers: - **Write** (`put_alias`): refuse an alias that is itself a published topic id, and refuse a topic that is not published (already the rule). - **Read** (`resolve_alias`): the Postgres lookup gains `AND NOT EXISTS (… WHERE s.topic_id = a.alias)`, so a row written before this guard still cannot shadow a canonical slug. The memory store returns `None` when the alias names a published topic. - **Schema** (`0024_proof_topic_alias.sql`): a `BEFORE INSERT OR UPDATE` trigger on `proof_topic_alias` *and* on `proof_topic_version`, because a writer going straight to SQL bypasses the store. `topic_id` alone is not unique in `proof_topic_version` (keyed `(topic_id, version)`), so this cannot be a UNIQUE constraint. Both trigger directions were probed against Postgres: claiming a published slug as an alias is refused, and publishing a topic whose id an existing alias claims is refused, with neither probe writing a row. Tests: the shared contract now covers the collision (refused at write, `None` at read) for both stores. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../db/migrations/0024_proof_topic_alias.sql | 37 +++++++++++++++++++ crates/proof-rlm-store/src/memory.rs | 12 ++++++ crates/proof-rlm-store/src/pg.rs | 17 ++++++++- .../proof-rlm-store/tests/store_contract.rs | 22 +++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/db/migrations/0024_proof_topic_alias.sql b/crates/db/migrations/0024_proof_topic_alias.sql index 1ef80e8b0..fa629bfc6 100644 --- a/crates/db/migrations/0024_proof_topic_alias.sql +++ b/crates/db/migrations/0024_proof_topic_alias.sql @@ -21,6 +21,14 @@ -- 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. @@ -42,4 +50,33 @@ CREATE TABLE proof_topic_alias ( -- 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. +CREATE OR REPLACE FUNCTION proof_topic_alias_no_shadow() RETURNS trigger AS $$ +BEGIN + IF TG_TABLE_NAME = 'proof_topic_alias' THEN + IF EXISTS (SELECT 1 FROM proof_topic_version WHERE topic_id = NEW.alias) THEN + RAISE EXCEPTION 'alias % is already a published topic id', NEW.alias + USING ERRCODE = 'check_violation'; + END IF; + ELSE + IF EXISTS (SELECT 1 FROM proof_topic_alias WHERE alias = NEW.topic_id) THEN + RAISE EXCEPTION 'topic % is already claimed as an alias', NEW.topic_id + 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/src/memory.rs b/crates/proof-rlm-store/src/memory.rs index f7bad06c6..d47678069 100644 --- a/crates/proof-rlm-store/src/memory.rs +++ b/crates/proof-rlm-store/src/memory.rs @@ -90,6 +90,13 @@ impl RlmStore for MemoryRlmStore { "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(()) } @@ -97,6 +104,11 @@ impl RlmStore for MemoryRlmStore { 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)) diff --git a/crates/proof-rlm-store/src/pg.rs b/crates/proof-rlm-store/src/pg.rs index c8e466d26..975661c03 100644 --- a/crates/proof-rlm-store/src/pg.rs +++ b/crates/proof-rlm-store/src/pg.rs @@ -227,6 +227,15 @@ impl RlmStore for PgRlmStore { "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. + if self.latest_topic(alias).await?.is_some() { + 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, \ @@ -244,9 +253,15 @@ impl RlmStore for PgRlmStore { // 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 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) diff --git a/crates/proof-rlm-store/tests/store_contract.rs b/crates/proof-rlm-store/tests/store_contract.rs index 0ac72ba1d..3603980a5 100644 --- a/crates/proof-rlm-store/tests/store_contract.rs +++ b/crates/proof-rlm-store/tests/store_contract.rs @@ -71,6 +71,28 @@ async fn contract(store: &dyn RlmStore) { 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!( From 625cc6f088e00f9ea6d30578ffd904c910195c8f Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:20:52 +0000 Subject: [PATCH 09/17] fix(proof): serialize concurrent claims on the same topic slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: the alias shadow guard was only atomic per statement. 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. Reproduced against Postgres before fixing: an alias insert and a topic publish for the same slug, each holding an open transaction, both committed (1 alias row + 1 topic row for one slug). Fixed by serializing claims on the slug with a transaction-scoped advisory lock (`pg_advisory_xact_lock(hashtextextended(slug, 0))`), taken: - in the migration's trigger, before the `EXISTS` check, in both directions; - in `put_alias`, which is now **one transaction** — the lock is released at the end of its transaction, so issuing the lock and the insert as separate statements would drop it before the insert and reopen the race. Re-running the same race now yields one winner and one refusal (`topic race-slug is already claimed as an alias`); sequential paths are unchanged. The lock is keyed on the slug, so unrelated topics never contend, and it is released automatically at commit/rollback. Test: a `multi_thread` regression that races two claims for one slug and asserts at most one commits and that a published canonical slug never resolves through an alias. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../db/migrations/0024_proof_topic_alias.sql | 27 ++++++++-- crates/proof-rlm-store/src/pg.rs | 31 +++++++++-- .../proof-rlm-store/tests/store_contract.rs | 54 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/crates/db/migrations/0024_proof_topic_alias.sql b/crates/db/migrations/0024_proof_topic_alias.sql index fa629bfc6..9c6993ef5 100644 --- a/crates/db/migrations/0024_proof_topic_alias.sql +++ b/crates/db/migrations/0024_proof_topic_alias.sql @@ -54,16 +54,35 @@ CREATE INDEX ix_proof_topic_alias_topic ON proof_topic_alias (topic_id); -- `(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 - IF EXISTS (SELECT 1 FROM proof_topic_version WHERE topic_id = NEW.alias) THEN - RAISE EXCEPTION 'alias % is already a published topic id', NEW.alias + 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 - IF EXISTS (SELECT 1 FROM proof_topic_alias WHERE alias = NEW.topic_id) THEN - RAISE EXCEPTION 'topic % is already claimed as an alias', NEW.topic_id + 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; diff --git a/crates/proof-rlm-store/src/pg.rs b/crates/proof-rlm-store/src/pg.rs index 975661c03..a3cc56cfe 100644 --- a/crates/proof-rlm-store/src/pg.rs +++ b/crates/proof-rlm-store/src/pg.rs @@ -219,10 +219,28 @@ impl RlmStore for PgRlmStore { } 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. - if self.latest_topic(topic_id).await?.is_none() { + 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" ))); @@ -230,7 +248,13 @@ impl RlmStore for PgRlmStore { // 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. - if self.latest_topic(alias).await?.is_some() { + 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" @@ -243,8 +267,9 @@ impl RlmStore for PgRlmStore { ) .bind(alias) .bind(topic_id) - .execute(&self.pool) + .execute(&mut *tx) .await?; + tx.commit().await?; Ok(()) } diff --git a/crates/proof-rlm-store/tests/store_contract.rs b/crates/proof-rlm-store/tests/store_contract.rs index 3603980a5..15f2ddb56 100644 --- a/crates/proof-rlm-store/tests/store_contract.rs +++ b/crates/proof-rlm-store/tests/store_contract.rs @@ -313,6 +313,60 @@ async fn memory_store_honours_the_contract() { contract(&MemoryRlmStore::new()).await; } +/// Two writers racing for the same slug must not both commit. +/// +/// The `EXISTS` guards in the trigger and in `put_alias` see nothing from the +/// other uncommitted transaction under READ COMMITTED, so without the +/// advisory lock both would commit and the slug would be shadowed after all. +/// Postgres-only: the memory store has one mutex and no such race. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_slug_claims_are_serialized() { + 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 store = std::sync::Arc::new(PgRlmStore::new(tp.pool().clone())); + + // One real topic to alias, and a slug that both racers will claim. + let doc = topic(); + store.put_topic_version(&doc).await.unwrap(); + let mut second = doc.clone(); + second.id = "race-slug-v0".into(); + store.put_topic_version(&second).await.unwrap(); + + // Race an alias claim against a *different* alias claim for the same slug. + // One must win; the loser must be refused, never silently applied. + let slug = "race-slug-v0"; + let a = store.clone(); + let b = store.clone(); + let (ra, rb) = tokio::join!( + async move { a.put_alias(slug, &doc.id).await }, + async move { b.put_alias(slug, "race-slug-v0").await }, + ); + // `race-slug-v0` is itself published, so B must be refused on that + // ground regardless; A may win. Whichever way it lands, at most one row + // may exist and it must not shadow a canonical slug. + let winners = usize::from(ra.is_ok()) + usize::from(rb.is_ok()); + assert!(winners <= 1, "both claims committed: {ra:?} / {rb:?}"); + + // The invariant that matters: the canonical slug still resolves to itself + // (or not at all), never through an alias. + assert!( + store.resolve_alias(slug).await.unwrap().is_none(), + "a published canonical slug must never resolve through an alias" + ); + let listed = store.aliases_for(slug).await.unwrap(); + assert!( + listed.is_empty(), + "no alias may be filed under a published slug: {listed:?}" + ); + + 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() { From a16afbcabc935c6973eff84ea0619e75ff592a9f Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:32:47 +0000 Subject: [PATCH 10/17] test(proof): make the slug-race regression actually detect the race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: the previous regression did not cover the cross-table race it was named for — it pre-published the contested slug and only raced alias writes, so it passed with or without the lock. Verified that directly: with the advisory lock stripped from both layers, the old test still passed. The replacement is a **deterministic** detector rather than a timing race. It holds an alias insert open in one transaction, then tries to publish a topic with that slug on another connection: - with the shared transaction-scoped lock, the publish **blocks** until the holder ends, which is the pass condition; - without it, the publish sees no committed alias and succeeds immediately. Confirmed it fails without the lock (`the publish did not block on the slug claim … Ok(PgQueryResult { rows_affected: 1 })`) and passes with it, so a future change that drops the lock cannot slip through. `tokio` gains the `time` feature for the bounded wait. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- crates/proof-rlm-store/Cargo.toml | 2 +- .../proof-rlm-store/tests/store_contract.rs | 87 ++++++++++++------- 2 files changed, 57 insertions(+), 32 deletions(-) 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/tests/store_contract.rs b/crates/proof-rlm-store/tests/store_contract.rs index 15f2ddb56..a5d0722f8 100644 --- a/crates/proof-rlm-store/tests/store_contract.rs +++ b/crates/proof-rlm-store/tests/store_contract.rs @@ -313,14 +313,22 @@ async fn memory_store_honours_the_contract() { contract(&MemoryRlmStore::new()).await; } -/// Two writers racing for the same slug must not both commit. +/// 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. +/// +/// The detector holds an alias insert **open** in one transaction and then +/// tries to publish a topic with that slug on another connection. With the +/// shared transaction-scoped lock the publish blocks until the holder ends; +/// without it, the publish sees no committed alias and succeeds immediately. +/// So "still blocked after the wait" is the pass condition — a test that only +/// races two fast commits passes either way and would not catch a regression. /// -/// The `EXISTS` guards in the trigger and in `put_alias` see nothing from the -/// other uncommitted transaction under READ COMMITTED, so without the -/// advisory lock both would commit and the slug would be shadowed after all. /// Postgres-only: the memory store has one mutex and no such race. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn concurrent_slug_claims_are_serialized() { +async fn a_concurrent_alias_and_topic_publish_cannot_both_claim_a_slug() { let Ok(url) = std::env::var("DATABASE_URL") else { return; }; @@ -328,40 +336,57 @@ async fn concurrent_slug_claims_are_serialized() { return; } let tp = db::test_pool_with_url(&url).await.expect("isolated schema"); - let store = std::sync::Arc::new(PgRlmStore::new(tp.pool().clone())); + let pool = tp.pool(); + let store = PgRlmStore::new(pool.clone()); - // One real topic to alias, and a slug that both racers will claim. + // A published topic for the alias to point at. let doc = topic(); store.put_topic_version(&doc).await.unwrap(); - let mut second = doc.clone(); - second.id = "race-slug-v0".into(); - store.put_topic_version(&second).await.unwrap(); + let contested = "contested-slug-v0"; - // Race an alias claim against a *different* alias claim for the same slug. - // One must win; the loser must be refused, never silently applied. - let slug = "race-slug-v0"; - let a = store.clone(); - let b = store.clone(); - let (ra, rb) = tokio::join!( - async move { a.put_alias(slug, &doc.id).await }, - async move { b.put_alias(slug, "race-slug-v0").await }, - ); - // `race-slug-v0` is itself published, so B must be refused on that - // ground regardless; A may win. Whichever way it lands, at most one row - // may exist and it must not shadow a canonical slug. - let winners = usize::from(ra.is_ok()) + usize::from(rb.is_ok()); - assert!(winners <= 1, "both claims committed: {ra:?} / {rb:?}"); + // 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"); - // The invariant that matters: the canonical slug still resolves to itself - // (or not at all), never through an alias. + // B: publish a topic whose id is `contested`, on another connection. + let mut publisher = pool.begin().await.expect("begin publisher"); + let publish = sqlx::query( + "INSERT INTO proof_topic_version (topic_id, version, status, document, signature) \ + VALUES ($1, 1, 'draft', '{}'::jsonb, 'sig')", + ) + .bind(contested) + .execute(&mut *publisher); + + let outcome = tokio::time::timeout(std::time::Duration::from_millis(750), publish).await; assert!( - store.resolve_alias(slug).await.unwrap().is_none(), - "a published canonical slug must never resolve through an alias" + outcome.is_err(), + "the publish did not block on the slug claim, so an alias and a topic can both \ + claim {contested}: {outcome:?}" ); - let listed = store.aliases_for(slug).await.unwrap(); + + // Releasing A lets B proceed; the invariant still holds because A's claim + // is then visible and B is refused. + holder.rollback().await.expect("rollback holder"); + drop(publisher); + + let second = tokio::time::timeout( + std::time::Duration::from_secs(5), + store.put_alias(contested, &doc.id), + ) + .await + .expect("the alias claim must not deadlock after the race"); + second.expect("the alias claim is free once the race is resolved"); + + let is_topic = store.latest_topic(contested).await.unwrap().is_some(); + let resolved = store.resolve_alias(contested).await.unwrap(); assert!( - listed.is_empty(), - "no alias may be filed under a published slug: {listed:?}" + !(is_topic && resolved.is_some()), + "slug {contested} is both a published topic and an alias -> {resolved:?}" ); tp.drop_schema().await.expect("drop"); From f38ace356087e8b74087ad3bde824d2fdf23b78b Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:47:26 +0000 Subject: [PATCH 11/17] test(proof): assert the publish is rejected, not just blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: the regression verified the block but not the rejection. Removing the publisher-side collision check while keeping the lock left the test green, so a realistic path — both an alias and a topic claiming one slug — was not covered. The test now commits the held alias claim and asserts the waiting publish is **refused** by the collision check (`already claimed as an alias`), then reads back that exactly one claim exists and the refused publish wrote no topic row. Verified both ways: with the publisher-side check removed (lock retained) the test fails with `the publish was admitted after the alias claim committed, so both claimed the slug`; restored, it passes. So it now covers both layers — the lock that serializes the claim and the check that rejects the loser. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../proof-rlm-store/tests/store_contract.rs | 81 +++++++++++-------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/crates/proof-rlm-store/tests/store_contract.rs b/crates/proof-rlm-store/tests/store_contract.rs index a5d0722f8..10ca1ccf8 100644 --- a/crates/proof-rlm-store/tests/store_contract.rs +++ b/crates/proof-rlm-store/tests/store_contract.rs @@ -317,14 +317,17 @@ async fn memory_store_honours_the_contract() { /// /// 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. +/// COMMITTED neither sees the other's uncommitted row. Two things must hold, +/// and the test checks both because each covers a different layer: /// -/// The detector holds an alias insert **open** in one transaction and then -/// tries to publish a topic with that slug on another connection. With the -/// shared transaction-scoped lock the publish blocks until the holder ends; -/// without it, the publish sees no committed alias and succeeds immediately. -/// So "still blocked after the wait" is the pass condition — a test that only -/// races two fast commits passes either way and would not catch a regression. +/// 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)] @@ -344,7 +347,7 @@ async fn a_concurrent_alias_and_topic_publish_cannot_both_claim_a_slug() { store.put_topic_version(&doc).await.unwrap(); let contested = "contested-slug-v0"; - // A: claim `contested` as an alias, and hold the transaction open. + // 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) @@ -354,39 +357,51 @@ async fn a_concurrent_alias_and_topic_publish_cannot_both_claim_a_slug() { .expect("alias insert inside the open transaction"); // B: publish a topic whose id is `contested`, on another connection. - let mut publisher = pool.begin().await.expect("begin publisher"); - let publish = sqlx::query( - "INSERT INTO proof_topic_version (topic_id, version, status, document, signature) \ - VALUES ($1, 1, 'draft', '{}'::jsonb, 'sig')", - ) - .bind(contested) - .execute(&mut *publisher); + 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 + }); - let outcome = tokio::time::timeout(std::time::Duration::from_millis(750), publish).await; + // 1. It must block while A is open. + let blocked = tokio::time::timeout(std::time::Duration::from_millis(750), &mut publisher).await; assert!( - outcome.is_err(), + blocked.is_err(), "the publish did not block on the slug claim, so an alias and a topic can both \ - claim {contested}: {outcome:?}" + claim {contested}: {blocked:?}" ); - // Releasing A lets B proceed; the invariant still holds because A's claim - // is then visible and B is refused. - holder.rollback().await.expect("rollback holder"); - drop(publisher); + // A commits: the alias claim is now visible and the lock is released. + holder.commit().await.expect("commit the alias claim"); - let second = tokio::time::timeout( - std::time::Duration::from_secs(5), - store.put_alias(contested, &doc.id), - ) - .await - .expect("the alias claim must not deadlock after the race"); - second.expect("the alias claim is free once the race is resolved"); + // 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}" + ); - let is_topic = store.latest_topic(contested).await.unwrap().is_some(); - let resolved = store.resolve_alias(contested).await.unwrap(); + // And the invariant, read back: exactly one claim exists, and the slug + // never resolves through an alias to a different topic's document. assert!( - !(is_topic && resolved.is_some()), - "slug {contested} is both a published topic and an alias -> {resolved:?}" + 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"); From 8e36538a6ed7c22e5086cd03c80e0e3ae4f2d603 Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:00:37 +0000 Subject: [PATCH 12/17] feat(proof): the bundle owns topic behavior; the CLI hands it to the RLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner reinforcement: topics are RLM-based and autonomous. The admin CLI only asks the RLM to install and set the topic up — it is the hook that hands control to the RLM Topic Install Bundle. Nothing topic-specific may be compiled into challenge, gateway, orchestrator, or CLI code. New `rlm` section on the bundle, owned by the RLM and opaque to Rust: rules · migrations · apis · submission_format · scoring The crate checks the section's *shape* (an object or array, bounded at 256 KiB) and carries it byte-for-byte into the install plan as `rlm_install`. It does not know what a rule, a migration, an API, a submission format, or a scoring function means — recognising them would mean this crate knows the topic. Unknown content is not an error; a shape Rust has never seen is data. The install plan now leads with the hand-off and names the RLM's own lifecycle steps (`provision -> propose_rules -> baseline`, the existing `TopicSetup` driver), then the publish call and host env. The CLI does not run those steps and does not read into the section. Two guard tests enforce the boundary, and both were verified to fail on the thing they forbid: - `no_topic_literal_appears_in_this_crates_logic` (bundle crate) and `the_cli_does_not_bake_in_topic_behavior` (CLI) scan the non-test source with comments stripped, so prose may explain the rule while a literal in a `let`/`match`/`if` is caught. Injecting `topic_id == "tb4"` fails the CLI guard; restoring passes. - The CLI guard also forbids naming a metric, a task, a benchmark, or the RLM section's own keys — the first draft enumerated `rules/migrations/apis/submission_format/scoring` in its output and the guard caught it. Seed ids stay strings: `tb4` and `tbench` appear only in fixtures, operator examples, and docs. The bundle crate's non-test source has no topic literal at all, and its doc comment that *named* the seed ids was reworded rather than exempted. Docs: the RLM-owned boundary and the hand-off are recorded in PROOF.md, COMPLETENESS.md, and ARCHITECTURE.md. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- bins/proof-admin/src/main.rs | 29 ++- bins/proof-admin/tests/cli.rs | 49 ++++ crates/proof-topic-bundle/src/lib.rs | 373 ++++++++++++++++++++++++++- docs/ARCHITECTURE.md | 2 +- docs/COMPLETENESS.md | 2 +- docs/PROOF.md | 33 +++ 6 files changed, 478 insertions(+), 10 deletions(-) diff --git a/bins/proof-admin/src/main.rs b/bins/proof-admin/src/main.rs index 8cd8f8192..669edc782 100644 --- a/bins/proof-admin/src/main.rs +++ b/bins/proof-admin/src/main.rs @@ -400,6 +400,7 @@ fn cmd_validate(opts: &Options, path: &Path, pin_path: &Path) -> Result<(), Fail "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(()); @@ -420,6 +421,14 @@ fn cmd_validate(opts: &Options, path: &Path, pin_path: &Path) -> Result<(), Fail .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!( @@ -505,7 +514,21 @@ fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { println!(" owner_gate n/a (staging)"); } println!(); - println!("1) Publish the signed document (one block; existing route, operator bearer):"); + 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() { @@ -513,9 +536,9 @@ fn print_plan(plan: &TopicInstallPlan, bundle_path: &Path, pin_path: &Path) { } println!(); if plan.host_env.is_empty() { - println!("2) Host env: nothing extra is required for this topic."); + println!("3) Host env: nothing extra is required for this topic."); } else { - println!("2) Set these on the master before the topic can score:"); + 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); diff --git a/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs index fb7c8a2b7..005e7d0ef 100644 --- a/bins/proof-admin/tests/cli.rs +++ b/bins/proof-admin/tests/cli.rs @@ -338,6 +338,8 @@ fn dry_run_install_prints_the_existing_publish_call_and_host_env() { "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", @@ -737,6 +739,53 @@ fn help_lists_every_p0_subcommand_and_says_what_is_not_implemented() { ); } +/// 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() { diff --git a/crates/proof-topic-bundle/src/lib.rs b/crates/proof-topic-bundle/src/lib.rs index eb5ca8b84..3b913e732 100644 --- a/crates/proof-topic-bundle/src/lib.rs +++ b/crates/proof-topic-bundle/src/lib.rs @@ -10,10 +10,31 @@ //! image pins are operator env. //! //! What was missing is the *procedure*: which signed document, which install -//! target, and which host env must agree with it before the topic can run. -//! That is this bundle. It **references** the document and **cross-checks** -//! the host expectations against it; it never restates a binding in a second -//! place that could drift. +//! 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: //! @@ -52,10 +73,11 @@ pub const MAX_DISPLAY_NAME_LEN: usize = 128; /// (`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; 5] = [ +pub const BUNDLE_KEYS: [&str; 6] = [ "display_name", "environment", "host", + "rlm", "schema_version", "topic", ]; @@ -70,6 +92,27 @@ pub const REQUIRED_BUNDLE_KEYS: [&str; 5] = [ "topic", ]; +/// Keys of the RLM-owned section, sorted. +/// +/// These are the parts the bundle owns, per the architecture: the topic's +/// **rules**, the **SQL migrations** it needs, the **APIs** it exposes, its +/// **submission format**, and its **scoring**. Naming them here makes the +/// section reviewable; it does **not** make this crate understand them. Each +/// value is carried verbatim and never read. +pub const RLM_KEYS: [&str; 5] = [ + "apis", + "migrations", + "rules", + "scoring", + "submission_format", +]; + +/// Largest canonical 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; + /// Keys of the `host` block, sorted. pub const HOST_KEYS: [&str; 5] = [ "custom_ids_entry", @@ -91,6 +134,14 @@ pub const DIGEST_PREFIX: &str = "sha256:"; /// 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"; @@ -221,6 +272,17 @@ pub enum BundleError { /// 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: &'static str, + /// What it was. + got: &'static str, + }, + /// 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), @@ -267,6 +329,93 @@ pub struct HostExpectations { 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**. +/// +/// This crate does not read any of it. The fields exist so an operator can +/// *review* the section and so the shape can be bounded; the values are +/// carried verbatim to the RLM. Nothing here is validated semantically, and +/// nothing here may become a branch in challenge, gateway, or orchestrator +/// code — that is exactly the hardcoding this boundary exists to prevent. +/// +/// Every field is optional and unconstrained beyond "valid JSON": a topic that +/// needs none of them says nothing, and a topic that needs something Rust has +/// never heard of puts it in the section rather than requiring a code change. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct RlmSection { + /// Anti-cheat rules the RLM ticks before any paid inference. Opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub rules: Option, + /// SQL migrations the topic's install needs. Opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub migrations: Option, + /// APIs the topic exposes. Opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub apis: Option, + /// The topic's submission format. Opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub submission_format: Option, + /// The topic's scoring definition. Opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub scoring: Option, +} + +impl RlmSection { + /// Whether the section carries anything at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.rules.is_none() + && self.migrations.is_none() + && self.apis.is_none() + && self.submission_format.is_none() + && self.scoring.is_none() + } + + /// Canonical form of the section, for the size bound. + fn canonical_len(&self) -> Result { + let value = + serde_json::to_value(self).map_err(|e| BundleError::Canonicalize(e.to_string()))?; + Ok(proof_canon::canonical_json(&value).len()) + } + + /// Shape only: the section must be a bounded JSON object. + /// + /// 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. + fn validate_shape(&self) -> Result<(), BundleError> { + for (field, value) in [ + ("rules", self.rules.as_ref()), + ("migrations", self.migrations.as_ref()), + ("apis", self.apis.as_ref()), + ("submission_format", self.submission_format.as_ref()), + ("scoring", self.scoring.as_ref()), + ] { + if let Some(v) = value { + if v.is_null() { + continue; + } + if !v.is_object() && !v.is_array() { + return Err(BundleError::RlmNotObject { + field, + got: json_kind(v), + }); + } + } + } + let len = self.canonical_len()?; + if len > MAX_RLM_BYTES { + return Err(BundleError::RlmTooLarge(len)); + } + Ok(()) + } +} + /// One topic install bundle. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -282,6 +431,9 @@ pub struct TopicInstallBundle { 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. @@ -320,6 +472,17 @@ pub struct TopicInstallPlan { 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. @@ -328,6 +491,18 @@ pub struct TopicInstallPlan { pub bundle_digest: String, } +/// Name a JSON value's kind, for an error that says what arrived. +fn json_kind(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a boolean", + serde_json::Value::Number(_) => "a number", + serde_json::Value::String(_) => "a string", + serde_json::Value::Array(_) => "an array", + serde_json::Value::Object(_) => "an object", + } +} + fn is_digest(s: &str) -> bool { s.strip_prefix(DIGEST_PREFIX).is_some_and(is_lower_hex64) } @@ -401,6 +576,9 @@ impl TopicInstallBundle { } } 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() } @@ -563,6 +741,8 @@ impl TopicInstallBundle { 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()?, @@ -624,6 +804,33 @@ mod tests { pack_dir: Some("/var/lib/proof/packs".into()), custom_ids_entry: Some("tbench".into()), }, + rlm: RlmSection::default(), + } + } + + /// A section carrying all five RLM-owned parts, with shapes this crate + /// has no opinion about. + fn rlm_section() -> RlmSection { + RlmSection { + rules: Some(serde_json::json!([ + {"id": "no_short_circuit", "text": "the harness must run the task"} + ])), + migrations: Some(serde_json::json!([ + {"name": "0001_topic_scratch", "sql": "CREATE TABLE scratch (id TEXT)"} + ])), + apis: Some(serde_json::json!([ + {"path": "/v1/topic/status", "method": "GET"} + ])), + submission_format: Some(serde_json::json!({ + "kind": "tar", + "max_bytes": 5_242_880, + "fields": ["entrypoint", "manifest"] + })), + scoring: Some(serde_json::json!({ + "primary": "success_rate", + "direction": "max", + "epsilon_rel": 0.05 + })), } } @@ -692,6 +899,162 @@ mod tests { } } + /// The RLM section is **opaque**: this crate carries it and never interprets + /// it. The test proves the carry is verbatim and that shapes this crate has + /// never heard of are not errors — recognising them 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, + "the section must be handed over byte-for-byte, not rewritten" + ); + // Content this crate has no schema for survives a round trip untouched. + let scoring = carried.scoring.as_ref().expect("scoring"); + assert_eq!(scoring["primary"], "success_rate"); + assert_eq!(scoring["epsilon_rel"], 0.05); + assert_eq!( + carried.rules.as_ref().expect("rules")[0]["id"], + "no_short_circuit" + ); + + // A topic-specific shape Rust has never seen is still not an error: the + // section is data, and only the RLM knows what it means. + let mut exotic = tb4(); + exotic.rlm = RlmSection { + scoring: Some(serde_json::json!({ + "some_future_metric_this_build_has_never_heard_of": {"weight": 0.7} + })), + ..RlmSection::default() + }; + exotic + .validate_shape() + .expect("unknown content is not a validation error"); + + // The hand-off names the RLM's own jobs, and they are the existing + // lifecycle steps — not anything derived from the bundle's contents. + assert_eq!(plan.rlm_jobs, ["provision", "propose_rules", "baseline"]); + + // An empty section is legal and is not handed over at all. + 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" + ); + } + + /// 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() { + for field in [ + "rules", + "migrations", + "apis", + "submission_format", + "scoring", + ] { + let mut bundle = tb4(); + let scalar = serde_json::json!("a bare string is not a section part"); + match field { + "rules" => bundle.rlm.rules = Some(scalar), + "migrations" => bundle.rlm.migrations = Some(scalar), + "apis" => bundle.rlm.apis = Some(scalar), + "submission_format" => bundle.rlm.submission_format = Some(scalar), + _ => bundle.rlm.scoring = Some(scalar), + } + let err = bundle + .validate_shape() + .expect_err(&format!("{field} must be an object or array")); + assert!( + matches!(err, BundleError::RlmNotObject { field: f, .. } if f == field), + "{field}: {err:?}" + ); + } + + // An array is fine: a rule list is a list. + let mut list = tb4(); + list.rlm.rules = Some(serde_json::json!([{"id": "r", "text": "t"}])); + list.validate_shape() + .expect("a list is a legal rules shape"); + + // The bound is on the section's own canonical size. + let mut huge = tb4(); + huge.rlm.scoring = Some(serde_json::json!({ "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(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5d69238b..31e79d733 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,7 +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). 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 | +| `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 1dfa7b913..089f46cd1 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -81,7 +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`. **No new table, no new route, no behavior 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. | +| 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`. **No new table, no new route, no behavior 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 40a83d8e6..3f9248d2d 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -239,6 +239,39 @@ 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 | From 34495f285c2890ac769a32601f21cb15f0a82d17 Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:20:08 +0000 Subject: [PATCH 13/17] fix(proof): hand the RLM section over byte-exact, not re-serialized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile, two defects in the RLM hand-off: 1. **The bytes were reconstructed.** Each part was a `serde_json::Value`, so parsing and re-serializing reordered keys, **collapsed duplicate keys**, and normalised whitespace. The operator would sign off one bundle and the RLM would receive another — the opposite of a verbatim hand-off. 2. **An explicit `null` was silently dropped.** `Option` folds `"rules": null` into "absent", so a part the operator wrote vanished from the install. Each part is now `Box` — the exact JSON text, carried untouched. `RawValue` has no `PartialEq`, so equality is defined on the raw bytes, which is the honest comparison for a hand-off. A hand-written `Deserialize` (the derive cannot express this) keeps explicit `null` as the text `null`, keeps unknown keys an error, and preserves each part's bytes; `Serialize` writes them straight through. The shape check now refuses an explicit `null` (`RlmExplicitNull`) instead of dropping it: a part the operator wrote is never silently discarded. The object-or-array check reads the raw first byte, so it still holds without parsing. Verified through the real CLI: a bundle whose `scoring` is `{"b": 1, "a": 2, "a": 3, "sp": "x y"}` reaches the plan's JSON output with key order, duplicate keys, and inner whitespace all intact, and `"rules": null` is refused with a message naming the field. Tests: bundle 18 -> 20, with regressions for both defects (the byte-exactness test splices its awkward part as *text*, so it does not itself round-trip through a `Value`). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- crates/proof-topic-bundle/Cargo.toml | 2 +- crates/proof-topic-bundle/src/lib.rs | 414 +++++++++++++++++++-------- 2 files changed, 302 insertions(+), 114 deletions(-) diff --git a/crates/proof-topic-bundle/Cargo.toml b/crates/proof-topic-bundle/Cargo.toml index 610602035..d586c5649 100644 --- a/crates/proof-topic-bundle/Cargo.toml +++ b/crates/proof-topic-bundle/Cargo.toml @@ -14,7 +14,7 @@ proof-canon = { path = "../proof-canon" } proof-experiment = { path = "../proof-experiment" } proof-task = { path = "../proof-task" } serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } sha2 = "0.10" thiserror = "2" diff --git a/crates/proof-topic-bundle/src/lib.rs b/crates/proof-topic-bundle/src/lib.rs index 3b913e732..ea58f49bb 100644 --- a/crates/proof-topic-bundle/src/lib.rs +++ b/crates/proof-topic-bundle/src/lib.rs @@ -62,6 +62,7 @@ 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; @@ -280,6 +281,15 @@ pub enum BundleError { /// 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: &'static str, + }, /// 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), @@ -336,79 +346,183 @@ pub struct HostExpectations { /// **rules**, the **SQL migrations** the topic needs, the **APIs** it exposes, /// its **submission format**, and its **scoring**. /// -/// This crate does not read any of it. The fields exist so an operator can -/// *review* the section and so the shape can be bounded; the values are -/// carried verbatim to the RLM. Nothing here is validated semantically, and -/// nothing here may become a branch in challenge, gateway, or orchestrator -/// code — that is exactly the hardcoding this boundary exists to prevent. +/// Each part is held as **raw JSON text** ([`RawValue`]), not a parsed +/// [`serde_json::Value`]. That is deliberate and load-bearing: parsing and +/// re-serializing would reorder keys, collapse duplicate keys, and normalise +/// whitespace, so the bytes handed to the RLM would not be the bytes the +/// operator wrote. This crate carries the text it was given. /// -/// Every field is optional and unconstrained beyond "valid JSON": a topic that -/// needs none of them says nothing, and a topic that needs something Rust has -/// never heard of puts it in the section rather than requiring a code change. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, default)] +/// The named fields exist so an operator can *review* the section and so its +/// shape can be bounded. Nothing here is validated semantically, and nothing +/// here may become a branch in challenge, gateway, or orchestrator code — +/// that is exactly the hardcoding this boundary prevents. A part Rust has +/// never heard of goes in the section rather than requiring a code change. +#[derive(Debug, Clone, Default)] pub struct RlmSection { /// Anti-cheat rules the RLM ticks before any paid inference. Opaque. - #[serde(skip_serializing_if = "Option::is_none")] - pub rules: Option, + pub rules: Option>, /// SQL migrations the topic's install needs. Opaque. - #[serde(skip_serializing_if = "Option::is_none")] - pub migrations: Option, + pub migrations: Option>, /// APIs the topic exposes. Opaque. - #[serde(skip_serializing_if = "Option::is_none")] - pub apis: Option, + pub apis: Option>, /// The topic's submission format. Opaque. - #[serde(skip_serializing_if = "Option::is_none")] - pub submission_format: Option, + pub submission_format: Option>, /// The topic's scoring definition. Opaque. - #[serde(skip_serializing_if = "Option::is_none")] - pub scoring: Option, + pub scoring: Option>, +} + +/// The five parts, in a fixed order, for iteration and error naming. +const RLM_FIELDS: [&str; 5] = [ + "rules", + "migrations", + "apis", + "submission_format", + "scoring", +]; + +/// Compare the raw **bytes**, which is what the RLM receives. +/// +/// `RawValue` has no `PartialEq`; comparing the text is also the honest +/// comparison here, since two sections are the same hand-off only if they +/// carry the same bytes. +impl PartialEq for RlmSection { + fn eq(&self, other: &Self) -> bool { + let raw = |v: Option<&RawValue>| v.map(|r| r.get().to_owned()); + RLM_FIELDS + .iter() + .all(|f| raw(field_of(self, f)) == raw(field_of(other, f))) + } +} + +impl Eq for RlmSection {} + +/// The field behind a name, for the generic helpers above. +fn field_of<'a>(section: &'a RlmSection, field: &str) -> Option<&'a RawValue> { + match field { + "rules" => section.rules.as_deref(), + "migrations" => section.migrations.as_deref(), + "apis" => section.apis.as_deref(), + "submission_format" => section.submission_format.as_deref(), + _ => section.scoring.as_deref(), + } +} + +/// Deserialize the section as a map of **raw** parts. +/// +/// Hand-written so three things hold that the derive cannot give: +/// +/// - Each part keeps its exact bytes (a parsed `Value` would not). +/// - An **explicit `null`** is kept as the text `null`, not folded into +/// "absent" the way `Option` would. [`Self::validate_shape`] then refuses +/// it, because silently dropping a part the operator wrote is the same +/// failure as rewriting it. +/// - An unknown key is an error, like `deny_unknown_fields`. +impl<'de> Deserialize<'de> for RlmSection { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SectionVisitor; + + impl<'de> serde::de::Visitor<'de> for SectionVisitor { + type Value = RlmSection; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("an RLM install section (a map of opaque parts)") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut section = RlmSection::default(); + while let Some(key) = map.next_key::()? { + let raw: Box = map.next_value()?; + let slot = match key.as_str() { + "rules" => &mut section.rules, + "migrations" => &mut section.migrations, + "apis" => &mut section.apis, + "submission_format" => &mut section.submission_format, + "scoring" => &mut section.scoring, + other => { + return Err(serde::de::Error::custom(format!( + "unknown RLM section key {other:?} (expected one of {RLM_FIELDS:?})" + ))); + } + }; + *slot = Some(raw); + } + Ok(section) + } + } + + deserializer.deserialize_map(SectionVisitor) + } +} + +impl Serialize for RlmSection { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(None)?; + for field in RLM_FIELDS { + if let Some(raw) = field_of(self, field) { + // `RawValue` writes its bytes through untouched. + map.serialize_entry(field, raw)?; + } + } + map.end() + } } impl RlmSection { /// Whether the section carries anything at all. #[must_use] pub fn is_empty(&self) -> bool { - self.rules.is_none() - && self.migrations.is_none() - && self.apis.is_none() - && self.submission_format.is_none() - && self.scoring.is_none() + RLM_FIELDS.iter().all(|f| field_of(self, f).is_none()) } - /// Canonical form of the section, for the size bound. - fn canonical_len(&self) -> Result { - let value = - serde_json::to_value(self).map_err(|e| BundleError::Canonicalize(e.to_string()))?; - Ok(proof_canon::canonical_json(&value).len()) + /// The raw bytes of one part, for a caller that wants to hand them on. + #[must_use] + pub fn raw(&self, field: &str) -> Option<&str> { + field_of(self, field).map(RawValue::get) + } + + /// Total size of the parts, in bytes — the text the RLM receives. + fn raw_len(&self) -> usize { + RLM_FIELDS + .iter() + .filter_map(|f| self.raw(f)) + .map(str::len) + .sum() } - /// Shape only: the section must be a bounded JSON object. + /// Shape only: each part must be a bounded JSON object or array. /// /// 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. fn validate_shape(&self) -> Result<(), BundleError> { - for (field, value) in [ - ("rules", self.rules.as_ref()), - ("migrations", self.migrations.as_ref()), - ("apis", self.apis.as_ref()), - ("submission_format", self.submission_format.as_ref()), - ("scoring", self.scoring.as_ref()), - ] { - if let Some(v) = value { - if v.is_null() { - continue; - } - if !v.is_object() && !v.is_array() { - return Err(BundleError::RlmNotObject { - field, - got: json_kind(v), - }); - } + for field in RLM_FIELDS { + let Some(text) = self.raw(field) else { + continue; + }; + let trimmed = text.trim(); + // An explicit null is refused rather than dropped: the operator + // wrote it, so silently discarding it would change the install. + if trimmed == "null" { + return Err(BundleError::RlmExplicitNull { field }); + } + if !trimmed.starts_with('{') && !trimmed.starts_with('[') { + return Err(BundleError::RlmNotObject { + field, + got: raw_kind(trimmed), + }); } } - let len = self.canonical_len()?; + let len = self.raw_len(); if len > MAX_RLM_BYTES { return Err(BundleError::RlmTooLarge(len)); } @@ -491,15 +605,16 @@ pub struct TopicInstallPlan { pub bundle_digest: String, } -/// Name a JSON value's kind, for an error that says what arrived. -fn json_kind(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "a boolean", - serde_json::Value::Number(_) => "a number", - serde_json::Value::String(_) => "a string", - serde_json::Value::Array(_) => "an array", - serde_json::Value::Object(_) => "an object", +/// 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", } } @@ -808,29 +923,24 @@ mod tests { } } + /// A raw part from text, the way a bundle file supplies it. + fn raw(text: &str) -> Box { + RawValue::from_string(text.to_owned()).expect("raw json") + } + /// A section carrying all five RLM-owned parts, with shapes this crate /// has no opinion about. fn rlm_section() -> RlmSection { RlmSection { - rules: Some(serde_json::json!([ - {"id": "no_short_circuit", "text": "the harness must run the task"} - ])), - migrations: Some(serde_json::json!([ - {"name": "0001_topic_scratch", "sql": "CREATE TABLE scratch (id TEXT)"} - ])), - apis: Some(serde_json::json!([ - {"path": "/v1/topic/status", "method": "GET"} - ])), - submission_format: Some(serde_json::json!({ - "kind": "tar", - "max_bytes": 5_242_880, - "fields": ["entrypoint", "manifest"] - })), - scoring: Some(serde_json::json!({ - "primary": "success_rate", - "direction": "max", - "epsilon_rel": 0.05 - })), + rules: Some(raw( + r#"[{"id": "no_short_circuit", "text": "run the task"}]"#, + )), + migrations: Some(raw( + r#"[{"name": "0001_scratch", "sql": "CREATE TABLE s (id TEXT)"}]"#, + )), + apis: Some(raw(r#"[{"path": "/v1/topic/status", "method": "GET"}]"#)), + submission_format: Some(raw(r#"{"kind": "tar", "max_bytes": 5242880}"#)), + scoring: Some(raw(r#"{"primary": "success_rate", "epsilon_rel": 0.05}"#)), } } @@ -900,7 +1010,7 @@ mod tests { } /// The RLM section is **opaque**: this crate carries it and never interprets - /// it. The test proves the carry is verbatim and that shapes this crate has + /// it. The test proves the carry is byte-exact and that shapes this crate has /// never heard of are not errors — recognising them would mean this crate /// knows the topic, which is exactly the hardcoding the boundary prevents. #[test] @@ -918,35 +1028,29 @@ mod tests { .expect("the section is handed over"); assert_eq!( carried, &bundle.rlm, - "the section must be handed over byte-for-byte, not rewritten" + "the section must be handed over unchanged" ); - // Content this crate has no schema for survives a round trip untouched. - let scoring = carried.scoring.as_ref().expect("scoring"); - assert_eq!(scoring["primary"], "success_rate"); - assert_eq!(scoring["epsilon_rel"], 0.05); assert_eq!( - carried.rules.as_ref().expect("rules")[0]["id"], - "no_short_circuit" + carried.raw("scoring"), + Some(r#"{"primary": "success_rate", "epsilon_rel": 0.05}"#), + "the part must come back as the exact text that went in" + ); + assert_eq!( + carried.raw("rules"), + Some(r#"[{"id": "no_short_circuit", "text": "run the task"}]"#) ); - // A topic-specific shape Rust has never seen is still not an error: the - // section is data, and only the RLM knows what it means. + // A topic-specific shape Rust has never seen is still not an error. let mut exotic = tb4(); - exotic.rlm = RlmSection { - scoring: Some(serde_json::json!({ - "some_future_metric_this_build_has_never_heard_of": {"weight": 0.7} - })), - ..RlmSection::default() - }; + exotic.rlm.scoring = Some(raw( + r#"{"some_future_metric_this_build_has_never_heard_of": {"weight": 0.7}}"#, + )); exotic .validate_shape() .expect("unknown content is not a validation error"); - // The hand-off names the RLM's own jobs, and they are the existing - // lifecycle steps — not anything derived from the bundle's contents. assert_eq!(plan.rlm_jobs, ["provision", "propose_rules", "baseline"]); - // An empty section is legal and is not handed over at all. let empty = tb4(); empty.validate_shape().expect("an absent section is fine"); assert!( @@ -959,24 +1063,93 @@ mod tests { ); } + /// The hand-off must preserve the **bytes** the operator wrote. + /// + /// Parsing a part into a `Value` and re-serializing would reorder keys, + /// collapse duplicate keys, and normalise whitespace, so the RLM would + /// receive something other than what was signed off. Key order, duplicate + /// keys, and significant whitespace are all checked here. + #[test] + fn the_hand_off_preserves_key_order_duplicates_and_whitespace() { + let awkward = r#"{"b": 1, "a": 2, "a": 3, "sp": "x y"}"#; + // Splice the awkward part into the serialized fixture as **text**, so + // the test itself does not round-trip it through a `Value` (which is + // exactly the lossy path under test). + let fixture = serde_json::to_string(&tb4()).expect("fixture json"); + let body = fixture.replacen( + "\"rlm\":{}", + &format!("\"rlm\":{{\"scoring\": {awkward}}}"), + 1, + ); + assert!( + body.contains(awkward), + "the splice must have landed: {body}" + ); + let bundle = TopicInstallBundle::from_json(&body).expect("parse"); + bundle.validate_shape().expect("validates"); + assert_eq!( + bundle.rlm.raw("scoring"), + Some(awkward), + "the exact bytes must survive parsing" + ); + + // 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("scoring"), + Some(awkward), + "the exact bytes must survive the plan's own JSON" + ); + } + + /// An explicit `null` is refused, not silently dropped. + /// + /// `Option>` would fold `"rules": null` into "absent", so the + /// operator would sign off one bundle and the RLM would receive another. The + /// custom deserializer keeps the text, and the shape check refuses it. + #[test] + fn an_explicit_null_rlm_part_is_refused_not_dropped() { + let body = format!( + r#"{{ + "schema_version": 1, "environment": "metal", "display_name": "Terminal-Bench 4", + "topic": {}, "host": {{}}, "rlm": {{"rules": null}} +}}"#, + serde_json::to_string(&custom_topic("tbench")).expect("topic json") + ); + let bundle = TopicInstallBundle::from_json(&body).expect("parse"); + assert_eq!( + bundle.rlm.raw("rules"), + Some("null"), + "the null must be kept, not folded into absent" + ); + let err = bundle + .validate_shape() + .expect_err("an explicit null is refused"); + assert!( + matches!(err, BundleError::RlmExplicitNull { 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() { - for field in [ - "rules", - "migrations", - "apis", - "submission_format", - "scoring", - ] { + for field in RLM_FIELDS { let mut bundle = tb4(); - let scalar = serde_json::json!("a bare string is not a section part"); + let scalar = || Some(raw(r#""a bare string is not a section part""#)); match field { - "rules" => bundle.rlm.rules = Some(scalar), - "migrations" => bundle.rlm.migrations = Some(scalar), - "apis" => bundle.rlm.apis = Some(scalar), - "submission_format" => bundle.rlm.submission_format = Some(scalar), - _ => bundle.rlm.scoring = Some(scalar), + "rules" => bundle.rlm.rules = scalar(), + "migrations" => bundle.rlm.migrations = scalar(), + "apis" => bundle.rlm.apis = scalar(), + "submission_format" => bundle.rlm.submission_format = scalar(), + _ => bundle.rlm.scoring = scalar(), } let err = bundle .validate_shape() @@ -987,20 +1160,35 @@ mod tests { ); } - // An array is fine: a rule list is a list. let mut list = tb4(); - list.rlm.rules = Some(serde_json::json!([{"id": "r", "text": "t"}])); + list.rlm.rules = Some(raw(r#"[{"id": "r", "text": "t"}]"#)); list.validate_shape() .expect("a list is a legal rules shape"); - // The bound is on the section's own canonical size. let mut huge = tb4(); - huge.rlm.scoring = Some(serde_json::json!({ "pad": "x".repeat(MAX_RLM_BYTES) })); + huge.rlm.scoring = Some(raw(&format!( + r#"{{"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}"); + + // An unknown key is refused, like every other key in this schema. + let body = format!( + r#"{{ + "schema_version": 1, "environment": "metal", "display_name": "x", + "topic": {}, "host": {{}}, "rlm": {{"not_a_part": {{}}}} +}}"#, + serde_json::to_string(&custom_topic("tbench")).expect("topic json") + ); + let err = TopicInstallBundle::from_json(&body).expect_err("unknown rlm key"); + assert!( + matches!(err, BundleError::Parse(ref m) if m.contains("not_a_part")), + "{err}" + ); } /// The topic slug and its alias are **strings**, never conditions. From e3d70b196ebf44d4fd0ab79bd863be7e82bc148d Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:33:13 +0000 Subject: [PATCH 14/17] docs(proof): commit the Owner dry-run fixture and correct the schema claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deliverables for the Owner A→Z walkthrough. **Committed dry-run fixture** under `bins/proof-admin/tests/fixtures/`: - `tb4.install-bundle.json` — a Topic Install Bundle for slug `tb4` with alias `tbench`, target `staging`, carrying a signed `TopicDocument` and an illustrative RLM section. - `tb4.pin.toml` — the pin that document is checked against. - `README-dry-run.md` — the exact `validate` / `install --dry-run` commands, the migrate note, and what the fixture is *not*. `--pin` is required and the README says so up front: the fixture is signed with the test mini-secret, so the default `config/proof-pin.toml` (the real trust root) correctly refuses it. Both documented commands are exercised on every test run by `the_committed_dry_run_fixture_still_validates_and_plans`, so a schema change that breaks the fixture fails CI instead of reaching the Owner. `regenerate_dry_run_fixture` (env-gated) regenerates both files, so they cannot be hand-edited out of sync with their signature. **Corrected schema claims.** The docs said "no new table, no behavior change" while the same row cited migration `0024` — contradictory, and wrong at HEAD: `0024_proof_topic_alias.sql` **does** ship. Both `docs/PROOF.md` and `docs/COMPLETENESS.md` now state it exactly: `0024` only; adds `proof_topic_alias` plus the `BEFORE INSERT`/`UPDATE` trigger pair that fails closed when an alias would shadow a published slug — a publish-path integrity guard, not scoring math; no `ALTER`/`DROP`; `0020` tables keep their columns, keys, and grants. No P0 scope expansion: no new route, no scoring change, a real install is still exit 3. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../tests/fixtures/tb4.bundle.json | 85 ++++++++++++++ .../proof-admin/tests/fixtures/tb4.pin.toml | 21 ++++ bins/proof-admin/tests/cli.rs | 109 ++++++++++++++++++ .../tests/fixtures/README-dry-run.md | 94 +++++++++++++++ .../tests/fixtures/tb4.install-bundle.json | 97 ++++++++++++++++ bins/proof-admin/tests/fixtures/tb4.pin.toml | 21 ++++ docs/COMPLETENESS.md | 2 +- docs/PROOF.md | 9 +- 8 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json create mode 100644 bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.pin.toml create mode 100644 bins/proof-admin/tests/fixtures/README-dry-run.md create mode 100644 bins/proof-admin/tests/fixtures/tb4.install-bundle.json create mode 100644 bins/proof-admin/tests/fixtures/tb4.pin.toml diff --git a/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json b/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json new file mode 100644 index 000000000..e581e80b9 --- /dev/null +++ b/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json @@ -0,0 +1,85 @@ +{ + "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" + }, + "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": "6844f9b168c73cca993fe6eb7c1e4353c287d55bf835d3e6cf8e91b5ce9411566c1744f2afdc2a6551331c478884d04ce1cee3295fedce8b3984b3cedab7d282", + "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/bins/proof-admin/tests/fixtures/tb4.pin.toml b/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.pin.toml new file mode 100644 index 000000000..48e778e0a --- /dev/null +++ b/bins/proof-admin/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/bins/proof-admin/tests/cli.rs b/bins/proof-admin/tests/cli.rs index 005e7d0ef..04eadf533 100644 --- a/bins/proof-admin/tests/cli.rs +++ b/bins/proof-admin/tests/cli.rs @@ -163,12 +163,121 @@ max_output_tokens = 8192 "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"); 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..65d878922 --- /dev/null +++ b/bins/proof-admin/tests/fixtures/README-dry-run.md @@ -0,0 +1,94 @@ +# `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 -- 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 -- 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 +``` + +Both write nothing and need no database. + +### `--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. Apply it through the usual sqlx path — migrations are +embedded and run automatically wherever `BASE_DATABASE_URL` is set (the +gateway does this on boot), so a compose / `remote-deploy` restart is the +path. There is no separate manual `sqlx migrate` step in this repo's deploy +flow: + +```bash +cargo sqlx migrate run # or: restart the service with BASE_DATABASE_URL set +``` + +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/docs/COMPLETENESS.md b/docs/COMPLETENESS.md index 089f46cd1..7e687c281 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -81,7 +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`. **No new table, no new route, no behavior 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. | +| 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 3f9248d2d..b717e1b95 100644 --- a/docs/PROOF.md +++ b/docs/PROOF.md @@ -277,11 +277,18 @@ does not read the section — it reports what the RLM will be asked to do. | Default | Value | Where | |---------|-------|-------| | First topic slug | **`tb4`** | the signed document's `id` | -| Temporary alias | **`tbench`** | `proof_topic_alias` row `tbench → tb4` | +| 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 From dad746963fc644b3e19212e1ec250195a5444209 Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:33:25 +0000 Subject: [PATCH 15/17] chore(proof): drop a stray nested fixture directory The first fixture-generation run used a relative dump path and wrote a second copy under `bins/proof-admin/bins/proof-admin/tests/fixtures/`. The committed fixture lives at `bins/proof-admin/tests/fixtures/`; this removes the stray tree. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../tests/fixtures/tb4.bundle.json | 85 ------------------- .../proof-admin/tests/fixtures/tb4.pin.toml | 21 ----- 2 files changed, 106 deletions(-) delete mode 100644 bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json delete mode 100644 bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.pin.toml diff --git a/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json b/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json deleted file mode 100644 index e581e80b9..000000000 --- a/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.bundle.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "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" - }, - "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": "6844f9b168c73cca993fe6eb7c1e4353c287d55bf835d3e6cf8e91b5ce9411566c1744f2afdc2a6551331c478884d04ce1cee3295fedce8b3984b3cedab7d282", - "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/bins/proof-admin/tests/fixtures/tb4.pin.toml b/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.pin.toml deleted file mode 100644 index 48e778e0a..000000000 --- a/bins/proof-admin/bins/proof-admin/tests/fixtures/tb4.pin.toml +++ /dev/null @@ -1,21 +0,0 @@ -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 From b265617bb38de4b35737fae91e590f28f5488bfd Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:34:08 +0000 Subject: [PATCH 16/17] docs(proof): correct the fixture commands (package name + --pin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to `README-dry-run.md`, both found by running the commands rather than reading them: - The package is **`proof-admin-bin`**, not `proof-admin` — this repo names binary packages with a `-bin` suffix (`trustroot-bin` → `trustroot`, `validator-bin` → `validator`), so `cargo run -p proof-admin` fails with "package(s) proof-admin not found". The README now gives `cargo run -p proof-admin-bin -- …` and notes the package-agnostic `cargo run --bin proof-admin -- …`. - `--pin` is required and is now called out before the commands, not after: the fixture is signed with the test mini-secret, so the default `config/proof-pin.toml` (the real trust root) correctly refuses it. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../tests/fixtures/README-dry-run.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/bins/proof-admin/tests/fixtures/README-dry-run.md b/bins/proof-admin/tests/fixtures/README-dry-run.md index 65d878922..7e671538b 100644 --- a/bins/proof-admin/tests/fixtures/README-dry-run.md +++ b/bins/proof-admin/tests/fixtures/README-dry-run.md @@ -13,18 +13,33 @@ Operator dry-run artifact for the dynamic-topics P0 skeleton (PR #297). Run from the repository root: ```bash -cargo run -p proof-admin -- topic validate \ +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 -- topic install \ +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 From f298c4d7cfbf436bf01bc588fd34c7b1e90b2b2d Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:54:28 +0000 Subject: [PATCH 17/17] fix(proof): carry the whole RLM section raw, not just its parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile, two findings, both reproduced before fixing. **The enclosing `rlm` object was still rebuilt.** Each named part was raw, but the object around them was deserialized into fields and re-serialized in `RLM_KEYS` order, so the object's own key order did not survive. Reproduced through the real CLI: submitting `{"scoring": …, "rules": …, "apis": …}` gave the plan `["rules", "apis", "scoring"]`. `RlmSection` now holds **one** `Box` — the whole object, verbatim. `validate_shape` parses a *copy* to check the shape (an object; each named part an object or array; no explicit `null`; bounded), and the hand-off is always the original bytes. Checking is not transforming. A key this crate has never heard of is no longer refused either: it is the RLM's business, which is what "opaque" should have meant from the start. Re-verified: the same input now reaches the plan as `["scoring", "rules", "apis"]`. **`cargo sqlx migrate run` is not a command in this repo.** `sqlx-cli` is not a workspace dependency and is absent from a clean checkout (`error: no such command: sqlx`). The README no longer presents it; migrations are embedded in the `db` crate and applied on boot where `BASE_DATABASE_URL` is set, so the staging path is the service restart. Tests: bundle 20, reworked for the single raw object. The byte-preservation test now asserts at the **object** level (keys out of canonical order, a duplicate key, inner whitespace) and splices its input as text, so it cannot itself pass through the lossy path it guards. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../tests/fixtures/README-dry-run.md | 18 +- crates/proof-topic-bundle/src/lib.rs | 449 +++++++----------- 2 files changed, 195 insertions(+), 272 deletions(-) diff --git a/bins/proof-admin/tests/fixtures/README-dry-run.md b/bins/proof-admin/tests/fixtures/README-dry-run.md index 7e671538b..c934ebf19 100644 --- a/bins/proof-admin/tests/fixtures/README-dry-run.md +++ b/bins/proof-admin/tests/fixtures/README-dry-run.md @@ -71,16 +71,22 @@ row key and is a follow-up (see below). ## Staging migrate `crates/db/migrations/0024_proof_topic_alias.sql` is the **only** schema -change in this PR. Apply it through the usual sqlx path — migrations are -embedded and run automatically wherever `BASE_DATABASE_URL` is set (the -gateway does this on boot), so a compose / `remote-deploy` restart is the -path. There is no separate manual `sqlx migrate` step in this repo's deploy -flow: +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 -cargo sqlx migrate run # or: restart the service with BASE_DATABASE_URL set +# 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). diff --git a/crates/proof-topic-bundle/src/lib.rs b/crates/proof-topic-bundle/src/lib.rs index ea58f49bb..e91498326 100644 --- a/crates/proof-topic-bundle/src/lib.rs +++ b/crates/proof-topic-bundle/src/lib.rs @@ -93,27 +93,6 @@ pub const REQUIRED_BUNDLE_KEYS: [&str; 5] = [ "topic", ]; -/// Keys of the RLM-owned section, sorted. -/// -/// These are the parts the bundle owns, per the architecture: the topic's -/// **rules**, the **SQL migrations** it needs, the **APIs** it exposes, its -/// **submission format**, and its **scoring**. Naming them here makes the -/// section reviewable; it does **not** make this crate understand them. Each -/// value is carried verbatim and never read. -pub const RLM_KEYS: [&str; 5] = [ - "apis", - "migrations", - "rules", - "scoring", - "submission_format", -]; - -/// Largest canonical 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; - /// Keys of the `host` block, sorted. pub const HOST_KEYS: [&str; 5] = [ "custom_ids_entry", @@ -277,7 +256,7 @@ pub enum BundleError { #[error("rlm.{field} must be a JSON object or array, got {got}")] RlmNotObject { /// Which field. - field: &'static str, + field: String, /// What it was. got: &'static str, }, @@ -288,7 +267,7 @@ pub enum BundleError { )] RlmExplicitNull { /// Which part. - field: &'static str, + 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")] @@ -346,186 +325,160 @@ pub struct HostExpectations { /// **rules**, the **SQL migrations** the topic needs, the **APIs** it exposes, /// its **submission format**, and its **scoring**. /// -/// Each part is held as **raw JSON text** ([`RawValue`]), not a parsed -/// [`serde_json::Value`]. That is deliberate and load-bearing: parsing and -/// re-serializing would reorder keys, collapse duplicate keys, and normalise -/// whitespace, so the bytes handed to the RLM would not be the bytes the -/// operator wrote. This crate carries the text it was given. +/// 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. /// -/// The named fields exist so an operator can *review* the section and so its -/// shape can be bounded. Nothing here is validated semantically, and nothing -/// here may become a branch in challenge, gateway, or orchestrator code — -/// that is exactly the hardcoding this boundary prevents. A part Rust has -/// never heard of goes in the section rather than requiring a code change. -#[derive(Debug, Clone, Default)] +/// [`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 { - /// Anti-cheat rules the RLM ticks before any paid inference. Opaque. - pub rules: Option>, - /// SQL migrations the topic's install needs. Opaque. - pub migrations: Option>, - /// APIs the topic exposes. Opaque. - pub apis: Option>, - /// The topic's submission format. Opaque. - pub submission_format: Option>, - /// The topic's scoring definition. Opaque. - pub scoring: Option>, + /// The section verbatim: the authoritative hand-off bytes. + raw: Box, } -/// The five parts, in a fixed order, for iteration and error naming. -const RLM_FIELDS: [&str; 5] = [ - "rules", - "migrations", +/// 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", - "submission_format", + "migrations", + "rules", "scoring", + "submission_format", ]; -/// Compare the raw **bytes**, which is what the RLM receives. +/// Largest RLM section, in bytes. /// -/// `RawValue` has no `PartialEq`; comparing the text is also the honest -/// comparison here, since two sections are the same hand-off only if they -/// carry the same 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 { - let raw = |v: Option<&RawValue>| v.map(|r| r.get().to_owned()); - RLM_FIELDS - .iter() - .all(|f| raw(field_of(self, f)) == raw(field_of(other, f))) + self.raw() == other.raw() } } impl Eq for RlmSection {} -/// The field behind a name, for the generic helpers above. -fn field_of<'a>(section: &'a RlmSection, field: &str) -> Option<&'a RawValue> { - match field { - "rules" => section.rules.as_deref(), - "migrations" => section.migrations.as_deref(), - "apis" => section.apis.as_deref(), - "submission_format" => section.submission_format.as_deref(), - _ => section.scoring.as_deref(), +impl Serialize for RlmSection { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // `RawValue` writes its bytes through untouched. + self.raw.serialize(serializer) } } -/// Deserialize the section as a map of **raw** parts. -/// -/// Hand-written so three things hold that the derive cannot give: -/// -/// - Each part keeps its exact bytes (a parsed `Value` would not). -/// - An **explicit `null`** is kept as the text `null`, not folded into -/// "absent" the way `Option` would. [`Self::validate_shape`] then refuses -/// it, because silently dropping a part the operator wrote is the same -/// failure as rewriting it. -/// - An unknown key is an error, like `deny_unknown_fields`. impl<'de> Deserialize<'de> for RlmSection { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - struct SectionVisitor; - - impl<'de> serde::de::Visitor<'de> for SectionVisitor { - type Value = RlmSection; - - fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("an RLM install section (a map of opaque parts)") - } - - fn visit_map(self, mut map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - let mut section = RlmSection::default(); - while let Some(key) = map.next_key::()? { - let raw: Box = map.next_value()?; - let slot = match key.as_str() { - "rules" => &mut section.rules, - "migrations" => &mut section.migrations, - "apis" => &mut section.apis, - "submission_format" => &mut section.submission_format, - "scoring" => &mut section.scoring, - other => { - return Err(serde::de::Error::custom(format!( - "unknown RLM section key {other:?} (expected one of {RLM_FIELDS:?})" - ))); - } - }; - *slot = Some(raw); - } - Ok(section) - } - } - - deserializer.deserialize_map(SectionVisitor) - } -} - -impl Serialize for RlmSection { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(None)?; - for field in RLM_FIELDS { - if let Some(raw) = field_of(self, field) { - // `RawValue` writes its bytes through untouched. - map.serialize_entry(field, raw)?; - } - } - map.end() + // 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 { - /// Whether the section carries anything at all. + /// The section verbatim — the bytes handed to the RLM. #[must_use] - pub fn is_empty(&self) -> bool { - RLM_FIELDS.iter().all(|f| field_of(self, f).is_none()) + pub fn raw(&self) -> &str { + self.raw.get() } - /// The raw bytes of one part, for a caller that wants to hand them on. - #[must_use] - pub fn raw(&self, field: &str) -> Option<&str> { - field_of(self, field).map(RawValue::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()))?, + }) } - /// Total size of the parts, in bytes — the text the RLM receives. - fn raw_len(&self) -> usize { - RLM_FIELDS - .iter() - .filter_map(|f| self.raw(f)) - .map(str::len) - .sum() + /// 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: each part must be a bounded JSON object or array. + /// 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. + /// this crate knows the topic. The parse here is a **check**; the hand-off + /// stays [`Self::raw`]. fn validate_shape(&self) -> Result<(), BundleError> { - for field in RLM_FIELDS { - let Some(text) = self.raw(field) else { - continue; - }; - let trimmed = text.trim(); + 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 trimmed == "null" { - return Err(BundleError::RlmExplicitNull { field }); + if value.is_null() { + return Err(BundleError::RlmExplicitNull { field: key.clone() }); } - if !trimmed.starts_with('{') && !trimmed.starts_with('[') { + // 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, - got: raw_kind(trimmed), + field: key.clone(), + got: raw_kind(&value.to_string()), }); } } - let len = self.raw_len(); - if len > MAX_RLM_BYTES { - return Err(BundleError::RlmTooLarge(len)); - } Ok(()) } } @@ -923,25 +876,20 @@ mod tests { } } - /// A raw part from text, the way a bundle file supplies it. - fn raw(text: &str) -> Box { - RawValue::from_string(text.to_owned()).expect("raw json") + /// 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, with shapes this crate - /// has no opinion about. + /// A section carrying all five RLM-owned parts. fn rlm_section() -> RlmSection { - RlmSection { - rules: Some(raw( - r#"[{"id": "no_short_circuit", "text": "run the task"}]"#, - )), - migrations: Some(raw( - r#"[{"name": "0001_scratch", "sql": "CREATE TABLE s (id TEXT)"}]"#, - )), - apis: Some(raw(r#"[{"path": "/v1/topic/status", "method": "GET"}]"#)), - submission_format: Some(raw(r#"{"kind": "tar", "max_bytes": 5242880}"#)), - scoring: Some(raw(r#"{"primary": "success_rate", "epsilon_rel": 0.05}"#)), - } + 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] @@ -1010,8 +958,8 @@ mod tests { } /// The RLM section is **opaque**: this crate carries it and never interprets - /// it. The test proves the carry is byte-exact and that shapes this crate has - /// never heard of are not errors — recognising them would mean this crate + /// 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() { @@ -1026,25 +974,17 @@ mod tests { .rlm_install .as_ref() .expect("the section is handed over"); + assert_eq!(carried, &bundle.rlm, "handed over unchanged"); assert_eq!( - carried, &bundle.rlm, - "the section must be handed over unchanged" - ); - assert_eq!( - carried.raw("scoring"), - Some(r#"{"primary": "success_rate", "epsilon_rel": 0.05}"#), - "the part must come back as the exact text that went in" - ); - assert_eq!( - carried.raw("rules"), - Some(r#"[{"id": "no_short_circuit", "text": "run the task"}]"#) + carried.raw(), + bundle.rlm.raw(), + "the hand-off is the original text" ); - // A topic-specific shape Rust has never seen is still not an error. + // Content Rust has never seen is still not an error. let mut exotic = tb4(); - exotic.rlm.scoring = Some(raw( - r#"{"some_future_metric_this_build_has_never_heard_of": {"weight": 0.7}}"#, - )); + 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"); @@ -1063,34 +1003,33 @@ mod tests { ); } - /// The hand-off must preserve the **bytes** the operator wrote. + /// The hand-off must preserve the **bytes** the operator wrote — including the + /// enclosing object's own key order and duplicate keys inside it. /// - /// Parsing a part into a `Value` and re-serializing would reorder keys, - /// collapse duplicate keys, and normalise whitespace, so the RLM would - /// receive something other than what was signed off. Key order, duplicate - /// keys, and significant whitespace are all checked here. + /// 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() { - let awkward = r#"{"b": 1, "a": 2, "a": 3, "sp": "x y"}"#; - // Splice the awkward part into the serialized fixture as **text**, so - // the test itself does not round-trip it through a `Value` (which is - // exactly the lossy path under test). - let fixture = serde_json::to_string(&tb4()).expect("fixture json"); - let body = fixture.replacen( - "\"rlm\":{}", - &format!("\"rlm\":{{\"scoring\": {awkward}}}"), - 1, - ); - assert!( - body.contains(awkward), - "the splice must have landed: {body}" + // 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("scoring"), - Some(awkward), - "the exact bytes must survive parsing" + 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. @@ -1098,41 +1037,28 @@ mod tests { 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("scoring"), - Some(awkward), + 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. /// - /// `Option>` would fold `"rules": null` into "absent", so the - /// operator would sign off one bundle and the RLM would receive another. The - /// custom deserializer keeps the text, and the shape check refuses it. + /// 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 body = format!( - r#"{{ - "schema_version": 1, "environment": "metal", "display_name": "Terminal-Bench 4", - "topic": {}, "host": {{}}, "rlm": {{"rules": null}} -}}"#, - serde_json::to_string(&custom_topic("tbench")).expect("topic json") - ); + 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("rules"), - Some("null"), - "the null must be kept, not folded into absent" - ); + 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 { field: "rules" }), + matches!(err, BundleError::RlmExplicitNull { ref field } if field == "rules"), "{err:?}" ); assert!(err.to_string().contains("never silently dropped"), "{err}"); @@ -1141,54 +1067,45 @@ mod tests { /// 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() { - for field in RLM_FIELDS { + // A named part must be an object or array. + for key in RLM_KEYS { let mut bundle = tb4(); - let scalar = || Some(raw(r#""a bare string is not a section part""#)); - match field { - "rules" => bundle.rlm.rules = scalar(), - "migrations" => bundle.rlm.migrations = scalar(), - "apis" => bundle.rlm.apis = scalar(), - "submission_format" => bundle.rlm.submission_format = scalar(), - _ => bundle.rlm.scoring = scalar(), - } + bundle.rlm = rlm(&format!(r#"{{"{key}": "a bare string"}}"#)); let err = bundle .validate_shape() - .expect_err(&format!("{field} must be an object or array")); + .expect_err(&format!("{key} must be an object or array")); assert!( - matches!(err, BundleError::RlmNotObject { field: f, .. } if f == field), - "{field}: {err:?}" + matches!(err, BundleError::RlmNotObject { ref field, .. } if field == key), + "{key}: {err:?}" ); } - let mut list = tb4(); - list.rlm.rules = Some(raw(r#"[{"id": "r", "text": "t"}]"#)); - list.validate_shape() - .expect("a list is a legal rules shape"); + // 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.scoring = Some(raw(&format!( - r#"{{"pad": "{}"}}"#, + 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}"); - - // An unknown key is refused, like every other key in this schema. - let body = format!( - r#"{{ - "schema_version": 1, "environment": "metal", "display_name": "x", - "topic": {}, "host": {{}}, "rlm": {{"not_a_part": {{}}}} -}}"#, - serde_json::to_string(&custom_topic("tbench")).expect("topic json") - ); - let err = TopicInstallBundle::from_json(&body).expect_err("unknown rlm key"); - assert!( - matches!(err, BundleError::Parse(ref m) if m.contains("not_a_part")), - "{err}" - ); } /// The topic slug and its alias are **strings**, never conditions.