1use std::path::PathBuf;
4
5use anyhow::Result;
6use clap::{Args, Subcommand, ValueEnum};
7
8use agentstatedeveloper_core::{
9 AsgIndexStore, AsgLedgerStore, AuditEvent, Author, AuthorKind, Decision, Engine, IndexStore,
10 LedgerEntry, LedgerKind, LedgerStore, Situation, actions, emit_audit, event_types,
11};
12
13use serde_json::json;
14
15use crate::config::Config;
16
17fn open_engine(cfg: &Config) -> anyhow::Result<Engine> {
24 let mut engine = Engine::open_sqlite(&cfg.db_path)?;
25 if let Some(ref p) = cfg.policy_path {
26 engine
27 .load_policy_file(p)
28 .map_err(|e| anyhow::anyhow!("failed to load policy file {}: {e}", p.display()))?;
29 }
30 if let Some(sink) = crate::audit_sink_override() {
31 engine.set_audit_sink(sink);
32 } else if cfg.audit_log_path.is_some() {
33 eprintln!(
34 "warning: audit log path configured but tamper-evident \
35 logging is a commercial feature — install asd-pro \
36 (Enterprise tier) to enable. Running with in-memory \
37 NullSink."
38 );
39 }
40 if let Some(ratify) = crate::ratify_ops_override() {
41 engine.set_ratify_ops(ratify);
42 }
43 Ok(engine)
44}
45
46pub fn open_engine_public(cfg: &Config) -> anyhow::Result<Engine> {
50 open_engine(cfg)
51}
52
53#[derive(Debug, Subcommand)]
54pub enum LedgerCmd {
55 Append(AppendArgs),
57
58 Approve(ApproveArgs),
60
61 Reject(RejectArgs),
63
64 Withdraw(WithdrawArgs),
67
68 Supersede(SupersedeArgs),
72
73 Rebind(RebindArgs),
78}
79
80#[derive(Debug, Args)]
81pub struct ApproveArgs {
82 pub entry_id: String,
84
85 #[arg(long)]
87 pub approver: String,
88
89 #[arg(long, default_value = "human")]
92 pub approver_kind: String,
93
94 #[arg(long)]
97 pub message: Option<String>,
98}
99
100#[derive(Debug, Args)]
101pub struct RejectArgs {
102 pub entry_id: String,
104
105 #[arg(long)]
107 pub reviewer: String,
108
109 #[arg(long, default_value = "human")]
111 pub reviewer_kind: String,
112
113 #[arg(long)]
115 pub reason: String,
116}
117
118#[derive(Debug, Args)]
119pub struct WithdrawArgs {
120 pub entry_id: String,
122
123 #[arg(long)]
125 pub author_id: String,
126}
127
128#[derive(Debug, Args)]
129pub struct SupersedeArgs {
130 pub qname: String,
132
133 #[arg(long = "supersede", required = true)]
136 pub supersedes: Vec<String>,
137
138 #[arg(long, value_enum)]
140 pub kind: CliLedgerKind,
141
142 #[arg(long)]
144 pub summary: String,
145
146 #[arg(long)]
148 pub body: Option<PathBuf>,
149
150 #[arg(long, value_enum, default_value_t = CliAuthorKind::Agent)]
152 pub author_kind: CliAuthorKind,
153
154 #[arg(long, default_value = "asd-cli-user")]
156 pub author_id: String,
157}
158
159#[derive(Debug, Args)]
160pub struct RebindArgs {
161 #[arg(
165 long,
166 required_unless_present = "map",
167 requires = "to",
168 conflicts_with = "map"
169 )]
170 pub from: Option<String>,
171
172 #[arg(long, requires = "from", conflicts_with = "map")]
174 pub to: Option<String>,
175
176 #[arg(long, value_name = "FILE")]
180 pub map: Option<PathBuf>,
181
182 #[arg(long, default_value = "asd-cli-user")]
184 pub agent_id: String,
185}
186
187#[derive(Debug, Args)]
188pub struct AppendArgs {
189 pub qname: String,
191
192 #[arg(long, value_enum)]
194 pub kind: CliLedgerKind,
195
196 #[arg(long)]
198 pub summary: String,
199
200 #[arg(long)]
202 pub body: Option<PathBuf>,
203
204 #[arg(long, value_enum, default_value_t = CliAuthorKind::Agent)]
206 pub author_kind: CliAuthorKind,
207
208 #[arg(long, default_value = "asd-cli-user")]
210 pub author_id: String,
211
212 #[arg(long)]
216 pub role: Option<String>,
217
218 #[arg(long)]
222 pub command: Option<String>,
223}
224
225#[derive(Debug, Clone, Copy, ValueEnum)]
226pub enum CliLedgerKind {
227 Decision,
228 Assumption,
229 Constraint,
230 Rationale,
231 Hazard,
232 Tradeoff,
233 Invariant,
235 Ownership,
237 Proof,
239 #[value(name = "validation_scenario")]
241 ValidationScenario,
242 #[value(name = "known_bug")]
244 KnownBug,
245 Concept,
247 Mapping,
249 #[value(name = "follow_up")]
251 FollowUp,
252}
253
254impl From<CliLedgerKind> for LedgerKind {
255 fn from(k: CliLedgerKind) -> Self {
256 match k {
257 CliLedgerKind::Decision => LedgerKind::Decision,
258 CliLedgerKind::Assumption => LedgerKind::Assumption,
259 CliLedgerKind::Constraint => LedgerKind::Constraint,
260 CliLedgerKind::Rationale => LedgerKind::Rationale,
261 CliLedgerKind::Hazard => LedgerKind::Hazard,
262 CliLedgerKind::Tradeoff => LedgerKind::Tradeoff,
263 CliLedgerKind::Invariant => LedgerKind::Invariant,
264 CliLedgerKind::Ownership => LedgerKind::Ownership,
265 CliLedgerKind::Proof => LedgerKind::Proof,
266 CliLedgerKind::ValidationScenario => LedgerKind::ValidationScenario,
267 CliLedgerKind::KnownBug => LedgerKind::KnownBug,
268 CliLedgerKind::Concept => LedgerKind::Concept,
269 CliLedgerKind::Mapping => LedgerKind::Mapping,
270 CliLedgerKind::FollowUp => LedgerKind::FollowUp,
271 }
272 }
273}
274
275#[derive(Debug, Clone, Copy, ValueEnum)]
276pub enum CliAuthorKind {
277 Agent,
278 Human,
279}
280
281impl From<CliAuthorKind> for AuthorKind {
282 fn from(k: CliAuthorKind) -> Self {
283 match k {
284 CliAuthorKind::Agent => AuthorKind::Agent,
285 CliAuthorKind::Human => AuthorKind::Human,
286 }
287 }
288}
289
290pub fn run(cfg: &Config, cmd: LedgerCmd) -> Result<()> {
291 match cmd {
292 LedgerCmd::Append(args) => append(cfg, args),
293 LedgerCmd::Approve(args) => approve(cfg, args),
294 LedgerCmd::Reject(args) => reject(cfg, args),
295 LedgerCmd::Withdraw(args) => withdraw(cfg, args),
296 LedgerCmd::Supersede(args) => supersede(cfg, args),
297 LedgerCmd::Rebind(args) => rebind(cfg, args),
298 }
299}
300
301fn approve(cfg: &Config, args: ApproveArgs) -> Result<()> {
302 let engine = open_engine(cfg)?;
303 let situation = Situation {
304 description: format!("ledger.approve {}", args.entry_id),
305 qualifiers: json!({ "entry_id": &args.entry_id }),
306 };
307 if let Decision::Deny {
308 matched_policy,
309 reason,
310 } = engine
311 .policy
312 .evaluate(&situation, actions::LEDGER_APPROVE, &args.approver)?
313 {
314 anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
315 }
316 let result = if let Some(ref ratify) = engine.ratify {
317 ratify.approve_entry(
318 &engine.repo,
319 &engine.ref_name,
320 &args.entry_id,
321 &args.approver,
322 &args.approver_kind,
323 args.message.as_deref(),
324 &cfg.agent_id,
325 )
326 } else {
327 let ledger_store = AsgLedgerStore::from_engine(&engine);
328 ledger_store.approve_entry(
329 &engine.ref_name,
330 &args.entry_id,
331 &args.approver,
332 &args.approver_kind,
333 args.message.as_deref(),
334 &cfg.agent_id,
335 )
336 };
337
338 match result {
339 Ok(outcome) => {
340 let status = if outcome.already_approved {
341 "already-approved"
342 } else {
343 "approved"
344 };
345 let event = AuditEvent::new(
346 event_types::LEDGER_APPROVE,
347 &args.approver,
348 &args.approver_kind,
349 status,
350 )
351 .with_subject(&outcome.entry.entry_id)
352 .with_secondary(&outcome.entry.symbol_id)
353 .with_matched_policy(outcome.entry.matched_policy.clone())
354 .with_payload(json!({ "tags": outcome.entry.tags }));
355 emit_audit(engine.audit.as_ref(), event);
356
357 println!(
358 "{}",
359 serde_json::to_string_pretty(&json!({
360 "status": status,
361 "entry_id": outcome.entry.entry_id,
362 "symbol_id": outcome.entry.symbol_id,
363 "tags": outcome.entry.tags,
364 }))?
365 );
366 Ok(())
367 }
368 Err(e) => {
369 let event = AuditEvent::new(
370 event_types::LEDGER_APPROVE,
371 &args.approver,
372 &args.approver_kind,
373 "error",
374 )
375 .with_subject(&args.entry_id)
376 .with_reason(e.to_string());
377 emit_audit(engine.audit.as_ref(), event);
378 Err(e.into())
379 }
380 }
381}
382
383fn reject(cfg: &Config, args: RejectArgs) -> Result<()> {
384 let engine = open_engine(cfg)?;
385 let situation = Situation {
386 description: format!("ledger.reject {}", args.entry_id),
387 qualifiers: json!({ "entry_id": &args.entry_id }),
388 };
389 if let Decision::Deny {
390 matched_policy,
391 reason,
392 } = engine
393 .policy
394 .evaluate(&situation, actions::LEDGER_REJECT, &args.reviewer)?
395 {
396 anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
397 }
398 let result = if let Some(ref ratify) = engine.ratify {
399 ratify.reject_entry(
400 &engine.repo,
401 &engine.ref_name,
402 &args.entry_id,
403 &args.reviewer,
404 &args.reviewer_kind,
405 &args.reason,
406 &cfg.agent_id,
407 )
408 } else {
409 let ledger_store = AsgLedgerStore::from_engine(&engine);
410 ledger_store.reject_entry(
411 &engine.ref_name,
412 &args.entry_id,
413 &args.reviewer,
414 &args.reviewer_kind,
415 &args.reason,
416 &cfg.agent_id,
417 )
418 };
419 match result {
420 Ok(outcome) => {
421 let status = if outcome.already_resolved {
422 "already-rejected"
423 } else {
424 "rejected"
425 };
426 let event = AuditEvent::new(
427 event_types::LEDGER_REJECT,
428 &args.reviewer,
429 &args.reviewer_kind,
430 status,
431 )
432 .with_subject(&outcome.entry.entry_id)
433 .with_secondary(&outcome.entry.symbol_id)
434 .with_matched_policy(outcome.entry.matched_policy.clone())
435 .with_reason(&args.reason)
436 .with_payload(json!({ "tags": outcome.entry.tags }));
437 emit_audit(engine.audit.as_ref(), event);
438
439 println!(
440 "{}",
441 serde_json::to_string_pretty(&json!({
442 "status": status,
443 "entry_id": outcome.entry.entry_id,
444 "symbol_id": outcome.entry.symbol_id,
445 "tags": outcome.entry.tags,
446 }))?
447 );
448 Ok(())
449 }
450 Err(e) => {
451 let event = AuditEvent::new(
452 event_types::LEDGER_REJECT,
453 &args.reviewer,
454 &args.reviewer_kind,
455 "error",
456 )
457 .with_subject(&args.entry_id)
458 .with_reason(e.to_string());
459 emit_audit(engine.audit.as_ref(), event);
460 Err(e.into())
461 }
462 }
463}
464
465fn withdraw(cfg: &Config, args: WithdrawArgs) -> Result<()> {
466 let engine = open_engine(cfg)?;
467 let situation = Situation {
468 description: format!("ledger.withdraw {}", args.entry_id),
469 qualifiers: json!({ "entry_id": &args.entry_id }),
470 };
471 if let Decision::Deny {
472 matched_policy,
473 reason,
474 } = engine
475 .policy
476 .evaluate(&situation, actions::LEDGER_WITHDRAW, &args.author_id)?
477 {
478 anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
479 }
480 let result = if let Some(ref ratify) = engine.ratify {
481 ratify.withdraw_entry(
482 &engine.repo,
483 &engine.ref_name,
484 &args.entry_id,
485 &args.author_id,
486 &cfg.agent_id,
487 )
488 } else {
489 let ledger_store = AsgLedgerStore::from_engine(&engine);
490 ledger_store.withdraw_entry(
491 &engine.ref_name,
492 &args.entry_id,
493 &args.author_id,
494 &cfg.agent_id,
495 )
496 };
497 match result {
498 Ok(outcome) => {
499 let status = if outcome.already_resolved {
500 "already-withdrawn"
501 } else {
502 "withdrawn"
503 };
504 let event = AuditEvent::new(
505 event_types::LEDGER_WITHDRAW,
506 &args.author_id,
507 "agent",
508 status,
509 )
510 .with_subject(&outcome.entry.entry_id)
511 .with_secondary(&outcome.entry.symbol_id)
512 .with_payload(json!({ "tags": outcome.entry.tags }));
513 emit_audit(engine.audit.as_ref(), event);
514
515 println!(
516 "{}",
517 serde_json::to_string_pretty(&json!({
518 "status": status,
519 "entry_id": outcome.entry.entry_id,
520 "symbol_id": outcome.entry.symbol_id,
521 "tags": outcome.entry.tags,
522 }))?
523 );
524 Ok(())
525 }
526 Err(e) => {
527 let event = AuditEvent::new(
528 event_types::LEDGER_WITHDRAW,
529 &args.author_id,
530 "agent",
531 "error",
532 )
533 .with_subject(&args.entry_id)
534 .with_reason(e.to_string());
535 emit_audit(engine.audit.as_ref(), event);
536 Err(e.into())
537 }
538 }
539}
540
541fn supersede(cfg: &Config, args: SupersedeArgs) -> Result<()> {
542 let engine = open_engine(cfg)?;
543 let index_store = AsgIndexStore::from_engine(&engine);
544 let symbol = index_store
545 .get_symbol_by_qname(&engine.ref_name, &args.qname)?
546 .ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
547
548 let author_kind_str: AuthorKind = args.author_kind.into();
549 let author_kind_label = match author_kind_str {
550 AuthorKind::Agent => "agent",
551 AuthorKind::Human => "human",
552 };
553 let author = Author {
554 kind: author_kind_str,
555 id: args.author_id.clone(),
556 };
557 let situation = Situation {
558 description: format!("ledger.supersede for {}", args.qname),
559 qualifiers: json!({
560 "qname": &args.qname,
561 "symbol_id": &symbol.symbol_id,
562 "file": &symbol.file,
563 "language": &symbol.language,
564 }),
565 };
566 if let Decision::Deny {
567 matched_policy,
568 reason,
569 } = engine
570 .policy
571 .evaluate(&situation, actions::LEDGER_SUPERSEDE, &args.author_id)?
572 {
573 anyhow::bail!("policy denied: {} (matched {})", reason, matched_policy);
574 }
575
576 let mut entry = LedgerEntry::new(&symbol.symbol_id, args.kind.into(), args.summary, author);
577 if let Some(body_path) = args.body {
578 entry.body = Some(std::fs::read_to_string(body_path)?);
579 }
580 entry.supersedes = args.supersedes.clone();
581 entry.tags.push("supersedes".to_string());
582
583 let ledger_store = AsgLedgerStore::from_engine(&engine);
584 match ledger_store.append_entry(&engine.ref_name, &entry, &cfg.agent_id) {
585 Ok(()) => {
586 let event = AuditEvent::new(
587 event_types::LEDGER_SUPERSEDE,
588 &args.author_id,
589 author_kind_label,
590 "success",
591 )
592 .with_subject(&entry.entry_id)
593 .with_secondary(&entry.symbol_id)
594 .with_payload(json!({
595 "supersedes": entry.supersedes,
596 "qname": args.qname,
597 }));
598 emit_audit(engine.audit.as_ref(), event);
599
600 println!(
601 "{}",
602 serde_json::to_string_pretty(&json!({
603 "status": "superseded",
604 "entry_id": entry.entry_id,
605 "symbol_id": entry.symbol_id,
606 "supersedes": entry.supersedes,
607 }))?
608 );
609 Ok(())
610 }
611 Err(e) => {
612 let event = AuditEvent::new(
613 event_types::LEDGER_SUPERSEDE,
614 &args.author_id,
615 author_kind_label,
616 "error",
617 )
618 .with_secondary(&symbol.symbol_id)
619 .with_reason(e.to_string());
620 emit_audit(engine.audit.as_ref(), event);
621 Err(e.into())
622 }
623 }
624}
625
626fn append(cfg: &Config, args: AppendArgs) -> Result<()> {
627 let engine = open_engine(cfg)?;
628
629 let index_store = AsgIndexStore::from_engine(&engine);
630 let symbol = index_store
631 .get_symbol_by_qname(&engine.ref_name, &args.qname)?
632 .ok_or_else(|| anyhow::anyhow!("symbol not found: {}", args.qname))?;
633
634 let ledger_kind: LedgerKind = args.kind.into();
635 let author_kind_str: AuthorKind = args.author_kind.into();
636 let author_kind_label = match author_kind_str {
637 AuthorKind::Agent => "agent",
638 AuthorKind::Human => "human",
639 };
640 let action = actions::ledger_append_action(ledger_kind.as_str());
641 let situation = Situation {
642 description: format!("ledger.append for {}", args.qname),
643 qualifiers: json!({
644 "qname": &args.qname,
645 "kind": ledger_kind.as_str(),
646 "symbol_id": &symbol.symbol_id,
647 "file": &symbol.file,
648 "language": &symbol.language,
649 }),
650 };
651 let decision = engine
652 .policy
653 .evaluate(&situation, &action, &args.author_id)?;
654
655 if let Decision::Deny {
656 matched_policy,
657 reason,
658 } = &decision
659 {
660 let event = AuditEvent::new(
662 event_types::LEDGER_APPEND,
663 &args.author_id,
664 author_kind_label,
665 "denied",
666 )
667 .with_secondary(&symbol.symbol_id)
668 .with_matched_policy(Some(matched_policy.clone()))
669 .with_reason(reason.clone())
670 .with_payload(json!({ "qname": &args.qname, "kind": ledger_kind.as_str() }));
671 emit_audit(engine.audit.as_ref(), event);
672
673 let err = json!({
674 "status": "denied",
675 "action": action,
676 "matched_policy": matched_policy,
677 "reason": reason,
678 });
679 println!("{}", serde_json::to_string_pretty(&err)?);
680 return Err(anyhow::anyhow!("policy denied: {}", reason));
681 }
682
683 let author = Author {
684 kind: author_kind_str,
685 id: args.author_id.clone(),
686 };
687 let mut entry = LedgerEntry::new(&symbol.symbol_id, ledger_kind, args.summary, author);
688 if let Some(body_path) = args.body {
689 let text = std::fs::read_to_string(&body_path)?;
690 entry.body = Some(text);
691 }
692 entry.matched_policy = decision.matched_policy();
693 if let Some(ref r) = args.role {
697 if agentstatedeveloper_core::RoleTag::from_str(r).is_none() {
698 let valid: Vec<&str> = agentstatedeveloper_core::RoleTag::all()
699 .iter()
700 .map(|t| t.as_str())
701 .collect();
702 eprintln!(
703 "asd: warning: role={:?} is not a canonical RoleTag. Valid: {}",
704 r,
705 valid.join(", ")
706 );
707 }
708 }
709 entry.role = args.role;
710 entry.command = args.command;
711
712 if let Decision::RequireApproval {
713 approvers, reason, ..
714 } = &decision
715 {
716 entry.tags.push("awaiting-approval".to_string());
717 for a in approvers {
718 entry.tags.push(format!("approver:{}", a));
719 }
720 if let Some(r) = reason {
721 if entry.body.is_none() {
722 entry.body = Some(format!("Approval reason: {}", r));
723 }
724 }
725 }
726
727 for tag in ctx_provenance_tags() {
729 if !entry.tags.contains(&tag) {
730 entry.tags.push(tag);
731 }
732 }
733
734 let ledger_store = AsgLedgerStore::from_engine(&engine);
735 ledger_store.append_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
736
737 let status = match &decision {
738 Decision::Allow { .. } => "allowed",
739 Decision::RequireApproval { .. } => "awaiting-approval",
740 Decision::Deny { .. } => "denied",
741 Decision::NoPolicyMatch => "no-policy-match",
742 };
743 let event = AuditEvent::new(
744 event_types::LEDGER_APPEND,
745 &args.author_id,
746 author_kind_label,
747 status,
748 )
749 .with_subject(&entry.entry_id)
750 .with_secondary(&entry.symbol_id)
751 .with_matched_policy(entry.matched_policy.clone())
752 .with_payload(json!({
753 "qname": &args.qname,
754 "kind": ledger_kind.as_str(),
755 "tags": &entry.tags,
756 }));
757 emit_audit(engine.audit.as_ref(), event);
758
759 println!(
760 "{}",
761 serde_json::to_string_pretty(&json!({
762 "entry_id": entry.entry_id,
763 "matched_policy": entry.matched_policy,
764 "status": status,
765 }))?
766 );
767 Ok(())
768}
769
770fn rebind(cfg: &Config, args: RebindArgs) -> Result<()> {
771 let engine = open_engine(cfg)?;
772
773 let requested: Vec<(String, String)> = match (&args.map, args.from, args.to) {
774 (Some(path), _, _) => {
775 let text = std::fs::read_to_string(path)
776 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
777 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&text)
778 .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
779 map.into_iter()
780 .map(|(from, to)| match to {
781 serde_json::Value::String(to) => Ok((from, to)),
782 _ => Err(anyhow::anyhow!(
783 "{}: the target for {from} must be a qualified name",
784 path.display()
785 )),
786 })
787 .collect::<Result<_>>()?
788 }
789 (None, Some(from), Some(to)) => vec![(from, to)],
790 _ => anyhow::bail!("pass --from and --to, or --map"),
791 };
792 if requested.is_empty() {
793 anyhow::bail!("no rebinds listed");
794 }
795
796 let index_store = AsgIndexStore::from_engine(&engine);
798 let ledger_store = AsgLedgerStore::from_engine(&engine);
799 let mut resolved = Vec::with_capacity(requested.len());
800 let mut problems = Vec::new();
801 for (from, to) in &requested {
802 let situation = Situation::new("rebind symbol")
803 .with_qualifier("from_symbol_id", from)
804 .with_qualifier("to_qname", to);
805 if let Decision::Deny {
806 matched_policy,
807 reason,
808 } = engine
809 .policy
810 .evaluate(&situation, actions::LEDGER_REBIND, &args.agent_id)?
811 {
812 problems.push(format!(
813 "{from}: policy denied by {matched_policy}: {reason}"
814 ));
815 continue;
816 }
817 let from_id = if from.starts_with("sym_") {
818 if ledger_store
819 .list_entries_with_superseded(&engine.ref_name, from)?
820 .is_empty()
821 {
822 problems.push(format!("{from}: no ledger entries are filed under it"));
823 continue;
824 }
825 from.clone()
826 } else {
827 match index_store.get_symbol_by_qname(&engine.ref_name, from)? {
828 Some(symbol) => symbol.symbol_id,
829 None => {
830 problems.push(format!(
831 "{from}: not in the index — for an orphaned symbol, pass its symbol_id"
832 ));
833 continue;
834 }
835 }
836 };
837 match index_store.get_symbol_by_qname(&engine.ref_name, to)? {
838 Some(symbol) => resolved.push((from_id, symbol)),
839 None => problems.push(format!("{to}: not in the index — run `asd index` first")),
840 }
841 }
842 if !problems.is_empty() {
843 anyhow::bail!("nothing rebound:\n {}", problems.join("\n "));
844 }
845
846 let outcomes = agentstatedeveloper_core::rebind_ledger(
847 &engine.repo,
848 engine.fts.as_ref(),
849 &engine.ref_name,
850 &resolved,
851 &args.agent_id,
852 )?;
853
854 for (outcome, (from, _)) in outcomes.iter().zip(&requested) {
855 let event = AuditEvent::new(event_types::LEDGER_REBIND, &args.agent_id, "agent", "allow")
856 .with_subject(outcome.from_symbol_id.clone())
857 .with_secondary(outcome.to_symbol_id.clone())
858 .with_payload(json!({
859 "from": from,
860 "from_symbol_id": outcome.from_symbol_id,
861 "to_symbol_id": outcome.to_symbol_id,
862 "to_qname": outcome.to_qname,
863 "entries_moved": outcome.entries_moved,
864 }));
865 emit_audit(engine.audit.as_ref(), event);
866 }
867
868 let output = match outcomes.as_slice() {
869 [one] if args.map.is_none() => json!({
870 "status": "rebound",
871 "from_symbol_id": one.from_symbol_id,
872 "to_symbol_id": one.to_symbol_id,
873 "to_qname": one.to_qname,
874 "entries_moved": one.entries_moved,
875 }),
876 all => json!({
877 "status": "rebound",
878 "rebinds": all,
879 "entries_moved": all.iter().map(|o| o.entries_moved).sum::<usize>(),
880 }),
881 };
882 println!("{}", serde_json::to_string_pretty(&output)?);
883 Ok(())
884}
885
886pub fn ctx_provenance_tags() -> Vec<String> {
893 let mut tags = Vec::new();
894 if let Ok(plan) = std::env::var("CTXONE_PLAN") {
895 if !plan.is_empty() {
896 tags.push(format!("ctx:plan:{}", plan));
897 }
898 }
899 if let Ok(task) = std::env::var("CTXONE_TASK") {
900 if !task.is_empty() {
901 tags.push(format!("ctx:task:{}", task));
902 }
903 }
904 tags
905}