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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions atomic-canonical/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod jcs;
pub mod lift;
pub mod memory;
pub mod node;
pub mod outcome_ref;
pub mod proof;
pub mod prov;
pub mod render;
Expand All @@ -52,6 +53,7 @@ pub use node::{
intent_substance_hash, AcceptanceCriterion, CanonicalNode, Constraint, Proof, Ref, ScopeItem,
Task,
};
pub use outcome_ref::{outcome_reference, verify_outcome_reference, OutcomePins};
pub use prov::{attest_prov, project, verify_prov, ProvActivityInput};
pub use render::{render, render_memory, Target};
pub use triage_ref::{triage_reference, verify_triage_reference, TriagePins};
Expand Down
132 changes: 132 additions & 0 deletions atomic-canonical/src/outcome_ref.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! The `urn:atomic:outcome:<blake3>` reference scheme.
//!
//! An outcome reference is a content address for an *outcome observation*: the
//! pins that fix a rollup of N views against a target — the views, the Merkle
//! state each was observed at, and the candidate change-set they contribute.
//! Two outcomes over exactly the same facts get the same reference; any change
//! to a pin yields a different one.
//!
//! This is the same scheme as [`crate::triage_ref`], and deliberately so: a
//! triage reference answers "was *this* change-set reviewed?", an outcome
//! reference answers "what did *this* body of work cost and produce?". Making
//! them the same shape means the two reports can be pinned, stored, and later
//! diffed against each other with one mechanism.
//!
//! Deliberately **string-based** (base32/hex strings, not typed hashes) so it
//! needs no `atomic-core` dependency — the caller supplies already-encoded
//! identifiers. Hashing goes through the one [`crate::jcs`] + BLAKE3 path the
//! rest of the crate uses, so the reference can never drift from a content hash.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::jcs;

/// The pinned facts an outcome reference addresses.
///
/// Field ordering is irrelevant to the hash: the two [`BTreeMap`]s
/// canonicalize, and `candidate_changes` is sorted before hashing, so only the
/// *set* of views and changes matters — not the order the user listed them in.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutcomePins {
/// The source views being rolled up, in the order the user named them.
/// Sorted before hashing, so `a b` and `b a` pin identically.
pub sources: Vec<String>,
/// The target view the sources would be promoted into.
pub target: String,
/// View name → base32 of that view's Merkle at observation time — the
/// materialized state the rollup is a fact about.
pub source_merkle: BTreeMap<String, String>,
/// Base32 Merkle of the target view at observation time.
pub target_merkle: String,
/// Base32 change hashes in the union of the sources' deltas. Order is not
/// significant (sorted before hashing).
pub candidate_changes: Vec<String>,
}

/// The `urn:atomic:outcome:<blake3-hex>` reference for a set of pins.
///
/// `candidate_changes` and `sources` are sorted before hashing so an outcome
/// over the same set of views and changes in any order gets the same
/// reference.
pub fn outcome_reference(pins: &OutcomePins) -> String {
let mut canonical_pins = pins.clone();
canonical_pins.sources.sort();
canonical_pins.candidate_changes.sort();
let value =
serde_json::to_value(&canonical_pins).expect("OutcomePins serialization is infallible");
let canonical = jcs::canonicalize(&value);
let digest = blake3::hash(canonical.as_bytes());
format!("urn:atomic:outcome:{}", digest.to_hex())
}

/// Recompute the reference for `pins` and compare it to `reference`. True iff
/// the pins hash to exactly this reference (order-insensitive for sources and
/// candidate changes).
pub fn verify_outcome_reference(pins: &OutcomePins, reference: &str) -> bool {
outcome_reference(pins) == reference
}

#[cfg(test)]
mod tests {
use super::*;

fn pins() -> OutcomePins {
let mut merkle = BTreeMap::new();
merkle.insert("feature-a".to_string(), "MERCLEA".to_string());
merkle.insert("feature-b".to_string(), "MERCLEB".to_string());
OutcomePins {
sources: vec!["feature-a".to_string(), "feature-b".to_string()],
target: "dev".to_string(),
source_merkle: merkle,
target_merkle: "MERKLEDEV".to_string(),
candidate_changes: vec!["HASHB".to_string(), "HASHA".to_string()],
}
}

#[test]
fn reference_is_stable_across_input_order() {
let mut reordered = pins();
reordered.sources.reverse();
reordered.candidate_changes.reverse();
assert_eq!(outcome_reference(&pins()), outcome_reference(&reordered));
}

#[test]
fn reference_round_trips() {
assert!(verify_outcome_reference(
&pins(),
&outcome_reference(&pins())
));
}

#[test]
fn changing_a_pin_changes_the_reference() {
let base = outcome_reference(&pins());

let mut other_target = pins();
other_target.target = "release".to_string();
assert_ne!(base, outcome_reference(&other_target));

let mut other_merkle = pins();
other_merkle.target_merkle = "MERKLE2".to_string();
assert_ne!(base, outcome_reference(&other_merkle));

let mut added_view = pins();
added_view.sources.push("feature-c".to_string());
assert_ne!(base, outcome_reference(&added_view));

let mut added_change = pins();
added_change.candidate_changes.push("HASHC".to_string());
assert_ne!(base, outcome_reference(&added_change));
}

#[test]
fn reference_has_the_documented_shape() {
let reference = outcome_reference(&pins());
assert!(reference.starts_with("urn:atomic:outcome:"));
// blake3 hex is 64 chars.
assert_eq!(reference.len(), "urn:atomic:outcome:".len() + 64);
}
}
2 changes: 2 additions & 0 deletions atomic-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub mod intent;
pub mod log;
pub mod memory;
pub mod mv;
pub mod outcome;
pub mod provenance;
pub mod record;
pub mod remove;
Expand Down Expand Up @@ -148,6 +149,7 @@ pub use intent::Intent;
pub use log::Log;
pub use memory::Memory;
pub use mv::Move;
pub use outcome::Outcome;
pub use project::ProjectCmd;
pub use provenance::Provenance;
pub use pull::Pull;
Expand Down
233 changes: 233 additions & 0 deletions atomic-cli/src/commands/outcome/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
//! `atomic outcome` — collapse a set of views into one rollup.
//!
//! Where `atomic triage review` answers "is *this* view ready to promote?" for
//! one source and one target, an outcome answers the question above that:
//! given **N** views, what would promoting all of them at once cost, touch,
//! and collide on?
//!
//! The report is a read-only projection. It creates no records, moves no
//! changes, and writes nothing to disk.

use clap::{Parser, Subcommand};
use clap_complete::engine::ArgValueCompleter;

use atomic_repository::Repository;

use crate::commands::complete::complete_view_names;
use crate::commands::{find_repository_root, Command};
use crate::error::{CliError, CliResult};

pub mod output;

/// Roll up a set of views into a single outcome.
#[derive(Debug, Parser)]
#[command(after_help = "\
<VIEW> defaults to the current view; --into defaults to that view's parent. Name
several views to ask what promoting all of them at once would cost.

Examples:
# What would promoting the current view cost?
atomic outcome generate
atomic outcome generate --json

# A stacked promotion: several views at once
atomic outcome generate feature-login feature-payments --into dev
atomic outcome generate feature-login feature-payments --into dev --json")]
pub struct Outcome {
#[command(subcommand)]
pub command: OutcomeCommands,
}

impl Command for Outcome {
fn run(&self) -> CliResult<()> {
match &self.command {
OutcomeCommands::Generate(cmd) => cmd.run(),
}
}
}

/// Subcommands for `atomic outcome`.
#[derive(Subcommand, Debug)]
pub enum OutcomeCommands {
/// Collapse N views into one rollup: cost, footprint, and mergeability.
///
/// See this subcommand's examples.
Generate(OutcomeGenerate),
}

/// Generate the outcome for a set of views against a target.
#[derive(Debug, Parser)]
#[command(after_help = "\
<VIEW> defaults to the current view; --into defaults to that view's parent. Name
several views to ask what promoting all of them at once would cost.

Examples:
# What would promoting the current view cost?
atomic outcome generate
atomic outcome generate --json

# A stacked promotion: several views at once
atomic outcome generate feature-login feature-payments --into dev
atomic outcome generate feature-login feature-payments --into dev --json")]
pub struct OutcomeGenerate {
/// The source view(s) to roll up. Defaults to the current view.
#[arg(
value_name = "VIEW",
add = ArgValueCompleter::new(complete_view_names)
)]
pub views: Vec<String>,

/// The target view the sources would be promoted into. Defaults to the
/// first source view's parent.
#[arg(
long,
value_name = "VIEW",
add = ArgValueCompleter::new(complete_view_names)
)]
pub into: Option<String>,

/// Emit the full outcome as JSON.
#[arg(long)]
pub json: bool,
}

impl Command for OutcomeGenerate {
fn run(&self) -> CliResult<()> {
let root = find_repository_root()?;
let repo = Repository::open(&root).map_err(CliError::Repository)?;
let (sources, target) = resolve_views(&repo, &self.views, self.into.as_deref())?;

let outcome = repo
.outcome(&sources, &target)
.map_err(CliError::Repository)?;

if self.json {
println!(
"{}",
serde_json::to_string_pretty(&outcome).map_err(|e| {
CliError::Internal(anyhow::anyhow!("failed to serialize outcome: {e}"))
})?
);
return Ok(());
}

output::print_outcome(&outcome);
Ok(())
}
}

/// Resolve the (sources, target) pair.
///
/// With no views named, the source is the current view and the target its
/// parent — the same default `atomic triage review` and a bare `atomic insert`
/// use, so the common gesture needs no arguments at all.
fn resolve_views(
repo: &Repository,
views: &[String],
into: Option<&str>,
) -> CliResult<(Vec<String>, String)> {
let sources: Vec<String> = if views.is_empty() {
vec![repo.current_view().to_string()]
} else {
for name in views {
if !repo.view_exists(name).map_err(CliError::Repository)? {
return Err(unknown_view(repo, name, "<VIEW>"));
}
}
views.to_vec()
};

let target = match into {
Some(name) => {
if !repo.view_exists(name).map_err(CliError::Repository)? {
return Err(unknown_view(repo, name, "--into"));
}
name.to_string()
}
None => match repo
.parent_change_count(&sources[0])
.map_err(CliError::Repository)?
{
Some((parent, _)) => parent,
None => {
return Err(CliError::InvalidArgument {
message: format!(
"'{}' is a root view — it has no parent to promote into.\n \
Pass --into <view> to choose a target (see 'atomic view list').",
sources[0]
),
})
}
},
};

Ok((sources, target))
}

/// The error for a view name that does not resolve.
///
/// The usual cause is typing the placeholder from the help text instead of a
/// real view name, so the message says the argument is optional and names the
/// current view as the value to simply drop in — or the value to leave off
/// entirely.
fn unknown_view(repo: &Repository, name: &str, arg: &str) -> CliError {
CliError::InvalidArgument {
message: format!(
"No view named '{name}' — {arg} takes a real view name, not a placeholder.\n \
Current view is '{}'; omit {arg} to roll that up instead, or run \
'atomic view list' to see all views.",
repo.current_view()
),
}
}

#[cfg(test)]
mod tests {
use clap::CommandFactory;

use super::*;

fn parse(args: &[&str]) -> OutcomeGenerate {
OutcomeGenerate::try_parse_from(std::iter::once("generate").chain(args.iter().copied()))
.unwrap()
}

#[test]
fn source_views_are_a_optional_list() {
assert!(parse(&[]).views.is_empty());
assert_eq!(parse(&["a"]).views, vec!["a".to_string()]);
assert_eq!(
parse(&["a", "b", "c"]).views,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
assert_eq!(parse(&["--into", "dev"]).into.as_deref(), Some("dev"));
}

#[test]
fn usage_advertises_both_arguments_as_optional() {
for cmd in [OutcomeGenerate::command()] {
for id in ["views", "into"] {
let arg = cmd
.get_arguments()
.find(|a| a.get_id() == id)
.unwrap_or_else(|| panic!("missing argument `{id}`"));
assert!(!arg.is_required_set(), "`{id}` is still required");
}
}
let usage = OutcomeGenerate::command().render_usage().to_string();
assert!(usage.contains("[VIEW]"), "usage: {usage}");
assert!(!usage.contains("[FEATURE]"), "usage: {usage}");
}

#[test]
fn help_carries_the_stacked_examples() {
// `apply_agent_help` drops `long_about` but keeps `after_help`, so the
// examples have to live there to be discoverable.
let help = OutcomeGenerate::command().render_long_help().to_string();
assert!(help.contains("Examples:"), "help missing examples:\n{help}");
assert!(
help.contains("feature-login feature-payments --into dev"),
"help missing the stacked example:\n{help}"
);
}
}
Loading
Loading