use std::path::PathBuf;
use anyhow::Result;
use clap::{Args, Subcommand, ValueEnum};
use agentstatedeveloper_core::{
AsgIndexStore, AsgLedgerStore, AuditEvent, Author, AuthorKind, Decision, Engine, IndexStore,
LedgerEntry, LedgerKind, LedgerStore, Situation, actions, emit_audit, event_types,
};
use serde_json::json;
use crate::config::Config;
fn open_engine(cfg: &Config) -> anyhow::Result<Engine> {
let mut engine = Engine::open_sqlite(&cfg.db_path)?;
if let Some(ref p) = cfg.policy_path {
engine
.load_policy_file(p)
.map_err(|e| anyhow::anyhow!("failed to load policy file {}: {e}", p.display()))?;
}
if let Some(sink) = crate::audit_sink_override() {
engine.set_audit_sink(sink);
} else if cfg.audit_log_path.is_some() {
eprintln!(
"warning: audit log path configured but tamper-evident \
logging is a commercial feature — install asd-pro \
(Enterprise tier) to enable. Running with in-memory \
NullSink."
);
}
if let Some(ratify) = crate::ratify_ops_override() {
engine.set_ratify_ops(ratify);
}
Ok(engine)
}
pub fn open_engine_public(cfg: &Config) -> anyhow::Result<Engine> {
open_engine(cfg)
}
#[derive(Debug, Subcommand)]
pub enum LedgerCmd {
Append(AppendArgs),
Approve(ApproveArgs),
Reject(RejectArgs),
Withdraw(WithdrawArgs),
Supersede(SupersedeArgs),
Rebind(RebindArgs),
}
#[derive(Debug, Args)]
pub struct ApproveArgs {
pub entry_id: String,
#[arg(long)]
pub approver: String,
#[arg(long, default_value = "human")]
pub approver_kind: String,
#[arg(long)]
pub message: Option<String>,
}
#[derive(Debug, Args)]
pub struct RejectArgs {
pub entry_id: String,
#[arg(long)]
pub reviewer: String,
#[arg(long, default_value = "human")]
pub reviewer_kind: String,
#[arg(long)]
pub reason: String,
}
#[derive(Debug, Args)]
pub struct WithdrawArgs {
pub entry_id: String,
#[arg(long)]
pub author_id: String,
}
#[derive(Debug, Args)]
pub struct SupersedeArgs {
pub qname: String,
#[arg(long = "supersede", required = true)]
pub supersedes: Vec<String>,
#[arg(long, value_enum)]
pub kind: CliLedgerKind,
#[arg(long)]
pub summary: String,
#[arg(long)]
pub body: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = CliAuthorKind::Agent)]
pub author_kind: CliAuthorKind,
#[arg(long, default_value = "asd-cli-user")]
pub author_id: String,
}
#[derive(Debug, Args)]
pub struct RebindArgs {
#[arg(
long,
required_unless_present = "map",
requires = "to",
conflicts_with = "map"
)]
pub from: Option<String>,
#[arg(long, requires = "from", conflicts_with = "map")]
pub to: Option<String>,
#[arg(long, value_name = "FILE")]
pub map: Option<PathBuf>,
#[arg(long, default_value = "asd-cli-user")]
pub agent_id: String,
}
#[derive(Debug, Args)]
pub struct AppendArgs {
pub qname: String,
#[arg(long, value_enum)]
pub kind: CliLedgerKind,
#[arg(long)]
pub summary: String,
#[arg(long)]
pub body: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = CliAuthorKind::Agent)]
pub author_kind: CliAuthorKind,
#[arg(long, default_value = "asd-cli-user")]
pub author_id: String,
#[arg(long)]
pub role: Option<String>,
#[arg(long)]
pub command: Option<String>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CliLedgerKind {
Decision,
Assumption,
Constraint,
Rationale,
Hazard,
Tradeoff,
Invariant,
Ownership,
Proof,
#[value(name = "validation_scenario")]
ValidationScenario,
#[value(name = "known_bug")]
KnownBug,
Concept,
Mapping,
#[value(name = "follow_up")]
FollowUp,
}
impl From<CliLedgerKind> for LedgerKind {
fn from(k: CliLedgerKind) -> Self {
match k {
CliLedgerKind::Decision => LedgerKind::Decision,
CliLedgerKind::Assumption => LedgerKind::Assumption,
CliLedgerKind::Constraint => LedgerKind::Constraint,
CliLedgerKind::Rationale => LedgerKind::Rationale,
CliLedgerKind::Hazard => LedgerKind::Hazard,
CliLedgerKind::Tradeoff => LedgerKind::Tradeoff,
CliLedgerKind::Invariant => LedgerKind::Invariant,
CliLedgerKind::Ownership => LedgerKind::Ownership,
CliLedgerKind::Proof => LedgerKind::Proof,
CliLedgerKind::ValidationScenario => LedgerKind::ValidationScenario,
CliLedgerKind::KnownBug => LedgerKind::KnownBug,
CliLedgerKind::Concept => LedgerKind::Concept,
CliLedgerKind::Mapping => LedgerKind::Mapping,
CliLedgerKind::FollowUp => LedgerKind::FollowUp,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CliAuthorKind {
Agent,
Human,
}
impl From<CliAuthorKind> for AuthorKind {
fn from(k: CliAuthorKind) -> Self {
match k {
CliAuthorKind::Agent => AuthorKind::Agent,
CliAuthorKind::Human => AuthorKind::Human,
}
}
}
pub fn run(cfg: &Config, cmd: LedgerCmd) -> Result<()> {
match cmd {
LedgerCmd::Append(args) => append(cfg, args),
LedgerCmd::Approve(args) => approve(cfg, args),
LedgerCmd::Reject(args) => reject(cfg, args),
LedgerCmd::Withdraw(args) => withdraw(cfg, args),
LedgerCmd::Supersede(args) => supersede(cfg, args),
LedgerCmd::Rebind(args) => rebind(cfg, args),
}
}
fn approve(cfg: &Config, args: ApproveArgs) -> Result<()> {
let engine = open_engine(cfg)?;
let situation = Situation {
description: format!("ledger.approve {}", args.entry_id),
qualifiers: json!({ "entry_id": &args.entry_id }),
};
if let Decision::Deny {
matched_policy,
reason,
} = engine
.policy
.evaluate(&situation, actions::LEDGER_APPROVE, &args.approver)?
{
anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
}
let result = if let Some(ref ratify) = engine.ratify {
ratify.approve_entry(
&engine.repo,
&engine.ref_name,
&args.entry_id,
&args.approver,
&args.approver_kind,
args.message.as_deref(),
&cfg.agent_id,
)
} else {
let ledger_store = AsgLedgerStore::from_engine(&engine);
ledger_store.approve_entry(
&engine.ref_name,
&args.entry_id,
&args.approver,
&args.approver_kind,
args.message.as_deref(),
&cfg.agent_id,
)
};
match result {
Ok(outcome) => {
let status = if outcome.already_approved {
"already-approved"
} else {
"approved"
};
let event = AuditEvent::new(
event_types::LEDGER_APPROVE,
&args.approver,
&args.approver_kind,
status,
)
.with_subject(&outcome.entry.entry_id)
.with_secondary(&outcome.entry.symbol_id)
.with_matched_policy(outcome.entry.matched_policy.clone())
.with_payload(json!({ "tags": outcome.entry.tags }));
emit_audit(engine.audit.as_ref(), event);
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": status,
"entry_id": outcome.entry.entry_id,
"symbol_id": outcome.entry.symbol_id,
"tags": outcome.entry.tags,
}))?
);
Ok(())
}
Err(e) => {
let event = AuditEvent::new(
event_types::LEDGER_APPROVE,
&args.approver,
&args.approver_kind,
"error",
)
.with_subject(&args.entry_id)
.with_reason(e.to_string());
emit_audit(engine.audit.as_ref(), event);
Err(e.into())
}
}
}
fn reject(cfg: &Config, args: RejectArgs) -> Result<()> {
let engine = open_engine(cfg)?;
let situation = Situation {
description: format!("ledger.reject {}", args.entry_id),
qualifiers: json!({ "entry_id": &args.entry_id }),
};
if let Decision::Deny {
matched_policy,
reason,
} = engine
.policy
.evaluate(&situation, actions::LEDGER_REJECT, &args.reviewer)?
{
anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
}
let result = if let Some(ref ratify) = engine.ratify {
ratify.reject_entry(
&engine.repo,
&engine.ref_name,
&args.entry_id,
&args.reviewer,
&args.reviewer_kind,
&args.reason,
&cfg.agent_id,
)
} else {
let ledger_store = AsgLedgerStore::from_engine(&engine);
ledger_store.reject_entry(
&engine.ref_name,
&args.entry_id,
&args.reviewer,
&args.reviewer_kind,
&args.reason,
&cfg.agent_id,
)
};
match result {
Ok(outcome) => {
let status = if outcome.already_resolved {
"already-rejected"
} else {
"rejected"
};
let event = AuditEvent::new(
event_types::LEDGER_REJECT,
&args.reviewer,
&args.reviewer_kind,
status,
)
.with_subject(&outcome.entry.entry_id)
.with_secondary(&outcome.entry.symbol_id)
.with_matched_policy(outcome.entry.matched_policy.clone())
.with_reason(&args.reason)
.with_payload(json!({ "tags": outcome.entry.tags }));
emit_audit(engine.audit.as_ref(), event);
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": status,
"entry_id": outcome.entry.entry_id,
"symbol_id": outcome.entry.symbol_id,
"tags": outcome.entry.tags,
}))?
);
Ok(())
}
Err(e) => {
let event = AuditEvent::new(
event_types::LEDGER_REJECT,
&args.reviewer,
&args.reviewer_kind,
"error",
)
.with_subject(&args.entry_id)
.with_reason(e.to_string());
emit_audit(engine.audit.as_ref(), event);
Err(e.into())
}
}
}
fn withdraw(cfg: &Config, args: WithdrawArgs) -> Result<()> {
let engine = open_engine(cfg)?;
let situation = Situation {
description: format!("ledger.withdraw {}", args.entry_id),
qualifiers: json!({ "entry_id": &args.entry_id }),
};
if let Decision::Deny {
matched_policy,
reason,
} = engine
.policy
.evaluate(&situation, actions::LEDGER_WITHDRAW, &args.author_id)?
{
anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
}
let result = if let Some(ref ratify) = engine.ratify {
ratify.withdraw_entry(
&engine.repo,
&engine.ref_name,
&args.entry_id,
&args.author_id,
&cfg.agent_id,
)
} else {
let ledger_store = AsgLedgerStore::from_engine(&engine);
ledger_store.withdraw_entry(
&engine.ref_name,
&args.entry_id,
&args.author_id,
&cfg.agent_id,
)
};
match result {
Ok(outcome) => {
let status = if outcome.already_resolved {
"already-withdrawn"
} else {
"withdrawn"
};
let event = AuditEvent::new(
event_types::LEDGER_WITHDRAW,
&args.author_id,
"agent",
status,
)
.with_subject(&outcome.entry.entry_id)
.with_secondary(&outcome.entry.symbol_id)
.with_payload(json!({ "tags": outcome.entry.tags }));
emit_audit(engine.audit.as_ref(), event);
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": status,
"entry_id": outcome.entry.entry_id,
"symbol_id": outcome.entry.symbol_id,
"tags": outcome.entry.tags,
}))?
);
Ok(())
}
Err(e) => {
let event = AuditEvent::new(
event_types::LEDGER_WITHDRAW,
&args.author_id,
"agent",
"error",
)
.with_subject(&args.entry_id)
.with_reason(e.to_string());
emit_audit(engine.audit.as_ref(), event);
Err(e.into())
}
}
}
fn supersede(cfg: &Config, args: SupersedeArgs) -> Result<()> {
let engine = open_engine(cfg)?;
let index_store = AsgIndexStore::from_engine(&engine);
let symbol = index_store
.get_symbol_by_qname(&engine.ref_name, &args.qname)?
.ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
let author_kind_str: AuthorKind = args.author_kind.into();
let author_kind_label = match author_kind_str {
AuthorKind::Agent => "agent",
AuthorKind::Human => "human",
};
let author = Author {
kind: author_kind_str,
id: args.author_id.clone(),
};
let situation = Situation {
description: format!("ledger.supersede for {}", args.qname),
qualifiers: json!({
"qname": &args.qname,
"symbol_id": &symbol.symbol_id,
"file": &symbol.file,
"language": &symbol.language,
}),
};
if let Decision::Deny {
matched_policy,
reason,
} = engine
.policy
.evaluate(&situation, actions::LEDGER_SUPERSEDE, &args.author_id)?
{
anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
}
let mut entry = LedgerEntry::new(&symbol.symbol_id, args.kind.into(), args.summary, author);
if let Some(body_path) = args.body {
entry.body = Some(std::fs::read_to_string(body_path)?);
}
entry.supersedes = args.supersedes.clone();
entry.tags.push("supersedes".to_string());
let ledger_store = AsgLedgerStore::from_engine(&engine);
match ledger_store.append_entry(&engine.ref_name, &entry, &cfg.agent_id) {
Ok(()) => {
let event = AuditEvent::new(
event_types::LEDGER_SUPERSEDE,
&args.author_id,
author_kind_label,
"success",
)
.with_subject(&entry.entry_id)
.with_secondary(&entry.symbol_id)
.with_payload(json!({
"supersedes": entry.supersedes,
"qname": args.qname,
}));
emit_audit(engine.audit.as_ref(), event);
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "superseded",
"entry_id": entry.entry_id,
"symbol_id": entry.symbol_id,
"supersedes": entry.supersedes,
}))?
);
Ok(())
}
Err(e) => {
let event = AuditEvent::new(
event_types::LEDGER_SUPERSEDE,
&args.author_id,
author_kind_label,
"error",
)
.with_secondary(&symbol.symbol_id)
.with_reason(e.to_string());
emit_audit(engine.audit.as_ref(), event);
Err(e.into())
}
}
}
fn append(cfg: &Config, args: AppendArgs) -> Result<()> {
let engine = open_engine(cfg)?;
let index_store = AsgIndexStore::from_engine(&engine);
let symbol = index_store
.get_symbol_by_qname(&engine.ref_name, &args.qname)?
.ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
let ledger_kind: LedgerKind = args.kind.into();
let author_kind_str: AuthorKind = args.author_kind.into();
let author_kind_label = match author_kind_str {
AuthorKind::Agent => "agent",
AuthorKind::Human => "human",
};
let action = actions::ledger_append_action(ledger_kind.as_str());
let situation = Situation {
description: format!("ledger.append for {}", args.qname),
qualifiers: json!({
"qname": &args.qname,
"kind": ledger_kind.as_str(),
"symbol_id": &symbol.symbol_id,
"file": &symbol.file,
"language": &symbol.language,
}),
};
let decision = engine
.policy
.evaluate(&situation, &action, &args.author_id)?;
if let Decision::Deny {
matched_policy,
reason,
} = &decision
{
let event = AuditEvent::new(
event_types::LEDGER_APPEND,
&args.author_id,
author_kind_label,
"denied",
)
.with_secondary(&symbol.symbol_id)
.with_matched_policy(Some(matched_policy.clone()))
.with_reason(reason.clone())
.with_payload(json!({ "qname": &args.qname, "kind": ledger_kind.as_str() }));
emit_audit(engine.audit.as_ref(), event);
let err = json!({
"status": "denied",
"action": action,
"matched_policy": matched_policy,
"reason": reason,
});
println!("{}", serde_json::to_string_pretty(&err)?);
return Err(anyhow::anyhow!("policy denied: {}", reason));
}
let author = Author {
kind: author_kind_str,
id: args.author_id.clone(),
};
let mut entry = LedgerEntry::new(&symbol.symbol_id, ledger_kind, args.summary, author);
if let Some(body_path) = args.body {
let text = std::fs::read_to_string(&body_path)?;
entry.body = Some(text);
}
entry.matched_policy = decision.matched_policy();
if let Some(ref r) = args.role {
if agentstatedeveloper_core::RoleTag::from_str(r).is_none() {
let valid: Vec<&str> = agentstatedeveloper_core::RoleTag::all()
.iter()
.map(|t| t.as_str())
.collect();
eprintln!(
"asd: warning: role={:?} is not a canonical RoleTag. Valid: {}",
r,
valid.join(", ")
);
}
}
entry.role = args.role;
entry.command = args.command;
if let Decision::RequireApproval {
approvers, reason, ..
} = &decision
{
entry.tags.push("awaiting-approval".to_string());
for a in approvers {
entry.tags.push(format!("approver:{}", a));
}
if let Some(r) = reason {
if entry.body.is_none() {
entry.body = Some(format!("Approval reason: {}", r));
}
}
}
for tag in ctx_provenance_tags() {
if !entry.tags.contains(&tag) {
entry.tags.push(tag);
}
}
let ledger_store = AsgLedgerStore::from_engine(&engine);
ledger_store.append_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
let status = match &decision {
Decision::Allow { .. } => "allowed",
Decision::RequireApproval { .. } => "awaiting-approval",
Decision::Deny { .. } => "denied",
Decision::NoPolicyMatch => "no-policy-match",
};
let event = AuditEvent::new(
event_types::LEDGER_APPEND,
&args.author_id,
author_kind_label,
status,
)
.with_subject(&entry.entry_id)
.with_secondary(&entry.symbol_id)
.with_matched_policy(entry.matched_policy.clone())
.with_payload(json!({
"qname": &args.qname,
"kind": ledger_kind.as_str(),
"tags": &entry.tags,
}));
emit_audit(engine.audit.as_ref(), event);
println!(
"{}",
serde_json::to_string_pretty(&json!({
"entry_id": entry.entry_id,
"matched_policy": entry.matched_policy,
"status": status,
}))?
);
Ok(())
}
fn rebind(cfg: &Config, args: RebindArgs) -> Result<()> {
let engine = open_engine(cfg)?;
let requested: Vec<(String, String)> = match (&args.map, args.from, args.to) {
(Some(path), _, _) => {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
map.into_iter()
.map(|(from, to)| match to {
serde_json::Value::String(to) => Ok((from, to)),
_ => Err(anyhow::anyhow!(
"{}: the target for {from} must be a qualified name",
path.display()
)),
})
.collect::<Result<_>>()?
}
(None, Some(from), Some(to)) => vec![(from, to)],
_ => anyhow::bail!("pass --from and --to, or --map"),
};
if requested.is_empty() {
anyhow::bail!("no rebinds listed");
}
let index_store = AsgIndexStore::from_engine(&engine);
let ledger_store = AsgLedgerStore::from_engine(&engine);
let mut resolved = Vec::with_capacity(requested.len());
let mut problems = Vec::new();
for (from, to) in &requested {
let situation = Situation::new("rebind symbol")
.with_qualifier("from_symbol_id", from)
.with_qualifier("to_qname", to);
if let Decision::Deny {
matched_policy,
reason,
} = engine
.policy
.evaluate(&situation, actions::LEDGER_REBIND, &args.agent_id)?
{
problems.push(format!(
"{from}: policy denied by {matched_policy}: {reason}"
));
continue;
}
let from_id = if from.starts_with("sym_") {
if ledger_store
.list_entries_with_superseded(&engine.ref_name, from)?
.is_empty()
{
problems.push(format!("{from}: no ledger entries are filed under it"));
continue;
}
from.clone()
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, from)? {
Some(symbol) => symbol.symbol_id,
None => {
problems.push(format!(
"{from}: not in the index — for an orphaned symbol, pass its symbol_id"
));
continue;
}
}
};
match index_store.get_symbol_by_qname(&engine.ref_name, to)? {
Some(symbol) => resolved.push((from_id, symbol)),
None => problems.push(format!("{to}: not in the index — run `asd index` first")),
}
}
if !problems.is_empty() {
anyhow::bail!("nothing rebound:\n {}", problems.join("\n "));
}
let outcomes = agentstatedeveloper_core::rebind_ledger(
&engine.repo,
engine.fts.as_ref(),
&engine.ref_name,
&resolved,
&args.agent_id,
)?;
for (outcome, (from, _)) in outcomes.iter().zip(&requested) {
let event = AuditEvent::new(event_types::LEDGER_REBIND, &args.agent_id, "agent", "allow")
.with_subject(outcome.from_symbol_id.clone())
.with_secondary(outcome.to_symbol_id.clone())
.with_payload(json!({
"from": from,
"from_symbol_id": outcome.from_symbol_id,
"to_symbol_id": outcome.to_symbol_id,
"to_qname": outcome.to_qname,
"entries_moved": outcome.entries_moved,
}));
emit_audit(engine.audit.as_ref(), event);
}
let output = match outcomes.as_slice() {
[one] if args.map.is_none() => json!({
"status": "rebound",
"from_symbol_id": one.from_symbol_id,
"to_symbol_id": one.to_symbol_id,
"to_qname": one.to_qname,
"entries_moved": one.entries_moved,
}),
all => json!({
"status": "rebound",
"rebinds": all,
"entries_moved": all.iter().map(|o| o.entries_moved).sum::<usize>(),
}),
};
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
pub fn ctx_provenance_tags() -> Vec<String> {
let mut tags = Vec::new();
if let Ok(plan) = std::env::var("CTXONE_PLAN") {
if !plan.is_empty() {
tags.push(format!("ctx:plan:{}", plan));
}
}
if let Ok(task) = std::env::var("CTXONE_TASK") {
if !task.is_empty() {
tags.push(format!("ctx:task:{}", task));
}
}
tags
}