Skip to main content

agentstatedeveloper_cli/commands/
ledger.rs

1//! `asd ledger …` — append ledger entries against a symbol.
2
3use 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
17/// Open the engine with optional policy + audit wiring based on cfg.
18/// If the process has installed a sink override (see
19/// `crate::set_audit_sink_override`), that sink is used. Otherwise
20/// events are swallowed by the default `NullSink`; when a log path
21/// was configured we surface a warning so the OSS user knows their
22/// `--audit-log` was a no-op.
23fn 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
46/// Public variant of [`open_engine`] for downstream crates that want
47/// to reuse the same policy + audit wiring. Used by `asd-pro`'s
48/// commercial subcommand handlers.
49pub fn open_engine_public(cfg: &Config) -> anyhow::Result<Engine> {
50    open_engine(cfg)
51}
52
53#[derive(Debug, Subcommand)]
54pub enum LedgerCmd {
55    /// Append a new ledger entry for a symbol.
56    Append(AppendArgs),
57
58    /// Approve an entry currently tagged `awaiting-approval`.
59    Approve(ApproveArgs),
60
61    /// Reject an entry currently tagged `awaiting-approval`.
62    Reject(RejectArgs),
63
64    /// Withdraw an awaiting-approval entry — must be called by the
65    /// original author.
66    Withdraw(WithdrawArgs),
67
68    /// Write a new entry that supersedes one or more existing entries.
69    /// Non-superseded entries remain; superseded ones are filtered out
70    /// of the default `list_entries` view.
71    Supersede(SupersedeArgs),
72
73    /// Record that a symbol was renamed/moved so its ledger history follows.
74    /// Writes a rebind record and re-parents all ledger entries to the new
75    /// symbol_id. Use this when you rename a function/class with an ASD-aware
76    /// tool so context doesn't become orphaned.
77    Rebind(RebindArgs),
78}
79
80#[derive(Debug, Args)]
81pub struct ApproveArgs {
82    /// Entry id (e.g., `led_abc…`) to approve.
83    pub entry_id: String,
84
85    /// Approver identifier. Recorded as `approved-by:<id>`.
86    #[arg(long)]
87    pub approver: String,
88
89    /// Approver kind. Must match one of the `approver:*` tags unless
90    /// the id itself matches.
91    #[arg(long, default_value = "human")]
92    pub approver_kind: String,
93
94    /// Optional rationale — appended to the entry body as an
95    /// "Approver note" section.
96    #[arg(long)]
97    pub message: Option<String>,
98}
99
100#[derive(Debug, Args)]
101pub struct RejectArgs {
102    /// Entry id to reject.
103    pub entry_id: String,
104
105    /// Reviewer identifier. Recorded as `rejected-by:<id>`.
106    #[arg(long)]
107    pub reviewer: String,
108
109    /// Reviewer kind. Same approver-match rule as approve.
110    #[arg(long, default_value = "human")]
111    pub reviewer_kind: String,
112
113    /// Rejection reason (required). Appended to the entry body.
114    #[arg(long)]
115    pub reason: String,
116}
117
118#[derive(Debug, Args)]
119pub struct WithdrawArgs {
120    /// Entry id to withdraw.
121    pub entry_id: String,
122
123    /// Author id — must match `entry.author.id`.
124    #[arg(long)]
125    pub author_id: String,
126}
127
128#[derive(Debug, Args)]
129pub struct SupersedeArgs {
130    /// Qname the new entry attaches to.
131    pub qname: String,
132
133    /// One or more entry ids to supersede. All must belong to the same
134    /// symbol as `qname`.
135    #[arg(long = "supersede", required = true)]
136    pub supersedes: Vec<String>,
137
138    /// Ledger kind for the new entry.
139    #[arg(long, value_enum)]
140    pub kind: CliLedgerKind,
141
142    /// One-line summary for the new entry.
143    #[arg(long)]
144    pub summary: String,
145
146    /// Optional body file.
147    #[arg(long)]
148    pub body: Option<PathBuf>,
149
150    /// Author kind.
151    #[arg(long, value_enum, default_value_t = CliAuthorKind::Agent)]
152    pub author_kind: CliAuthorKind,
153
154    /// Author id.
155    #[arg(long, default_value = "asd-cli-user")]
156    pub author_id: String,
157}
158
159#[derive(Debug, Args)]
160pub struct RebindArgs {
161    /// The symbol whose ledger history should move: its symbol_id
162    /// (`sym_abc123…`) — the only handle on an orphaned symbol, which is no
163    /// longer in the index — or its qualified name.
164    #[arg(
165        long,
166        required_unless_present = "map",
167        requires = "to",
168        conflicts_with = "map"
169    )]
170    pub from: Option<String>,
171
172    /// The qualified name the history moves to. Must exist in the index.
173    #[arg(long, requires = "from", conflicts_with = "map")]
174    pub to: Option<String>,
175
176    /// A JSON file of many rebinds at once: `{"<symbol_id or qname>": "<qname
177    /// it moves to>", …}`. Every source and target is checked before anything
178    /// is written, and all of them land in one commit.
179    #[arg(long, value_name = "FILE")]
180    pub map: Option<PathBuf>,
181
182    /// Author/agent performing the rebind.
183    #[arg(long, default_value = "asd-cli-user")]
184    pub agent_id: String,
185}
186
187#[derive(Debug, Args)]
188pub struct AppendArgs {
189    /// Fully-qualified symbol name.
190    pub qname: String,
191
192    /// Ledger kind.
193    #[arg(long, value_enum)]
194    pub kind: CliLedgerKind,
195
196    /// One-line summary.
197    #[arg(long)]
198    pub summary: String,
199
200    /// Optional path to a body file (markdown or plain text).
201    #[arg(long)]
202    pub body: Option<PathBuf>,
203
204    /// Author kind — `agent` or `human`.
205    #[arg(long, value_enum, default_value_t = CliAuthorKind::Agent)]
206    pub author_kind: CliAuthorKind,
207
208    /// Author identifier (email, agent-slug, etc.).
209    #[arg(long, default_value = "asd-cli-user")]
210    pub author_id: String,
211
212    /// Plan B t-002: classification role/intent tag (e.g. "diagnostic-test",
213    /// "fast-test", "fixture-path", "stale-api"). Optional; most meaningful
214    /// for kind=ownership/concept entries.
215    #[arg(long)]
216    pub role: Option<String>,
217
218    /// Plan B t-002: canonical reproduction or validation command
219    /// (e.g. "swift test --filter SongPlayersTests"). Optional; most
220    /// meaningful for kind=validation_scenario/proof/follow_up entries.
221    #[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 that must always hold at this symbol.
234    Invariant,
235    /// Ownership: which subsystem/team owns this symbol.
236    Ownership,
237    /// Proof that an invariant holds (test, review, trace).
238    Proof,
239    /// Concrete scenario to validate (behaviour + expected outcome).
240    #[value(name = "validation_scenario")]
241    ValidationScenario,
242    /// Known bug or defect not yet fixed.
243    #[value(name = "known_bug")]
244    KnownBug,
245    /// Domain concept (e.g. "Drift Pad clip playhead") — first-class queryable entity.
246    Concept,
247    /// Plan B t-002: replacement-coverage mapping ("legacy X is covered by new Y").
248    Mapping,
249    /// Plan B t-002: open follow-up tied to an external task.
250    #[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        // Emit a denied audit event even though no entry is written.
661        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    // Plan C t-002: validate role against the canonical RoleTag set;
694    // emit a stderr warning on unknown so unknown tags don't break old
695    // data but the user notices the typo.
696    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    // t-001: inject CTX plan/task provenance from env vars.
728    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    // Resolve and check every rebind before writing any of them.
797    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
886// ---------------------------------------------------------------------------
887// t-001: CTX plan/task provenance from env vars
888// ---------------------------------------------------------------------------
889
890/// Read CTXONE_PLAN and CTXONE_TASK env vars and return them as ledger tags.
891/// Call this before every ledger append to attach workflow provenance.
892pub 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}