Skip to main content

agentstategraph_ffi/
lib.rs

1//! C ABI for AgentStateGraph — opaque handle-based API.
2//!
3//! All functions use opaque pointers and C strings.
4//! The caller is responsible for freeing returned strings with agentstategraph_free_string.
5//!
6//! This crate produces a shared library (.so/.dylib/.dll) and static library (.a)
7//! that any language with C FFI can call.
8//!
9//! Every `extern "C"` function in this crate takes raw pointers by design —
10//! they form the C ABI contract. Marking them `unsafe fn` would change that
11//! ABI and make every bindgen-generated header noisier. We validate pointer
12//! inputs inside each function instead (`is_null` checks, bounded `CStr`
13//! reads). The blanket allow below silences clippy on this pattern
14//! crate-wide.
15
16#![allow(clippy::not_unsafe_ptr_arg_deref)]
17#![allow(clippy::missing_safety_doc)]
18
19use std::ffi::{CStr, CString};
20use std::os::raw::c_char;
21use std::ptr;
22use std::sync::Arc;
23
24use agentstategraph::{
25    CommitOptions, CreateSessionParams, Repository, RetentionPolicy, SpecHandle, SCHEMA_VERSION,
26};
27use agentstategraph_core::{
28    IntentCategory, Namespace, ObjectId, QueryFilters, SessionStatus, ToolCall,
29};
30use agentstategraph_migrate::{CheckResult, Registry, RunMode, StepStatus};
31use agentstategraph_policy::{ChangeProposal, Policy, PolicyStore, Situation};
32use agentstategraph_storage::SqliteStorage;
33use agentstategraph_taint::{
34    QuarantineParams, TaintEffect, TaintKind, TaintMetadata, TaintParams, TaintSeverity,
35    UntaintParams, UnwatchParams, WatchDirection, WatchParams,
36};
37use agentstategraph_tasks::{AddTaskOptions, Priority, Proof, ProofKind, TaskId, TaskStore};
38use semver::Version;
39use serde::Serialize;
40
41/// Opaque handle to a Repository.
42pub struct SgRepo {
43    inner: Arc<Repository>,
44}
45
46/// Opaque handle to a TaskStore.
47pub struct SgTaskStore {
48    inner: TaskStore,
49}
50
51/// Opaque handle to a PolicyStore.
52pub struct SgPolicyStore {
53    inner: PolicyStore,
54}
55
56/// Create a new in-memory AgentStateGraph repository.
57#[no_mangle]
58pub extern "C" fn agentstategraph_new_memory() -> *mut SgRepo {
59    let storage = match SqliteStorage::in_memory() {
60        Ok(s) => s,
61        Err(_) => return ptr::null_mut(),
62    };
63    let repo = Repository::new(Box::new(storage));
64    if repo.init().is_err() {
65        return ptr::null_mut();
66    }
67    Box::into_raw(Box::new(SgRepo {
68        inner: Arc::new(repo),
69    }))
70}
71
72/// Create a new SQLite-backed AgentStateGraph repository.
73#[no_mangle]
74pub extern "C" fn agentstategraph_new_sqlite(path: *const c_char) -> *mut SgRepo {
75    let path = unsafe {
76        if path.is_null() {
77            return ptr::null_mut();
78        }
79        match CStr::from_ptr(path).to_str() {
80            Ok(s) => s.to_string(),
81            Err(_) => return ptr::null_mut(),
82        }
83    };
84    let storage = match SqliteStorage::open(&path) {
85        Ok(s) => s,
86        Err(_) => return ptr::null_mut(),
87    };
88    let repo = Repository::new(Box::new(storage));
89    if repo.init().is_err() {
90        return ptr::null_mut();
91    }
92    Box::into_raw(Box::new(SgRepo {
93        inner: Arc::new(repo),
94    }))
95}
96
97/// Create a new Postgres-backed AgentStateGraph repository.
98///
99/// Feature-gated: only exported when the `postgres` feature is
100/// enabled at build time. Pass a libpq-style connection URL
101/// (e.g. `postgresql://user@host/dbname`) and a tenant id for
102/// multi-tenant isolation — every row written by this handle is
103/// scoped to `tenant_id`. Returns NULL on connection / init
104/// failure.
105#[cfg(feature = "postgres")]
106#[no_mangle]
107pub extern "C" fn agentstategraph_new_postgres(
108    url: *const c_char,
109    tenant_id: *const c_char,
110) -> *mut SgRepo {
111    use agentstategraph_storage::PostgresStorage;
112    if url.is_null() || tenant_id.is_null() {
113        return ptr::null_mut();
114    }
115    let url = match unsafe { CStr::from_ptr(url) }.to_str() {
116        Ok(s) => s.to_string(),
117        Err(_) => return ptr::null_mut(),
118    };
119    let tenant = match unsafe { CStr::from_ptr(tenant_id) }.to_str() {
120        Ok(s) => s.to_string(),
121        Err(_) => return ptr::null_mut(),
122    };
123    // PostgresStorage::connect_tenant is async. We need a runtime
124    // to drive it, and we need the runtime to persist so later
125    // block_on calls inside the Storage impls can dispatch on it.
126    // The pattern used by SqliteStorage is
127    // fully-sync, so for Postgres we run an owned multi-thread
128    // runtime and enter() its handle on construction so the repo
129    // inherits it.
130    let rt = match tokio::runtime::Builder::new_multi_thread()
131        .enable_all()
132        .build()
133    {
134        Ok(r) => r,
135        Err(_) => return ptr::null_mut(),
136    };
137    let storage = match rt.block_on(PostgresStorage::connect_tenant(&url, &tenant)) {
138        Ok(s) => s,
139        Err(_) => return ptr::null_mut(),
140    };
141    // Leak the runtime: its Handle::current is needed by every
142    // subsequent Storage call on this handle. Dropping the runtime
143    // would break in-flight queries; the runtime is freed alongside
144    // the SgRepo on agentstategraph_free via the leaker going out
145    // of scope is NOT done — we deliberately keep it alive for the
146    // process lifetime. For consumers that care about clean
147    // shutdown, shut the process down gracefully (same pattern as
148    // tokio::main).
149    let _guard = rt.enter();
150    std::mem::forget(rt);
151    let repo = Repository::new(Box::new(storage));
152    if repo.init().is_err() {
153        return ptr::null_mut();
154    }
155    Box::into_raw(Box::new(SgRepo {
156        inner: Arc::new(repo),
157    }))
158}
159
160/// Free a repository handle.
161#[no_mangle]
162pub extern "C" fn agentstategraph_free(repo: *mut SgRepo) {
163    if !repo.is_null() {
164        unsafe {
165            drop(Box::from_raw(repo));
166        }
167    }
168}
169
170/// Free a string returned by AgentStateGraph functions.
171#[no_mangle]
172pub extern "C" fn agentstategraph_free_string(s: *mut c_char) {
173    if !s.is_null() {
174        unsafe {
175            drop(CString::from_raw(s));
176        }
177    }
178}
179
180/// Get a JSON value at a path. Returns a JSON string (caller must free).
181#[no_mangle]
182pub extern "C" fn agentstategraph_get(
183    repo: *const SgRepo,
184    ref_name: *const c_char,
185    path: *const c_char,
186) -> *mut c_char {
187    let (repo, ref_name, path) = match unsafe { parse_repo_ref_path(repo, ref_name, path) } {
188        Some(v) => v,
189        None => return ptr::null_mut(),
190    };
191    match repo.inner.get_json(&ref_name, &path) {
192        Ok(val) => to_c_string(&serde_json::to_string(&val).unwrap_or_default()),
193        Err(_) => ptr::null_mut(),
194    }
195}
196
197/// Set a JSON value at a path. Returns commit ID string (caller must free).
198#[no_mangle]
199pub extern "C" fn agentstategraph_set(
200    repo: *const SgRepo,
201    ref_name: *const c_char,
202    path: *const c_char,
203    json_value: *const c_char,
204    intent_category: *const c_char,
205    intent_description: *const c_char,
206) -> *mut c_char {
207    let (repo, ref_name, path) = match unsafe { parse_repo_ref_path(repo, ref_name, path) } {
208        Some(v) => v,
209        None => return ptr::null_mut(),
210    };
211    let json_str = unsafe { c_to_str(json_value) };
212    let category_str = unsafe { c_to_str(intent_category) };
213    let desc_str = unsafe { c_to_str(intent_description) };
214
215    let value: serde_json::Value = match serde_json::from_str(&json_str) {
216        Ok(v) => v,
217        Err(_) => return ptr::null_mut(),
218    };
219
220    let category = parse_category(&category_str);
221    let opts = CommitOptions::new("ffi", category, &desc_str);
222
223    match repo.inner.set_json(&ref_name, &path, &value, opts) {
224        Ok(id) => to_c_string(&id.to_string()),
225        Err(_) => ptr::null_mut(),
226    }
227}
228
229/// Delete a value at a path. Returns commit ID string.
230#[no_mangle]
231pub extern "C" fn agentstategraph_delete(
232    repo: *const SgRepo,
233    ref_name: *const c_char,
234    path: *const c_char,
235    intent_category: *const c_char,
236    intent_description: *const c_char,
237) -> *mut c_char {
238    let (repo, ref_name, path) = match unsafe { parse_repo_ref_path(repo, ref_name, path) } {
239        Some(v) => v,
240        None => return ptr::null_mut(),
241    };
242    let category_str = unsafe { c_to_str(intent_category) };
243    let desc_str = unsafe { c_to_str(intent_description) };
244    let category = parse_category(&category_str);
245    let opts = CommitOptions::new("ffi", category, &desc_str);
246
247    match repo.inner.delete(&ref_name, &path, opts) {
248        Ok(id) => to_c_string(&id.to_string()),
249        Err(_) => ptr::null_mut(),
250    }
251}
252
253/// Create a branch. Returns commit ID string.
254#[no_mangle]
255pub extern "C" fn agentstategraph_branch(
256    repo: *const SgRepo,
257    name: *const c_char,
258    from: *const c_char,
259) -> *mut c_char {
260    let repo = unsafe { repo.as_ref() };
261    let repo = match repo {
262        Some(r) => r,
263        None => return ptr::null_mut(),
264    };
265    let name = unsafe { c_to_str(name) };
266    let from = unsafe { c_to_str(from) };
267
268    match repo.inner.branch(&name, &from) {
269        Ok(id) => to_c_string(&id.to_string()),
270        Err(_) => ptr::null_mut(),
271    }
272}
273
274/// List branches, optionally filtered by prefix. `prefix` may be NULL or
275/// an empty string for no filter. Returns a JSON array of
276/// `{"name": String, "target": String}` objects (target = commit id hex),
277/// or `{"error": "..."}` on failure.
278#[no_mangle]
279pub extern "C" fn agentstategraph_list_branches(
280    repo: *const SgRepo,
281    prefix: *const c_char,
282) -> *mut c_char {
283    let repo = match unsafe { repo.as_ref() } {
284        Some(r) => r,
285        None => return ptr::null_mut(),
286    };
287    let prefix_filter: Option<String> = if prefix.is_null() {
288        None
289    } else {
290        let s = unsafe { c_to_str(prefix) };
291        if s.is_empty() {
292            None
293        } else {
294            Some(s)
295        }
296    };
297    match repo.inner.list_branches(prefix_filter.as_deref()) {
298        Ok(entries) => {
299            let payload: Vec<serde_json::Value> = entries
300                .into_iter()
301                .map(|(name, id)| {
302                    serde_json::json!({
303                        "name": name,
304                        "target": id.to_string(),
305                    })
306                })
307                .collect();
308            json_ok(&payload)
309        }
310        Err(e) => json_err(&e.to_string()),
311    }
312}
313
314/// Delete a branch by name. Returns `{"deleted": true|false}` or
315/// `{"error": "..."}`. `deleted=false` means the ref didn't exist.
316#[no_mangle]
317pub extern "C" fn agentstategraph_delete_branch(
318    repo: *const SgRepo,
319    name: *const c_char,
320) -> *mut c_char {
321    let repo = match unsafe { repo.as_ref() } {
322        Some(r) => r,
323        None => return ptr::null_mut(),
324    };
325    let name = unsafe { c_to_str(name) };
326    match repo.inner.delete_branch(&name) {
327        Ok(deleted) => to_c_string(&format!("{{\"deleted\":{}}}", deleted)),
328        Err(e) => json_err(&e.to_string()),
329    }
330}
331
332/// Diff two refs. Returns JSON string of DiffOps.
333#[no_mangle]
334pub extern "C" fn agentstategraph_diff(
335    repo: *const SgRepo,
336    ref_a: *const c_char,
337    ref_b: *const c_char,
338) -> *mut c_char {
339    let repo = unsafe { repo.as_ref() };
340    let repo = match repo {
341        Some(r) => r,
342        None => return ptr::null_mut(),
343    };
344    let ref_a = unsafe { c_to_str(ref_a) };
345    let ref_b = unsafe { c_to_str(ref_b) };
346
347    match repo.inner.diff(&ref_a, &ref_b) {
348        Ok(ops) => to_c_string(&serde_json::to_string(&ops).unwrap_or_default()),
349        Err(_) => ptr::null_mut(),
350    }
351}
352
353/// Merge source into target. Returns commit ID or error JSON.
354#[no_mangle]
355pub extern "C" fn agentstategraph_merge(
356    repo: *const SgRepo,
357    source: *const c_char,
358    target: *const c_char,
359    description: *const c_char,
360) -> *mut c_char {
361    let repo = unsafe { repo.as_ref() };
362    let repo = match repo {
363        Some(r) => r,
364        None => return ptr::null_mut(),
365    };
366    let source = unsafe { c_to_str(source) };
367    let target = unsafe { c_to_str(target) };
368    let desc = unsafe { c_to_str(description) };
369
370    let opts = CommitOptions::new("ffi", IntentCategory::Merge, &desc);
371    match repo.inner.merge(&source, &target, opts) {
372        Ok(id) => to_c_string(&id.to_string()),
373        Err(e) => to_c_string(&format!("error:{}", e)),
374    }
375}
376
377/// Get commit log as JSON. Returns JSON array string.
378#[no_mangle]
379pub extern "C" fn agentstategraph_log(
380    repo: *const SgRepo,
381    ref_name: *const c_char,
382    limit: u32,
383) -> *mut c_char {
384    let repo = unsafe { repo.as_ref() };
385    let repo = match repo {
386        Some(r) => r,
387        None => return ptr::null_mut(),
388    };
389    let ref_name = unsafe { c_to_str(ref_name) };
390
391    match repo.inner.log(&ref_name, limit as usize) {
392        Ok(commits) => {
393            let entries: Vec<serde_json::Value> = commits
394                .iter()
395                .map(|c| {
396                    serde_json::json!({
397                        "id": c.id.short(),
398                        "agent": c.agent_id,
399                        "intent_category": format!("{:?}", c.intent.category),
400                        "intent_description": c.intent.description,
401                        "reasoning": c.reasoning,
402                        "confidence": c.confidence,
403                    })
404                })
405                .collect();
406            to_c_string(&serde_json::to_string(&entries).unwrap_or_default())
407        }
408        Err(_) => ptr::null_mut(),
409    }
410}
411
412/// Blame — returns JSON string with blame entry.
413#[no_mangle]
414pub extern "C" fn agentstategraph_blame(
415    repo: *const SgRepo,
416    ref_name: *const c_char,
417    path: *const c_char,
418) -> *mut c_char {
419    let (repo, ref_name, path) = match unsafe { parse_repo_ref_path(repo, ref_name, path) } {
420        Some(v) => v,
421        None => return ptr::null_mut(),
422    };
423    match repo.inner.blame(&ref_name, &path) {
424        Ok(entry) => to_c_string(&serde_json::to_string(&entry).unwrap_or_default()),
425        Err(_) => ptr::null_mut(),
426    }
427}
428
429/// Return the advanced repository API contract implemented by
430/// [`agentstategraph_repository_call`]. Keeping this contract data-driven gives
431/// native bindings one stable ABI entry point while still allowing their
432/// public APIs to remain strongly typed and idiomatic.
433#[no_mangle]
434pub extern "C" fn agentstategraph_repository_capabilities() -> *mut c_char {
435    json_ok(&serde_json::json!({
436        "contract_version": 1,
437        "operations": [
438            "head", "set_cas", "query.commits",
439            "namespace.current", "namespace.create", "namespace.list", "namespace.delete",
440            "merge.base", "merge.preview", "merge.checked",
441            "explore.list_paths", "explore.get_tree", "explore.search_values",
442            "explore.stats", "explore.commit_graph", "explore.intent_tree",
443            "explore.history", "gc.dry_run", "gc.sweep", "gc.vacuum",
444            "spec.create", "spec.set", "spec.delete", "spec.compare", "spec.commit",
445            "spec.discard", "spec.list",
446            "session.create", "session.get", "session.list", "session.children",
447            "session.update_head", "session.end", "session.active.get", "session.active.set",
448            "epoch.create", "epoch.get", "epoch.list", "epoch.seal", "epoch.archive",
449            "epoch.export", "epoch.active.get", "epoch.active.set"
450        ]
451    }))
452}
453
454/// Create another repository handle over the same storage, scoped to
455/// `namespace`. The returned handle has independent active session/epoch state
456/// and must be released with [`agentstategraph_free`].
457#[no_mangle]
458pub extern "C" fn agentstategraph_fork_namespace(
459    repo: *const SgRepo,
460    namespace: *const c_char,
461) -> *mut SgRepo {
462    let Some(repo) = (unsafe { repo.as_ref() }) else {
463        return ptr::null_mut();
464    };
465    let namespace = match Namespace::new(unsafe { c_to_str(namespace) }) {
466        Ok(ns) => ns,
467        Err(_) => return ptr::null_mut(),
468    };
469    let fork = repo.inner.fork_namespace(namespace);
470    if fork.init().is_err() {
471        return ptr::null_mut();
472    }
473    Box::into_raw(Box::new(SgRepo {
474        inner: Arc::new(fork),
475    }))
476}
477
478/// Invoke an advanced repository operation through the stable JSON ABI.
479///
480/// `request_json` must be a JSON object. Successful results and failures are
481/// JSON values; failures use the existing `{"error":"..."}` convention.
482/// Bindings should query [`agentstategraph_repository_capabilities`] instead of
483/// assuming that a newer operation exists in an older native artifact.
484#[no_mangle]
485pub extern "C" fn agentstategraph_repository_call(
486    repo: *const SgRepo,
487    operation: *const c_char,
488    request_json: *const c_char,
489) -> *mut c_char {
490    let Some(repo) = (unsafe { repo.as_ref() }) else {
491        return ptr::null_mut();
492    };
493    let operation = unsafe { c_to_str(operation) };
494    let request: serde_json::Value = if request_json.is_null() {
495        serde_json::json!({})
496    } else {
497        match serde_json::from_str(&unsafe { c_to_str(request_json) }) {
498            Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
499            Ok(_) => return json_err("request_json must be a JSON object"),
500            Err(e) => return json_err(&format!("invalid request_json: {e}")),
501        }
502    };
503
504    match repository_call(&repo.inner, &operation, &request) {
505        Ok(value) => json_ok(&value),
506        Err(error) => json_err(&error),
507    }
508}
509
510fn repository_call(
511    repo: &Repository,
512    operation: &str,
513    request: &serde_json::Value,
514) -> Result<serde_json::Value, String> {
515    let s = |key: &str| request_string(request, key);
516    let optional = |key: &str| request_optional_string(request, key);
517    let ref_name = || optional("ref").unwrap_or_else(|| "main".to_string());
518    let commit_options = || -> Result<CommitOptions, String> {
519        let mut options = CommitOptions::new(
520            optional("agent_id").unwrap_or_else(|| "ffi".to_string()),
521            parse_category(&optional("category").unwrap_or_else(|| "checkpoint".to_string())),
522            s("description")?,
523        );
524        if let Some(reasoning) = optional("reasoning") {
525            options = options.with_reasoning(reasoning);
526        }
527        if let Some(confidence) = request.get("confidence").and_then(|v| v.as_f64()) {
528            options = options.with_confidence(confidence);
529        }
530        if request.get("tags").is_some() {
531            options = options.with_tags(request_string_array(request, "tags")?);
532        }
533        let tool_calls = request_tool_calls(request)?;
534        if !tool_calls.is_empty() {
535            options = options.with_tool_calls(tool_calls);
536        }
537        Ok(options)
538    };
539
540    match operation {
541        "head" => Ok(
542            serde_json::json!({ "head": repo.head(&ref_name()).map_err(err_string)?.to_string() }),
543        ),
544        "set_cas" => {
545            let expected = parse_object_id(&s("expected_head")?)?;
546            let value = request
547                .get("value")
548                .cloned()
549                .ok_or_else(|| "missing field: value".to_string())?;
550            let id = repo
551                .set_json_cas(
552                    &ref_name(),
553                    expected,
554                    &s("path")?,
555                    &value,
556                    commit_options()?,
557                )
558                .map_err(err_string)?;
559            Ok(serde_json::json!({ "commit": id.to_string() }))
560        }
561        "query.commits" => {
562            let confidence_range = request
563                .get("confidence_min")
564                .and_then(|v| v.as_f64())
565                .zip(request.get("confidence_max").and_then(|v| v.as_f64()));
566            let filters = QueryFilters {
567                agent_id: optional("agent_id"),
568                intent_category: optional("intent_category"),
569                tags: request
570                    .get("tags")
571                    .map(|_| request_string_array(request, "tags"))
572                    .transpose()?,
573                reasoning_contains: optional("reasoning_contains"),
574                confidence_range,
575                has_deviations: request.get("has_deviations").and_then(|v| v.as_bool()),
576                ..Default::default()
577            };
578            let commits = repo
579                .query_commits(
580                    &ref_name(),
581                    &filters,
582                    request_usize(request, "limit").unwrap_or(50),
583                )
584                .map_err(err_string)?;
585            Ok(serde_json::Value::Array(commits.iter().map(|commit| serde_json::json!({
586                "id": commit.id.to_string(), "agent_id": commit.agent_id,
587                "intent_category": format!("{:?}", commit.intent.category),
588                "intent_description": commit.intent.description, "tags": commit.intent.tags,
589                "reasoning": commit.reasoning, "confidence": commit.confidence,
590                "parents": commit.parents.iter().map(ToString::to_string).collect::<Vec<_>>(),
591                "timestamp": commit.timestamp.to_rfc3339()
592            })).collect()))
593        }
594        "namespace.current" => Ok(serde_json::json!({ "namespace": repo.namespace().as_str() })),
595        "namespace.create" => {
596            let namespace = Namespace::new(s("name")?).map_err(err_string)?;
597            repo.create_namespace(namespace.as_str())
598                .map_err(err_string)?;
599            repo.fork_namespace(namespace).init().map_err(err_string)?;
600            Ok(serde_json::json!({ "created": true }))
601        }
602        "namespace.list" => Ok(
603            serde_json::to_value(repo.list_namespaces().map_err(err_string)?)
604                .map_err(err_string)?,
605        ),
606        "namespace.delete" => Ok(serde_json::json!({
607            "deleted": repo.delete_namespace(&s("name")?).map_err(err_string)?
608        })),
609        "merge.base" => Ok(serde_json::json!({
610            "commit": repo.merge_base(&s("source")?, &s("target")?).map_err(err_string)?.to_string()
611        })),
612        "merge.preview" => {
613            let p = repo
614                .preview_merge(&s("source")?, &s("target")?)
615                .map_err(err_string)?;
616            Ok(serde_json::json!({
617                "fast_forward": p.fast_forward, "added": p.added, "changed": p.changed,
618                "removed": p.removed, "conflicts": p.conflicts
619            }))
620        }
621        "merge.checked" => {
622            let id = repo
623                .merge_checked(
624                    &s("source")?,
625                    &s("target")?,
626                    commit_options()?,
627                    request
628                        .get("allow_deletions")
629                        .and_then(|v| v.as_bool())
630                        .unwrap_or(false),
631                )
632                .map_err(err_string)?;
633            Ok(serde_json::json!({ "commit": id.to_string() }))
634        }
635        "explore.list_paths" => Ok(serde_json::to_value(
636            repo.list_paths(
637                &ref_name(),
638                &optional("prefix").unwrap_or_else(|| "/".to_string()),
639                request_usize(request, "max_depth"),
640            )
641            .map_err(err_string)?,
642        )
643        .map_err(err_string)?),
644        "explore.get_tree" => repo
645            .get_tree(
646                &ref_name(),
647                &optional("prefix").unwrap_or_else(|| "/".to_string()),
648            )
649            .map_err(err_string),
650        "explore.search_values" => {
651            let rows = repo
652                .search_values(
653                    &ref_name(),
654                    &s("query")?,
655                    request_usize(request, "max_results"),
656                )
657                .map_err(err_string)?;
658            Ok(serde_json::Value::Array(
659                rows.into_iter()
660                    .map(|(path, value)| serde_json::json!({ "path": path, "value": value }))
661                    .collect(),
662            ))
663        }
664        "explore.stats" => repo.stats(&ref_name()).map_err(err_string),
665        "explore.commit_graph" => Ok(serde_json::Value::Array(
666            repo.commit_graph(&ref_name(), request_usize(request, "depth").unwrap_or(100))
667                .map_err(err_string)?,
668        )),
669        "explore.intent_tree" => repo
670            .intent_tree(&ref_name(), optional("root_commit_id").as_deref())
671            .map_err(err_string),
672        "explore.history" => repo
673            .history_report(
674                optional("namespace").as_deref(),
675                &optional("by").unwrap_or_else(|| "day".to_string()),
676                request_usize(request, "milestones").unwrap_or(50),
677                request
678                    .get("refresh")
679                    .and_then(|v| v.as_bool())
680                    .unwrap_or(true),
681                request
682                    .get("store")
683                    .and_then(|v| v.as_bool())
684                    .unwrap_or(false),
685            )
686            .map_err(err_string),
687        "gc.dry_run" => repo.gc_dry_run().map_err(err_string),
688        "gc.sweep" => {
689            let default = RetentionPolicy::default();
690            let policy = RetentionPolicy {
691                keep_recent: request_usize(request, "keep_recent").unwrap_or(default.keep_recent),
692                checkpoint_every: request_usize(request, "checkpoint_every")
693                    .unwrap_or(default.checkpoint_every),
694                keep_milestones: request
695                    .get("keep_milestones")
696                    .and_then(|v| v.as_bool())
697                    .unwrap_or(default.keep_milestones),
698            };
699            let mutate = request
700                .get("mutate")
701                .and_then(|v| v.as_bool())
702                .unwrap_or(false);
703            let vacuum = request
704                .get("vacuum")
705                .and_then(|v| v.as_bool())
706                .unwrap_or(false);
707            repo.gc_sweep(policy, mutate, vacuum).map_err(err_string)
708        }
709        "gc.vacuum" => repo.gc_vacuum().map_err(err_string),
710        "spec.create" => Ok(serde_json::json!({
711            "handle": repo.speculate(&ref_name(), optional("label")).map_err(err_string)?.id()
712        })),
713        "spec.set" => {
714            let value = request
715                .get("value")
716                .cloned()
717                .ok_or_else(|| "missing field: value".to_string())?;
718            repo.spec_set_json(spec_handle(request)?, &s("path")?, &value)
719                .map_err(err_string)?;
720            Ok(serde_json::json!({ "updated": true }))
721        }
722        "spec.delete" => {
723            repo.spec_delete(spec_handle(request)?, &s("path")?)
724                .map_err(err_string)?;
725            Ok(serde_json::json!({ "deleted": true }))
726        }
727        "spec.compare" => {
728            let ids = request
729                .get("handles")
730                .and_then(|v| v.as_array())
731                .ok_or_else(|| "missing field: handles".to_string())?;
732            let handles: Result<Vec<_>, _> = ids
733                .iter()
734                .map(|v| {
735                    v.as_u64()
736                        .map(SpecHandle::from_id)
737                        .ok_or_else(|| "handles must contain integers".to_string())
738                })
739                .collect();
740            let comparison = repo.compare_speculations(&handles?).map_err(err_string)?;
741            Ok(serde_json::json!({
742                "base_ref": comparison.base_ref,
743                "entries": comparison.entries.into_iter().map(|entry| serde_json::json!({
744                    "handle": entry.handle.id(), "label": entry.label, "diff": entry.diff_from_base
745                })).collect::<Vec<_>>()
746            }))
747        }
748        "spec.commit" => Ok(serde_json::json!({
749            "commit": repo.commit_speculation(spec_handle(request)?, commit_options()?)
750                .map_err(err_string)?.to_string()
751        })),
752        "spec.discard" => {
753            repo.discard_speculation(spec_handle(request)?)
754                .map_err(err_string)?;
755            Ok(serde_json::json!({ "discarded": true }))
756        }
757        "spec.list" => Ok(serde_json::Value::Array(
758            repo.list_speculations()
759                .into_iter()
760                .map(|(handle, label)| serde_json::json!({ "handle": handle.id(), "label": label }))
761                .collect(),
762        )),
763        "session.create" => {
764            let branch = optional("working_branch").unwrap_or_else(|| "main".to_string());
765            let namespace = match optional("scope_namespace") {
766                Some(name) => Some(Namespace::new(name).map_err(err_string)?),
767                None => None,
768            };
769            let session = repo
770                .sessions()
771                .create(
772                    &s("agent_id")?,
773                    &branch,
774                    repo.head(&branch).map_err(err_string)?,
775                    CreateSessionParams {
776                        parent_session: optional("parent_session"),
777                        delegated_intent: optional("delegated_intent"),
778                        report_to: optional("report_to"),
779                        path_scope: optional("path_scope"),
780                        scope_namespace: namespace,
781                    },
782                )
783                .map_err(err_string)?;
784            Ok(session_json(&session))
785        }
786        "session.get" => Ok(repo
787            .sessions()
788            .get(&s("id")?)
789            .map_err(err_string)?
790            .as_ref()
791            .map(session_json)
792            .unwrap_or(serde_json::Value::Null)),
793        "session.list" => Ok(serde_json::Value::Array(
794            repo.sessions()
795                .list(optional("agent_id").as_deref())
796                .map_err(err_string)?
797                .iter()
798                .map(session_json)
799                .collect(),
800        )),
801        "session.children" => Ok(serde_json::Value::Array(
802            repo.sessions()
803                .children(&s("parent_id")?)
804                .map_err(err_string)?
805                .iter()
806                .map(session_json)
807                .collect(),
808        )),
809        "session.update_head" => {
810            repo.sessions()
811                .update_head(&s("id")?, parse_object_id(&s("head")?)?)
812                .map_err(err_string)?;
813            Ok(serde_json::json!({ "updated": true }))
814        }
815        "session.end" => {
816            let status = match s("status")?.to_ascii_lowercase().as_str() {
817                "completed" => SessionStatus::Completed,
818                "abandoned" => SessionStatus::Abandoned,
819                _ => return Err("status must be completed or abandoned".to_string()),
820            };
821            repo.sessions().end(&s("id")?, status).map_err(err_string)?;
822            Ok(serde_json::json!({ "ended": true }))
823        }
824        "session.active.get" => {
825            Ok(serde_json::json!({ "session": repo.active_session().map_err(err_string)? }))
826        }
827        "session.active.set" => {
828            repo.set_active_session(optional("id"))
829                .map_err(err_string)?;
830            Ok(serde_json::json!({ "updated": true }))
831        }
832        "epoch.create" => Ok(epoch_json(
833            &repo
834                .create_epoch(
835                    &s("id")?,
836                    &s("description")?,
837                    request_string_array(request, "root_intents")?,
838                )
839                .map_err(err_string)?,
840        )),
841        "epoch.get" => Ok(epoch_json(&repo.get_epoch(&s("id")?).map_err(err_string)?)),
842        "epoch.list" => Ok(serde_json::Value::Array(
843            repo.list_epochs()
844                .map_err(err_string)?
845                .iter()
846                .map(|entry| {
847                    serde_json::json!({
848                        "id": entry.id, "description": entry.description,
849                        "status": format!("{:?}", entry.status),
850                        "created_at": entry.created_at.to_rfc3339(),
851                        "sealed_at": entry.sealed_at.map(|v| v.to_rfc3339()),
852                        "root_intents": entry.root_intents, "agents": entry.agents,
853                        "commit_count": entry.commit_count,
854                        "seal_hash": entry.seal_hash.map(|v| v.to_string()), "tags": entry.tags
855                    })
856                })
857                .collect(),
858        )),
859        "epoch.seal" => {
860            repo.seal_epoch(&s("id")?, &s("summary")?)
861                .map_err(err_string)?;
862            Ok(serde_json::json!({ "sealed": true }))
863        }
864        "epoch.archive" => {
865            repo.archive_epoch(&s("id")?).map_err(err_string)?;
866            Ok(serde_json::json!({ "archived": true }))
867        }
868        "epoch.export" => repo.export_epoch(&s("id")?).map_err(err_string),
869        "epoch.active.get" => {
870            Ok(serde_json::json!({ "epoch": repo.active_epoch().map_err(err_string)? }))
871        }
872        "epoch.active.set" => {
873            repo.set_active_epoch(optional("id")).map_err(err_string)?;
874            Ok(serde_json::json!({ "updated": true }))
875        }
876        _ => Err(format!("unsupported repository operation: {operation}")),
877    }
878}
879
880fn request_string(request: &serde_json::Value, key: &str) -> Result<String, String> {
881    request
882        .get(key)
883        .and_then(|v| v.as_str())
884        .map(str::to_owned)
885        .ok_or_else(|| format!("missing field: {key}"))
886}
887
888fn request_optional_string(request: &serde_json::Value, key: &str) -> Option<String> {
889    request.get(key).and_then(|v| v.as_str()).map(str::to_owned)
890}
891
892fn request_usize(request: &serde_json::Value, key: &str) -> Option<usize> {
893    request
894        .get(key)
895        .and_then(|v| v.as_u64())
896        .and_then(|v| usize::try_from(v).ok())
897}
898
899fn request_string_array(request: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
900    request
901        .get(key)
902        .and_then(|v| v.as_array())
903        .ok_or_else(|| format!("missing field: {key}"))?
904        .iter()
905        .map(|v| {
906            v.as_str()
907                .map(str::to_owned)
908                .ok_or_else(|| format!("{key} must contain strings"))
909        })
910        .collect()
911}
912
913/// Parse the optional `tool_calls` array from a request. Each entry needs a
914/// `tool_name`; `arguments` (any JSON) and `result` (string) are optional, and
915/// `timestamp` (RFC3339) defaults to now when omitted — agents needn't supply
916/// one. Absent `tool_calls` yields an empty vec.
917fn request_tool_calls(request: &serde_json::Value) -> Result<Vec<ToolCall>, String> {
918    let Some(array) = request.get("tool_calls") else {
919        return Ok(Vec::new());
920    };
921    let array = array
922        .as_array()
923        .ok_or_else(|| "tool_calls must be an array".to_string())?;
924    array
925        .iter()
926        .map(|entry| {
927            let tool_name = entry
928                .get("tool_name")
929                .and_then(|v| v.as_str())
930                .ok_or_else(|| "each tool_call needs a tool_name".to_string())?
931                .to_owned();
932            let timestamp = match entry.get("timestamp").and_then(|v| v.as_str()) {
933                Some(s) => chrono::DateTime::parse_from_rfc3339(s)
934                    .map_err(|e| format!("invalid tool_call timestamp: {e}"))?
935                    .with_timezone(&chrono::Utc),
936                None => chrono::Utc::now(),
937            };
938            Ok(ToolCall {
939                tool_name,
940                arguments: entry
941                    .get("arguments")
942                    .cloned()
943                    .unwrap_or(serde_json::Value::Null),
944                result: entry
945                    .get("result")
946                    .and_then(|v| v.as_str())
947                    .map(str::to_owned),
948                timestamp,
949            })
950        })
951        .collect()
952}
953
954fn parse_object_id(value: &str) -> Result<ObjectId, String> {
955    ObjectId::from_hex(value).ok_or_else(|| format!("invalid object id: {value}"))
956}
957
958fn spec_handle(request: &serde_json::Value) -> Result<SpecHandle, String> {
959    request
960        .get("handle")
961        .and_then(|v| v.as_u64())
962        .map(SpecHandle::from_id)
963        .ok_or_else(|| "missing field: handle".to_string())
964}
965
966fn err_string(error: impl std::fmt::Display) -> String {
967    error.to_string()
968}
969
970fn session_json(session: &agentstategraph::Session) -> serde_json::Value {
971    serde_json::json!({
972        "id": session.id, "agent_id": session.agent_id,
973        "working_branch": session.working_branch, "head": session.head.to_string(),
974        "parent_session": session.parent_session,
975        "delegated_intent": session.delegated_intent, "report_to": session.report_to,
976        "path_scope": session.path_scope, "scope_tenant": session.scope_tenant,
977        "scope_namespace": session.scope_namespace.as_ref().map(|v| v.as_str()),
978        "status": format!("{:?}", session.status),
979        "created_at": session.created_at.to_rfc3339(),
980        "ended_at": session.ended_at.map(|v| v.to_rfc3339())
981    })
982}
983
984fn epoch_json(epoch: &agentstategraph_core::Epoch) -> serde_json::Value {
985    serde_json::json!({
986        "id": epoch.id, "description": epoch.description,
987        "root_intents": epoch.root_intents, "status": format!("{:?}", epoch.status),
988        "created_at": epoch.created_at.to_rfc3339(),
989        "sealed_at": epoch.sealed_at.map(|v| v.to_rfc3339()),
990        "seal_summary": epoch.seal_summary,
991        "seal_hash": epoch.seal_hash.map(|v| v.to_string()),
992        "commits": epoch.commits.iter().map(ToString::to_string).collect::<Vec<_>>(),
993        "agents": epoch.agents, "branches": epoch.branches, "tags": epoch.tags,
994        "sealed_commits": epoch.sealed_commits.iter().map(ToString::to_string).collect::<Vec<_>>()
995    })
996}
997
998// ===========================================================================
999// TaskStore FFI
1000// ===========================================================================
1001
1002/// Create a new TaskStore handle bound to the given repository, path
1003/// prefix, and agent id. The returned pointer must be freed with
1004/// `agentstategraph_taskstore_free`. The underlying repository is shared
1005/// (refcounted); freeing the TaskStore does NOT free the repository.
1006#[no_mangle]
1007pub extern "C" fn agentstategraph_taskstore_new(
1008    repo: *const SgRepo,
1009    prefix: *const c_char,
1010    agent_id: *const c_char,
1011) -> *mut SgTaskStore {
1012    let repo = unsafe { repo.as_ref() };
1013    let repo = match repo {
1014        Some(r) => r,
1015        None => return ptr::null_mut(),
1016    };
1017    let prefix = unsafe { c_to_str(prefix) };
1018    let agent_id = unsafe { c_to_str(agent_id) };
1019    if prefix.is_empty() || agent_id.is_empty() {
1020        return ptr::null_mut();
1021    }
1022    let store = TaskStore::new(repo.inner.clone(), prefix, agent_id);
1023    Box::into_raw(Box::new(SgTaskStore { inner: store }))
1024}
1025
1026/// Free a TaskStore handle.
1027#[no_mangle]
1028pub extern "C" fn agentstategraph_taskstore_free(store: *mut SgTaskStore) {
1029    if !store.is_null() {
1030        unsafe {
1031            drop(Box::from_raw(store));
1032        }
1033    }
1034}
1035
1036fn taskstore_ref<'a>(store: *const SgTaskStore) -> Option<&'a SgTaskStore> {
1037    unsafe { store.as_ref() }
1038}
1039
1040fn json_ok<T: Serialize>(v: &T) -> *mut c_char {
1041    match serde_json::to_string(v) {
1042        Ok(s) => to_c_string(&s),
1043        Err(_) => ptr::null_mut(),
1044    }
1045}
1046
1047fn json_err(msg: &str) -> *mut c_char {
1048    to_c_string(&format!(
1049        "{{\"error\":{}}}",
1050        serde_json::to_string(msg).unwrap_or_else(|_| "\"error\"".into())
1051    ))
1052}
1053
1054// Plan ops ------------------------------------------------------------------
1055
1056/// Create a plan. Returns the Plan as JSON, or `{"error": ...}` on failure.
1057#[no_mangle]
1058pub extern "C" fn agentstategraph_taskstore_create_plan(
1059    store: *const SgTaskStore,
1060    ref_name: *const c_char,
1061    name: *const c_char,
1062    description: *const c_char,
1063) -> *mut c_char {
1064    let Some(store) = taskstore_ref(store) else {
1065        return ptr::null_mut();
1066    };
1067    let ref_name = unsafe { c_to_str(ref_name) };
1068    let name = unsafe { c_to_str(name) };
1069    let description = if description.is_null() {
1070        None
1071    } else {
1072        let s = unsafe { c_to_str(description) };
1073        if s.is_empty() {
1074            None
1075        } else {
1076            Some(s)
1077        }
1078    };
1079    match store.inner.create_plan(&ref_name, &name, description) {
1080        Ok(plan) => json_ok(&plan),
1081        Err(e) => json_err(&e.to_string()),
1082    }
1083}
1084
1085/// List plans. Returns JSON array.
1086#[no_mangle]
1087pub extern "C" fn agentstategraph_taskstore_list_plans(
1088    store: *const SgTaskStore,
1089    ref_name: *const c_char,
1090) -> *mut c_char {
1091    let Some(store) = taskstore_ref(store) else {
1092        return ptr::null_mut();
1093    };
1094    let ref_name = unsafe { c_to_str(ref_name) };
1095    match store.inner.list_plans(&ref_name) {
1096        Ok(p) => json_ok(&p),
1097        Err(e) => json_err(&e.to_string()),
1098    }
1099}
1100
1101/// List plans filtered by status. `status` is "active", "completed",
1102/// "archived", or null/empty for all. Returns JSON array.
1103#[no_mangle]
1104pub extern "C" fn agentstategraph_taskstore_list_plans_by_status(
1105    store: *const SgTaskStore,
1106    ref_name: *const c_char,
1107    status: *const c_char,
1108) -> *mut c_char {
1109    let Some(store) = taskstore_ref(store) else {
1110        return ptr::null_mut();
1111    };
1112    let ref_name = unsafe { c_to_str(ref_name) };
1113    let status_str = unsafe { c_to_str(status) };
1114    let parsed = parse_plan_status(&status_str);
1115    match store.inner.list_plans_by_status(&ref_name, parsed) {
1116        Ok(p) => json_ok(&p),
1117        Err(e) => json_err(&e.to_string()),
1118    }
1119}
1120
1121/// Get a plan by name.
1122#[no_mangle]
1123pub extern "C" fn agentstategraph_taskstore_get_plan(
1124    store: *const SgTaskStore,
1125    ref_name: *const c_char,
1126    name: *const c_char,
1127) -> *mut c_char {
1128    let Some(store) = taskstore_ref(store) else {
1129        return ptr::null_mut();
1130    };
1131    let ref_name = unsafe { c_to_str(ref_name) };
1132    let name = unsafe { c_to_str(name) };
1133    match store.inner.get_plan(&ref_name, &name) {
1134        Ok(p) => json_ok(&p),
1135        Err(e) => json_err(&e.to_string()),
1136    }
1137}
1138
1139#[no_mangle]
1140pub extern "C" fn agentstategraph_taskstore_archive_plan(
1141    store: *const SgTaskStore,
1142    ref_name: *const c_char,
1143    name: *const c_char,
1144) -> *mut c_char {
1145    let Some(store) = taskstore_ref(store) else {
1146        return ptr::null_mut();
1147    };
1148    let ref_name = unsafe { c_to_str(ref_name) };
1149    let name = unsafe { c_to_str(name) };
1150    match store.inner.archive_plan(&ref_name, &name) {
1151        Ok(p) => json_ok(&p),
1152        Err(e) => json_err(&e.to_string()),
1153    }
1154}
1155
1156#[no_mangle]
1157pub extern "C" fn agentstategraph_taskstore_delete_plan(
1158    store: *const SgTaskStore,
1159    ref_name: *const c_char,
1160    name: *const c_char,
1161) -> *mut c_char {
1162    let Some(store) = taskstore_ref(store) else {
1163        return ptr::null_mut();
1164    };
1165    let ref_name = unsafe { c_to_str(ref_name) };
1166    let name = unsafe { c_to_str(name) };
1167    match store.inner.delete_plan(&ref_name, &name) {
1168        Ok(()) => to_c_string("{\"ok\":true}"),
1169        Err(e) => json_err(&e.to_string()),
1170    }
1171}
1172
1173// Task ops ------------------------------------------------------------------
1174
1175/// Add a task. Arguments:
1176///   - priority: "low" | "medium" | "high" | "critical"
1177///   - parent_id: nullable, e.g. "t-001"
1178///   - blockers_json: JSON array of task ids (may be null or "[]")
1179///   - assigned_to: nullable agent id
1180#[no_mangle]
1181pub extern "C" fn agentstategraph_taskstore_add_task(
1182    store: *const SgTaskStore,
1183    ref_name: *const c_char,
1184    plan: *const c_char,
1185    title: *const c_char,
1186    priority: *const c_char,
1187    parent_id: *const c_char,
1188    blockers_json: *const c_char,
1189    assigned_to: *const c_char,
1190) -> *mut c_char {
1191    let Some(store) = taskstore_ref(store) else {
1192        return ptr::null_mut();
1193    };
1194    let ref_name = unsafe { c_to_str(ref_name) };
1195    let plan = unsafe { c_to_str(plan) };
1196    let title = unsafe { c_to_str(title) };
1197    let priority_str = unsafe { c_to_str(priority) };
1198    let prio = parse_priority(&priority_str);
1199
1200    let parent = if parent_id.is_null() {
1201        None
1202    } else {
1203        let s = unsafe { c_to_str(parent_id) };
1204        if s.is_empty() {
1205            None
1206        } else {
1207            Some(TaskId(s))
1208        }
1209    };
1210
1211    let blockers: Vec<TaskId> = if blockers_json.is_null() {
1212        Vec::new()
1213    } else {
1214        let s = unsafe { c_to_str(blockers_json) };
1215        if s.is_empty() {
1216            Vec::new()
1217        } else {
1218            match serde_json::from_str::<Vec<String>>(&s) {
1219                Ok(v) => v.into_iter().map(TaskId).collect(),
1220                Err(e) => return json_err(&format!("invalid blockers_json: {e}")),
1221            }
1222        }
1223    };
1224
1225    let assigned = if assigned_to.is_null() {
1226        None
1227    } else {
1228        let s = unsafe { c_to_str(assigned_to) };
1229        if s.is_empty() {
1230            None
1231        } else {
1232            Some(s)
1233        }
1234    };
1235
1236    match store
1237        .inner
1238        .add_task(&ref_name, &plan, &title, prio, parent, blockers, assigned)
1239    {
1240        Ok(t) => json_ok(&t),
1241        Err(e) => json_err(&e.to_string()),
1242    }
1243}
1244
1245/// Extended add_task that also threads the 0.6.0 Task extension fields
1246/// through the FFI: `payload_json` (an arbitrary JSON value or NULL),
1247/// `parent_change` (opaque string or NULL), `on_complete_json` (a JSON
1248/// OnCompleteHook value or NULL — see agentstategraph-tasks::OnCompleteHook
1249/// for the variant tags). Returns the Task as JSON or `{"error": "..."}`.
1250#[no_mangle]
1251#[allow(clippy::too_many_arguments)]
1252pub extern "C" fn agentstategraph_taskstore_add_task_ex(
1253    store: *const SgTaskStore,
1254    ref_name: *const c_char,
1255    plan: *const c_char,
1256    title: *const c_char,
1257    priority: *const c_char,
1258    parent_id: *const c_char,
1259    blockers_json: *const c_char,
1260    assigned_to: *const c_char,
1261    payload_json: *const c_char,
1262    parent_change: *const c_char,
1263    on_complete_json: *const c_char,
1264) -> *mut c_char {
1265    let Some(store) = taskstore_ref(store) else {
1266        return ptr::null_mut();
1267    };
1268    let ref_name = unsafe { c_to_str(ref_name) };
1269    let plan = unsafe { c_to_str(plan) };
1270    let title = unsafe { c_to_str(title) };
1271    let priority_str = unsafe { c_to_str(priority) };
1272    let prio = parse_priority(&priority_str);
1273
1274    let parent = if parent_id.is_null() {
1275        None
1276    } else {
1277        let s = unsafe { c_to_str(parent_id) };
1278        if s.is_empty() {
1279            None
1280        } else {
1281            Some(TaskId(s))
1282        }
1283    };
1284
1285    let blockers: Vec<TaskId> = if blockers_json.is_null() {
1286        Vec::new()
1287    } else {
1288        let s = unsafe { c_to_str(blockers_json) };
1289        if s.is_empty() {
1290            Vec::new()
1291        } else {
1292            match serde_json::from_str::<Vec<String>>(&s) {
1293                Ok(v) => v.into_iter().map(TaskId).collect(),
1294                Err(e) => return json_err(&format!("invalid blockers_json: {e}")),
1295            }
1296        }
1297    };
1298
1299    let assigned = if assigned_to.is_null() {
1300        None
1301    } else {
1302        let s = unsafe { c_to_str(assigned_to) };
1303        if s.is_empty() {
1304            None
1305        } else {
1306            Some(s)
1307        }
1308    };
1309
1310    let payload: Option<serde_json::Value> = if payload_json.is_null() {
1311        None
1312    } else {
1313        let s = unsafe { c_to_str(payload_json) };
1314        if s.is_empty() {
1315            None
1316        } else {
1317            match serde_json::from_str(&s) {
1318                Ok(v) => Some(v),
1319                Err(e) => return json_err(&format!("invalid payload_json: {e}")),
1320            }
1321        }
1322    };
1323
1324    let parent_change_opt: Option<String> = if parent_change.is_null() {
1325        None
1326    } else {
1327        let s = unsafe { c_to_str(parent_change) };
1328        if s.is_empty() {
1329            None
1330        } else {
1331            Some(s)
1332        }
1333    };
1334
1335    let on_complete_opt: Option<agentstategraph_tasks::OnCompleteHook> =
1336        if on_complete_json.is_null() {
1337            None
1338        } else {
1339            let s = unsafe { c_to_str(on_complete_json) };
1340            if s.is_empty() {
1341                None
1342            } else {
1343                match serde_json::from_str(&s) {
1344                    Ok(v) => Some(v),
1345                    Err(e) => return json_err(&format!("invalid on_complete_json: {e}")),
1346                }
1347            }
1348        };
1349
1350    match store.inner.add_task_with_extensions(
1351        &ref_name,
1352        &plan,
1353        &title,
1354        prio,
1355        parent,
1356        blockers,
1357        assigned,
1358        AddTaskOptions {
1359            payload,
1360            parent_change: parent_change_opt,
1361            on_complete: on_complete_opt,
1362        },
1363    ) {
1364        Ok(t) => json_ok(&t),
1365        Err(e) => json_err(&e.to_string()),
1366    }
1367}
1368
1369#[no_mangle]
1370pub extern "C" fn agentstategraph_taskstore_list_tasks(
1371    store: *const SgTaskStore,
1372    ref_name: *const c_char,
1373    plan: *const c_char,
1374) -> *mut c_char {
1375    let Some(store) = taskstore_ref(store) else {
1376        return ptr::null_mut();
1377    };
1378    let ref_name = unsafe { c_to_str(ref_name) };
1379    let plan = unsafe { c_to_str(plan) };
1380    match store.inner.list_tasks(&ref_name, &plan) {
1381        Ok(t) => json_ok(&t),
1382        Err(e) => json_err(&e.to_string()),
1383    }
1384}
1385
1386#[no_mangle]
1387pub extern "C" fn agentstategraph_taskstore_task_ids(
1388    store: *const SgTaskStore,
1389    ref_name: *const c_char,
1390    plan: *const c_char,
1391) -> *mut c_char {
1392    let Some(store) = taskstore_ref(store) else {
1393        return ptr::null_mut();
1394    };
1395    let ref_name = unsafe { c_to_str(ref_name) };
1396    let plan = unsafe { c_to_str(plan) };
1397    match store.inner.task_ids(&ref_name, &plan) {
1398        Ok(ids) => {
1399            let strs: Vec<String> = ids.into_iter().map(|i| i.0).collect();
1400            json_ok(&strs)
1401        }
1402        Err(e) => json_err(&e.to_string()),
1403    }
1404}
1405
1406#[no_mangle]
1407pub extern "C" fn agentstategraph_taskstore_get_task(
1408    store: *const SgTaskStore,
1409    ref_name: *const c_char,
1410    plan: *const c_char,
1411    task_id: *const c_char,
1412) -> *mut c_char {
1413    let Some(store) = taskstore_ref(store) else {
1414        return ptr::null_mut();
1415    };
1416    let ref_name = unsafe { c_to_str(ref_name) };
1417    let plan = unsafe { c_to_str(plan) };
1418    let id = TaskId(unsafe { c_to_str(task_id) });
1419    match store.inner.get_task(&ref_name, &plan, &id) {
1420        Ok(t) => json_ok(&t),
1421        Err(e) => json_err(&e.to_string()),
1422    }
1423}
1424
1425#[no_mangle]
1426pub extern "C" fn agentstategraph_taskstore_start_task(
1427    store: *const SgTaskStore,
1428    ref_name: *const c_char,
1429    plan: *const c_char,
1430    task_id: *const c_char,
1431) -> *mut c_char {
1432    let Some(store) = taskstore_ref(store) else {
1433        return ptr::null_mut();
1434    };
1435    let ref_name = unsafe { c_to_str(ref_name) };
1436    let plan = unsafe { c_to_str(plan) };
1437    let id = TaskId(unsafe { c_to_str(task_id) });
1438    match store.inner.start_task(&ref_name, &plan, &id) {
1439        Ok(t) => json_ok(&t),
1440        Err(e) => json_err(&e.to_string()),
1441    }
1442}
1443
1444/// Complete a task. `proof_kind` is "commit" | "file" | "test" | "text".
1445#[no_mangle]
1446pub extern "C" fn agentstategraph_taskstore_complete_task(
1447    store: *const SgTaskStore,
1448    ref_name: *const c_char,
1449    plan: *const c_char,
1450    task_id: *const c_char,
1451    proof_kind: *const c_char,
1452    proof_value: *const c_char,
1453    proof_note: *const c_char,
1454) -> *mut c_char {
1455    let Some(store) = taskstore_ref(store) else {
1456        return ptr::null_mut();
1457    };
1458    let ref_name = unsafe { c_to_str(ref_name) };
1459    let plan = unsafe { c_to_str(plan) };
1460    let id = TaskId(unsafe { c_to_str(task_id) });
1461    let kind_str = unsafe { c_to_str(proof_kind) };
1462    let value = unsafe { c_to_str(proof_value) };
1463    let kind = match parse_proof_kind(&kind_str) {
1464        Some(k) => k,
1465        None => return json_err(&format!("invalid proof kind: {kind_str}")),
1466    };
1467    let note = if proof_note.is_null() {
1468        None
1469    } else {
1470        let s = unsafe { c_to_str(proof_note) };
1471        if s.is_empty() {
1472            None
1473        } else {
1474            Some(s)
1475        }
1476    };
1477    let proof = Proof { kind, value, note };
1478    match store.inner.complete_task(&ref_name, &plan, &id, proof) {
1479        Ok(t) => json_ok(&t),
1480        Err(e) => json_err(&e.to_string()),
1481    }
1482}
1483
1484#[no_mangle]
1485pub extern "C" fn agentstategraph_taskstore_abandon_task(
1486    store: *const SgTaskStore,
1487    ref_name: *const c_char,
1488    plan: *const c_char,
1489    task_id: *const c_char,
1490    reason: *const c_char,
1491) -> *mut c_char {
1492    let Some(store) = taskstore_ref(store) else {
1493        return ptr::null_mut();
1494    };
1495    let ref_name = unsafe { c_to_str(ref_name) };
1496    let plan = unsafe { c_to_str(plan) };
1497    let id = TaskId(unsafe { c_to_str(task_id) });
1498    let reason = unsafe { c_to_str(reason) };
1499    match store.inner.abandon_task(&ref_name, &plan, &id, &reason) {
1500        Ok(t) => json_ok(&t),
1501        Err(e) => json_err(&e.to_string()),
1502    }
1503}
1504
1505#[no_mangle]
1506pub extern "C" fn agentstategraph_taskstore_set_priority(
1507    store: *const SgTaskStore,
1508    ref_name: *const c_char,
1509    plan: *const c_char,
1510    task_id: *const c_char,
1511    priority: *const c_char,
1512) -> *mut c_char {
1513    let Some(store) = taskstore_ref(store) else {
1514        return ptr::null_mut();
1515    };
1516    let ref_name = unsafe { c_to_str(ref_name) };
1517    let plan = unsafe { c_to_str(plan) };
1518    let id = TaskId(unsafe { c_to_str(task_id) });
1519    let prio = parse_priority(&unsafe { c_to_str(priority) });
1520    match store.inner.set_priority(&ref_name, &plan, &id, prio) {
1521        Ok(t) => json_ok(&t),
1522        Err(e) => json_err(&e.to_string()),
1523    }
1524}
1525
1526/// Replace a task's title; a blank title is refused.
1527#[no_mangle]
1528pub extern "C" fn agentstategraph_taskstore_set_title(
1529    store: *const SgTaskStore,
1530    ref_name: *const c_char,
1531    plan: *const c_char,
1532    task_id: *const c_char,
1533    title: *const c_char,
1534) -> *mut c_char {
1535    let Some(store) = taskstore_ref(store) else {
1536        return ptr::null_mut();
1537    };
1538    let ref_name = unsafe { c_to_str(ref_name) };
1539    let plan = unsafe { c_to_str(plan) };
1540    let id = TaskId(unsafe { c_to_str(task_id) });
1541    let title = unsafe { c_to_str(title) };
1542    match store.inner.set_title(&ref_name, &plan, &id, &title) {
1543        Ok(t) => json_ok(&t),
1544        Err(e) => json_err(&e.to_string()),
1545    }
1546}
1547
1548/// Set blockers. `blockers_json` is a JSON string array (may be null or "[]").
1549#[no_mangle]
1550pub extern "C" fn agentstategraph_taskstore_set_blockers(
1551    store: *const SgTaskStore,
1552    ref_name: *const c_char,
1553    plan: *const c_char,
1554    task_id: *const c_char,
1555    blockers_json: *const c_char,
1556) -> *mut c_char {
1557    let Some(store) = taskstore_ref(store) else {
1558        return ptr::null_mut();
1559    };
1560    let ref_name = unsafe { c_to_str(ref_name) };
1561    let plan = unsafe { c_to_str(plan) };
1562    let id = TaskId(unsafe { c_to_str(task_id) });
1563    let blockers: Vec<TaskId> = if blockers_json.is_null() {
1564        Vec::new()
1565    } else {
1566        let s = unsafe { c_to_str(blockers_json) };
1567        if s.is_empty() {
1568            Vec::new()
1569        } else {
1570            match serde_json::from_str::<Vec<String>>(&s) {
1571                Ok(v) => v.into_iter().map(TaskId).collect(),
1572                Err(e) => return json_err(&format!("invalid blockers_json: {e}")),
1573            }
1574        }
1575    };
1576    match store.inner.set_blockers(&ref_name, &plan, &id, blockers) {
1577        Ok(t) => json_ok(&t),
1578        Err(e) => json_err(&e.to_string()),
1579    }
1580}
1581
1582#[no_mangle]
1583pub extern "C" fn agentstategraph_taskstore_assign_task(
1584    store: *const SgTaskStore,
1585    ref_name: *const c_char,
1586    plan: *const c_char,
1587    task_id: *const c_char,
1588    agent: *const c_char,
1589) -> *mut c_char {
1590    let Some(store) = taskstore_ref(store) else {
1591        return ptr::null_mut();
1592    };
1593    let ref_name = unsafe { c_to_str(ref_name) };
1594    let plan = unsafe { c_to_str(plan) };
1595    let id = TaskId(unsafe { c_to_str(task_id) });
1596    let agent = unsafe { c_to_str(agent) };
1597    match store.inner.assign_task(&ref_name, &plan, &id, &agent) {
1598        Ok(t) => json_ok(&t),
1599        Err(e) => json_err(&e.to_string()),
1600    }
1601}
1602
1603#[no_mangle]
1604pub extern "C" fn agentstategraph_taskstore_unassign_task(
1605    store: *const SgTaskStore,
1606    ref_name: *const c_char,
1607    plan: *const c_char,
1608    task_id: *const c_char,
1609) -> *mut c_char {
1610    let Some(store) = taskstore_ref(store) else {
1611        return ptr::null_mut();
1612    };
1613    let ref_name = unsafe { c_to_str(ref_name) };
1614    let plan = unsafe { c_to_str(plan) };
1615    let id = TaskId(unsafe { c_to_str(task_id) });
1616    match store.inner.unassign_task(&ref_name, &plan, &id) {
1617        Ok(t) => json_ok(&t),
1618        Err(e) => json_err(&e.to_string()),
1619    }
1620}
1621
1622/// Returns the next unblocked task as JSON, `null` if none, or error JSON.
1623#[no_mangle]
1624pub extern "C" fn agentstategraph_taskstore_next_task(
1625    store: *const SgTaskStore,
1626    ref_name: *const c_char,
1627    plan: *const c_char,
1628) -> *mut c_char {
1629    let Some(store) = taskstore_ref(store) else {
1630        return ptr::null_mut();
1631    };
1632    let ref_name = unsafe { c_to_str(ref_name) };
1633    let plan = unsafe { c_to_str(plan) };
1634    match store.inner.next_task(&ref_name, &plan) {
1635        Ok(opt) => json_ok(&opt),
1636        Err(e) => json_err(&e.to_string()),
1637    }
1638}
1639
1640/// Next task filtered by assignment. `agent` null for any; `include_unassigned`
1641/// controls fallback to unassigned when an agent is specified.
1642#[no_mangle]
1643pub extern "C" fn agentstategraph_taskstore_next_task_for(
1644    store: *const SgTaskStore,
1645    ref_name: *const c_char,
1646    plan: *const c_char,
1647    agent: *const c_char,
1648    include_unassigned: u8,
1649) -> *mut c_char {
1650    let Some(store) = taskstore_ref(store) else {
1651        return ptr::null_mut();
1652    };
1653    let ref_name = unsafe { c_to_str(ref_name) };
1654    let plan = unsafe { c_to_str(plan) };
1655    let agent_str = if agent.is_null() {
1656        None
1657    } else {
1658        let s = unsafe { c_to_str(agent) };
1659        if s.is_empty() {
1660            None
1661        } else {
1662            Some(s)
1663        }
1664    };
1665    match store.inner.next_task_for(
1666        &ref_name,
1667        &plan,
1668        agent_str.as_deref(),
1669        include_unassigned != 0,
1670    ) {
1671        Ok(opt) => json_ok(&opt),
1672        Err(e) => json_err(&e.to_string()),
1673    }
1674}
1675
1676/// Compute the derived (rollup) status of a parent task. Returns
1677/// `"pending"` | `"in_progress"` | `"done"` | `"abandoned"` JSON-wrapped.
1678#[no_mangle]
1679pub extern "C" fn agentstategraph_taskstore_derived_status(
1680    store: *const SgTaskStore,
1681    ref_name: *const c_char,
1682    plan: *const c_char,
1683    parent_id: *const c_char,
1684) -> *mut c_char {
1685    let Some(store) = taskstore_ref(store) else {
1686        return ptr::null_mut();
1687    };
1688    let ref_name = unsafe { c_to_str(ref_name) };
1689    let plan = unsafe { c_to_str(plan) };
1690    let id = TaskId(unsafe { c_to_str(parent_id) });
1691    match store.inner.derived_status(&ref_name, &plan, &id) {
1692        Ok(s) => json_ok(&s),
1693        Err(e) => json_err(&e.to_string()),
1694    }
1695}
1696
1697// ===========================================================================
1698// PolicyStore FFI
1699// ===========================================================================
1700
1701/// Create a new PolicyStore handle bound to the given repository, path
1702/// prefix, and agent id. The returned pointer must be freed with
1703/// `agentstategraph_policy_store_free`. The underlying repository is
1704/// shared (refcounted); freeing the PolicyStore does NOT free the
1705/// repository.
1706#[no_mangle]
1707pub extern "C" fn agentstategraph_policy_store_new(
1708    repo: *const SgRepo,
1709    prefix: *const c_char,
1710    agent_id: *const c_char,
1711) -> *mut SgPolicyStore {
1712    let repo = unsafe { repo.as_ref() };
1713    let repo = match repo {
1714        Some(r) => r,
1715        None => return ptr::null_mut(),
1716    };
1717    let prefix = unsafe { c_to_str(prefix) };
1718    let agent_id = unsafe { c_to_str(agent_id) };
1719    if prefix.is_empty() || agent_id.is_empty() {
1720        return ptr::null_mut();
1721    }
1722    let store = PolicyStore::new(repo.inner.clone(), prefix, agent_id);
1723    Box::into_raw(Box::new(SgPolicyStore { inner: store }))
1724}
1725
1726/// Free a PolicyStore handle.
1727#[no_mangle]
1728pub extern "C" fn agentstategraph_policy_store_free(store: *mut SgPolicyStore) {
1729    if !store.is_null() {
1730        unsafe {
1731            drop(Box::from_raw(store));
1732        }
1733    }
1734}
1735
1736fn policystore_ref<'a>(store: *const SgPolicyStore) -> Option<&'a SgPolicyStore> {
1737    unsafe { store.as_ref() }
1738}
1739
1740fn opt_c_to_str(ptr: *const c_char) -> Option<String> {
1741    if ptr.is_null() {
1742        None
1743    } else {
1744        let s = unsafe { c_to_str(ptr) };
1745        if s.is_empty() {
1746            None
1747        } else {
1748            Some(s)
1749        }
1750    }
1751}
1752
1753/// Propose a new (unratified) policy. `policy_json` is a JSON object
1754/// matching the `Policy` schema. Returns a JSON string `"path@version"`
1755/// handle on success, or `{"error": ...}` JSON on failure.
1756#[no_mangle]
1757pub extern "C" fn agentstategraph_policy_propose(
1758    store: *const SgPolicyStore,
1759    ref_name: *const c_char,
1760    policy_json: *const c_char,
1761) -> *mut c_char {
1762    let Some(store) = policystore_ref(store) else {
1763        return ptr::null_mut();
1764    };
1765    let ref_name = unsafe { c_to_str(ref_name) };
1766    let policy_str = unsafe { c_to_str(policy_json) };
1767    let policy: Policy = match serde_json::from_str(&policy_str) {
1768        Ok(p) => p,
1769        Err(e) => return json_err(&format!("invalid policy: {e}")),
1770    };
1771    match store.inner.propose(&ref_name, policy) {
1772        Ok(handle) => json_ok(&handle),
1773        Err(e) => json_err(&e.to_string()),
1774    }
1775}
1776
1777/// Ratify an unratified proposal at `path`. Returns `{"ok":true}` on
1778/// success, `{"error": ...}` on failure.
1779#[no_mangle]
1780pub extern "C" fn agentstategraph_policy_ratify(
1781    store: *const SgPolicyStore,
1782    ref_name: *const c_char,
1783    path: *const c_char,
1784    ratifier: *const c_char,
1785    reasoning: *const c_char,
1786) -> *mut c_char {
1787    let Some(store) = policystore_ref(store) else {
1788        return ptr::null_mut();
1789    };
1790    let ref_name = unsafe { c_to_str(ref_name) };
1791    let path = unsafe { c_to_str(path) };
1792    let ratifier = unsafe { c_to_str(ratifier) };
1793    let reasoning = unsafe { c_to_str(reasoning) };
1794    match store.inner.ratify(&ref_name, &path, &ratifier, &reasoning) {
1795        Ok(()) => to_c_string("{\"ok\":true}"),
1796        Err(e) => json_err(&e.to_string()),
1797    }
1798}
1799
1800/// Replace the active policy at `path` with `new_policy_json`. Returns
1801/// JSON `"path@new_version"` handle on success.
1802#[no_mangle]
1803pub extern "C" fn agentstategraph_policy_supersede(
1804    store: *const SgPolicyStore,
1805    ref_name: *const c_char,
1806    path: *const c_char,
1807    new_policy_json: *const c_char,
1808) -> *mut c_char {
1809    let Some(store) = policystore_ref(store) else {
1810        return ptr::null_mut();
1811    };
1812    let ref_name = unsafe { c_to_str(ref_name) };
1813    let path = unsafe { c_to_str(path) };
1814    let policy_str = unsafe { c_to_str(new_policy_json) };
1815    let policy: Policy = match serde_json::from_str(&policy_str) {
1816        Ok(p) => p,
1817        Err(e) => return json_err(&format!("invalid policy: {e}")),
1818    };
1819    match store.inner.supersede(&ref_name, &path, policy) {
1820        Ok(handle) => json_ok(&handle),
1821        Err(e) => json_err(&e.to_string()),
1822    }
1823}
1824
1825/// List policies whose path starts with `prefix_or_null` (NULL or empty
1826/// = no filter). Returns JSON array of policies.
1827#[no_mangle]
1828pub extern "C" fn agentstategraph_policy_list(
1829    store: *const SgPolicyStore,
1830    ref_name: *const c_char,
1831    prefix_or_null: *const c_char,
1832) -> *mut c_char {
1833    let Some(store) = policystore_ref(store) else {
1834        return ptr::null_mut();
1835    };
1836    let ref_name = unsafe { c_to_str(ref_name) };
1837    let filter = opt_c_to_str(prefix_or_null);
1838    match store.inner.list(&ref_name, filter.as_deref()) {
1839        Ok(ps) => json_ok(&ps),
1840        Err(e) => json_err(&e.to_string()),
1841    }
1842}
1843
1844/// List currently-active (ratified and `active_from <= now`) policies.
1845/// `prefix_or_null` filters by path prefix (NULL/empty = all). Returns
1846/// JSON array.
1847#[no_mangle]
1848pub extern "C" fn agentstategraph_policy_active(
1849    store: *const SgPolicyStore,
1850    ref_name: *const c_char,
1851    prefix_or_null: *const c_char,
1852) -> *mut c_char {
1853    let Some(store) = policystore_ref(store) else {
1854        return ptr::null_mut();
1855    };
1856    let ref_name = unsafe { c_to_str(ref_name) };
1857    let filter = opt_c_to_str(prefix_or_null);
1858    match store.inner.active(&ref_name, filter.as_deref()) {
1859        Ok(ps) => json_ok(&ps),
1860        Err(e) => json_err(&e.to_string()),
1861    }
1862}
1863
1864/// Fetch the active policy at `path`. Returns JSON policy object.
1865#[no_mangle]
1866pub extern "C" fn agentstategraph_policy_get(
1867    store: *const SgPolicyStore,
1868    ref_name: *const c_char,
1869    path: *const c_char,
1870) -> *mut c_char {
1871    let Some(store) = policystore_ref(store) else {
1872        return ptr::null_mut();
1873    };
1874    let ref_name = unsafe { c_to_str(ref_name) };
1875    let path = unsafe { c_to_str(path) };
1876    match store.inner.get(&ref_name, &path, None) {
1877        Ok(p) => json_ok(&p),
1878        Err(e) => json_err(&e.to_string()),
1879    }
1880}
1881
1882/// Walk the supersedes chain for `path`. Returns JSON array, oldest
1883/// first through the current active version.
1884#[no_mangle]
1885pub extern "C" fn agentstategraph_policy_history(
1886    store: *const SgPolicyStore,
1887    ref_name: *const c_char,
1888    path: *const c_char,
1889) -> *mut c_char {
1890    let Some(store) = policystore_ref(store) else {
1891        return ptr::null_mut();
1892    };
1893    let ref_name = unsafe { c_to_str(ref_name) };
1894    let path = unsafe { c_to_str(path) };
1895    match store.inner.history(&ref_name, &path) {
1896        Ok(ps) => json_ok(&ps),
1897        Err(e) => json_err(&e.to_string()),
1898    }
1899}
1900
1901/// Authorization evaluation (POLICY_V1.md §5). `situation_json` is a
1902/// flat `{string: string}` JSON object, or `{"facts": {...}}` wrapper.
1903/// Returns JSON `Decision`.
1904#[no_mangle]
1905pub extern "C" fn agentstategraph_policy_evaluate(
1906    store: *const SgPolicyStore,
1907    ref_name: *const c_char,
1908    situation_json: *const c_char,
1909    action: *const c_char,
1910    agent_id: *const c_char,
1911) -> *mut c_char {
1912    let Some(store) = policystore_ref(store) else {
1913        return ptr::null_mut();
1914    };
1915    let ref_name = unsafe { c_to_str(ref_name) };
1916    let situation_str = unsafe { c_to_str(situation_json) };
1917    let action = unsafe { c_to_str(action) };
1918    let agent_id = unsafe { c_to_str(agent_id) };
1919    let situation = match parse_situation(&situation_str) {
1920        Ok(s) => s,
1921        Err(e) => return json_err(&format!("invalid situation: {e}")),
1922    };
1923    match store
1924        .inner
1925        .evaluate(&ref_name, &situation, &action, &agent_id)
1926    {
1927        Ok(d) => json_ok(&d),
1928        Err(e) => json_err(&e.to_string()),
1929    }
1930}
1931
1932/// Change-proposal evaluation (POLICY_V1.md §22.2). `proposal_json` is
1933/// a JSON `ChangeProposal`. Returns JSON `Decision`.
1934#[no_mangle]
1935pub extern "C" fn agentstategraph_policy_evaluate_change(
1936    store: *const SgPolicyStore,
1937    ref_name: *const c_char,
1938    proposal_json: *const c_char,
1939) -> *mut c_char {
1940    let Some(store) = policystore_ref(store) else {
1941        return ptr::null_mut();
1942    };
1943    let ref_name = unsafe { c_to_str(ref_name) };
1944    let proposal_str = unsafe { c_to_str(proposal_json) };
1945    let proposal: ChangeProposal = match serde_json::from_str(&proposal_str) {
1946        Ok(p) => p,
1947        Err(e) => return json_err(&format!("invalid proposal: {e}")),
1948    };
1949    match store.inner.evaluate_change(&ref_name, &proposal) {
1950        Ok(d) => json_ok(&d),
1951        Err(e) => json_err(&e.to_string()),
1952    }
1953}
1954
1955/// Scoped evaluator (0.7.5 §3b). Variant of
1956/// `agentstategraph_policy_evaluate` that accepts an optional
1957/// `tenant_filter`. Pass `NULL` for no filter (equivalent to the
1958/// non-scoped variant). Returns JSON `Decision`.
1959#[no_mangle]
1960pub extern "C" fn agentstategraph_policy_evaluate_scoped(
1961    store: *const SgPolicyStore,
1962    ref_name: *const c_char,
1963    situation_json: *const c_char,
1964    action: *const c_char,
1965    agent_id: *const c_char,
1966    tenant_filter: *const c_char,
1967) -> *mut c_char {
1968    let Some(store) = policystore_ref(store) else {
1969        return ptr::null_mut();
1970    };
1971    let ref_name = unsafe { c_to_str(ref_name) };
1972    let situation_str = unsafe { c_to_str(situation_json) };
1973    let action = unsafe { c_to_str(action) };
1974    let agent_id = unsafe { c_to_str(agent_id) };
1975    let tenant = if tenant_filter.is_null() {
1976        None
1977    } else {
1978        Some(unsafe { c_to_str(tenant_filter) })
1979    };
1980    let situation = match parse_situation(&situation_str) {
1981        Ok(s) => s,
1982        Err(e) => return json_err(&format!("invalid situation: {e}")),
1983    };
1984    match store
1985        .inner
1986        .evaluate_scoped(&ref_name, &situation, &action, &agent_id, tenant.as_deref())
1987    {
1988        Ok(d) => json_ok(&d),
1989        Err(e) => json_err(&e.to_string()),
1990    }
1991}
1992
1993/// Scoped change-proposal evaluator (0.7.5 §3b). See
1994/// `agentstategraph_policy_evaluate_scoped` for `tenant_filter`
1995/// semantics.
1996#[no_mangle]
1997pub extern "C" fn agentstategraph_policy_evaluate_change_scoped(
1998    store: *const SgPolicyStore,
1999    ref_name: *const c_char,
2000    proposal_json: *const c_char,
2001    tenant_filter: *const c_char,
2002) -> *mut c_char {
2003    let Some(store) = policystore_ref(store) else {
2004        return ptr::null_mut();
2005    };
2006    let ref_name = unsafe { c_to_str(ref_name) };
2007    let proposal_str = unsafe { c_to_str(proposal_json) };
2008    let tenant = if tenant_filter.is_null() {
2009        None
2010    } else {
2011        Some(unsafe { c_to_str(tenant_filter) })
2012    };
2013    let proposal: ChangeProposal = match serde_json::from_str(&proposal_str) {
2014        Ok(p) => p,
2015        Err(e) => return json_err(&format!("invalid proposal: {e}")),
2016    };
2017    match store
2018        .inner
2019        .evaluate_change_scoped(&ref_name, &proposal, tenant.as_deref())
2020    {
2021        Ok(d) => json_ok(&d),
2022        Err(e) => json_err(&e.to_string()),
2023    }
2024}
2025
2026/// List active policies whose `triggers` intersect `tokens_json` (a JSON
2027/// array of strings). Binding-level helper mirroring the internal filter
2028/// used by `evaluate_change`. Returns JSON array of policies.
2029#[no_mangle]
2030pub extern "C" fn agentstategraph_policy_check_tokens(
2031    store: *const SgPolicyStore,
2032    ref_name: *const c_char,
2033    tokens_json: *const c_char,
2034) -> *mut c_char {
2035    let Some(store) = policystore_ref(store) else {
2036        return ptr::null_mut();
2037    };
2038    let ref_name = unsafe { c_to_str(ref_name) };
2039    let tokens_str = unsafe { c_to_str(tokens_json) };
2040    let tokens: Vec<String> = if tokens_str.is_empty() {
2041        Vec::new()
2042    } else {
2043        match serde_json::from_str(&tokens_str) {
2044            Ok(v) => v,
2045            Err(e) => return json_err(&format!("invalid tokens_json: {e}")),
2046        }
2047    };
2048    let actives = match store.inner.active(&ref_name, None) {
2049        Ok(v) => v,
2050        Err(e) => return json_err(&e.to_string()),
2051    };
2052    let token_set: std::collections::HashSet<&str> = tokens.iter().map(|s| s.as_str()).collect();
2053    let matched: Vec<&Policy> = actives
2054        .iter()
2055        .filter(|p| p.triggers.iter().any(|t| token_set.contains(t.as_str())))
2056        .collect();
2057    json_ok(&matched)
2058}
2059
2060/// Sign the active policy at `path`. §2c of the 0.7.5-beta.1 plan.
2061///
2062/// Configuring a `PolicySigner` through the C ABI is deferred to a
2063/// later milestone (§4c surfaces runner/signer configuration); until
2064/// then this extern unconditionally returns
2065/// `{"error": "no signer registered"}`. The symbol is exported so
2066/// bindings can wire it up in §5 without another header bump.
2067#[no_mangle]
2068pub extern "C" fn agentstategraph_policy_sign(
2069    store: *const SgPolicyStore,
2070    ref_name: *const c_char,
2071    path: *const c_char,
2072    signer_key_id: *const c_char,
2073) -> *mut c_char {
2074    let Some(_store) = policystore_ref(store) else {
2075        return ptr::null_mut();
2076    };
2077    let _ref_name = unsafe { c_to_str(ref_name) };
2078    let _path = unsafe { c_to_str(path) };
2079    let _signer_key_id = opt_c_to_str(signer_key_id);
2080    // TODO(§4c): expose signer registration on SgPolicyStore.
2081    to_c_string("{\"error\":\"no signer registered\"}")
2082}
2083
2084/// Verify the signature on the active policy at `path`. §2c of the
2085/// 0.7.5-beta.1 plan.
2086///
2087/// Configuring a `SignatureVerifier` through the C ABI is deferred —
2088/// see `agentstategraph_policy_sign` above. Returns
2089/// `{"valid": null, "reason": "no verifier registered"}` until
2090/// configuration support lands.
2091#[no_mangle]
2092pub extern "C" fn agentstategraph_policy_verify(
2093    store: *const SgPolicyStore,
2094    ref_name: *const c_char,
2095    path: *const c_char,
2096) -> *mut c_char {
2097    let Some(_store) = policystore_ref(store) else {
2098        return ptr::null_mut();
2099    };
2100    let _ref_name = unsafe { c_to_str(ref_name) };
2101    let _path = unsafe { c_to_str(path) };
2102    // TODO(§4c): expose verifier registration on SgPolicyStore.
2103    to_c_string("{\"valid\":null,\"reason\":\"no verifier registered\"}")
2104}
2105
2106/// Configure an external policy evaluator on the store (0.7.5 §4c).
2107///
2108/// `config_json` accepts the opaque shape
2109/// `{ "kind": "wasm" | "rego" | "cedar", "options": { ... } }`; the
2110/// concrete options are runner-specific (e.g. `{"opa_path": "..."}` for
2111/// Rego). This keeps the C ABI surface small — the full runner wiring
2112/// happens through `AgentStateGraphServer::with_external_evaluator` on
2113/// the Rust side.
2114///
2115/// This extern is currently a stub: it validates the pointers and
2116/// unconditionally returns the envelope
2117/// `{"error": "external evaluators not configured via FFI; use the server builder"}`.
2118/// The symbol is declared so §5 language bindings can reference it
2119/// without another header bump; real FFI runner configuration is a
2120/// follow-up milestone.
2121#[no_mangle]
2122pub extern "C" fn agentstategraph_policy_set_external_evaluator(
2123    store: *const SgPolicyStore,
2124    config_json: *const c_char,
2125) -> *mut c_char {
2126    let Some(_store) = policystore_ref(store) else {
2127        return ptr::null_mut();
2128    };
2129    let _config = unsafe { c_to_str(config_json) };
2130    // TODO(post-§4c): materialize the runner from `config_json` and
2131    // attach it to a mutable handle. Today `SgPolicyStore` wraps a
2132    // `PolicyStore` by value (not `Arc<Mutex<>>`), so installing a
2133    // registry would require a handle redesign. The Rust-side builder
2134    // `AgentStateGraphServer::with_external_evaluator` is the supported
2135    // path in the meantime.
2136    to_c_string(
2137        "{\"error\":\"external evaluators not configured via FFI; use the server builder\"}",
2138    )
2139}
2140
2141// ===========================================================================
2142// Taint / Quarantine / Watch FFI (0.7.75 §7)
2143// ===========================================================================
2144
2145fn parse_taint_effect_ffi(s: &str) -> Option<TaintEffect> {
2146    match s.to_lowercase().as_str() {
2147        "warn" => Some(TaintEffect::Warn),
2148        "block" => Some(TaintEffect::Block),
2149        "review" => Some(TaintEffect::Review),
2150        "isolate" => Some(TaintEffect::Isolate),
2151        "advisory" => Some(TaintEffect::Advisory),
2152        _ => None,
2153    }
2154}
2155
2156fn parse_taint_severity_ffi(s: Option<&str>) -> TaintSeverity {
2157    match s.unwrap_or("medium").to_lowercase().as_str() {
2158        "low" => TaintSeverity::Low,
2159        "high" => TaintSeverity::High,
2160        "critical" => TaintSeverity::Critical,
2161        _ => TaintSeverity::Medium,
2162    }
2163}
2164
2165fn parse_taint_kind_ffi(s: &str) -> Option<TaintKind> {
2166    match s.to_lowercase().as_str() {
2167        "taint" => Some(TaintKind::Taint),
2168        "quarantine" => Some(TaintKind::Quarantine),
2169        "watch" => Some(TaintKind::Watch),
2170        _ => None,
2171    }
2172}
2173
2174/// Apply a taint. `params_json` shape:
2175/// `{"name":"...","effect":"warn|block|review|isolate","reason":"...",
2176/// "severity":"medium","expires":null,"propagate":true,"agent_id":"..."}`.
2177/// Returns JSON `{"ok":true,"id":"<uuid>"}` or `{"error":"..."}`.
2178#[no_mangle]
2179pub extern "C" fn agentstategraph_taint_apply(
2180    repo: *const SgRepo,
2181    ref_name: *const c_char,
2182    path: *const c_char,
2183    params_json: *const c_char,
2184) -> *mut c_char {
2185    let Some(repo) = (unsafe { repo.as_ref() }) else {
2186        return ptr::null_mut();
2187    };
2188    let ref_name = unsafe { c_to_str(ref_name) };
2189    let path = unsafe { c_to_str(path) };
2190    let params_str = unsafe { c_to_str(params_json) };
2191    let v: serde_json::Value = match serde_json::from_str(&params_str) {
2192        Ok(v) => v,
2193        Err(e) => return json_err(&format!("invalid params: {e}")),
2194    };
2195    let Some(name) = v.get("name").and_then(|x| x.as_str()).map(str::to_string) else {
2196        return json_err("missing 'name'");
2197    };
2198    let Some(effect) = v
2199        .get("effect")
2200        .and_then(|x| x.as_str())
2201        .and_then(parse_taint_effect_ffi)
2202    else {
2203        return json_err("missing or invalid 'effect'");
2204    };
2205    let reason = v
2206        .get("reason")
2207        .and_then(|x| x.as_str())
2208        .unwrap_or("")
2209        .to_string();
2210    let severity = parse_taint_severity_ffi(v.get("severity").and_then(|x| x.as_str()));
2211    let expires_at = v
2212        .get("expires")
2213        .and_then(|x| x.as_str())
2214        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
2215        .map(|d| d.with_timezone(&chrono::Utc));
2216    let propagate = v.get("propagate").and_then(|x| x.as_bool()).unwrap_or(true);
2217    let agent_id = v
2218        .get("agent_id")
2219        .and_then(|x| x.as_str())
2220        .unwrap_or("")
2221        .to_string();
2222    let params = TaintParams {
2223        name,
2224        effect,
2225        reason,
2226        severity,
2227        expires_at,
2228        propagate,
2229        metadata: TaintMetadata::new(),
2230        agent_id,
2231    };
2232    match repo.inner.taint(&ref_name, &path, params) {
2233        Ok(id) => json_ok(&serde_json::json!({ "ok": true, "id": id })),
2234        Err(e) => json_err(&e.to_string()),
2235    }
2236}
2237
2238/// Remove a taint. `params_json`: `{"name":"...","reason":"...","proof":null,"agent_id":"..."}`.
2239#[no_mangle]
2240pub extern "C" fn agentstategraph_taint_remove(
2241    repo: *const SgRepo,
2242    ref_name: *const c_char,
2243    path: *const c_char,
2244    params_json: *const c_char,
2245) -> *mut c_char {
2246    let Some(repo) = (unsafe { repo.as_ref() }) else {
2247        return ptr::null_mut();
2248    };
2249    let ref_name = unsafe { c_to_str(ref_name) };
2250    let path = unsafe { c_to_str(path) };
2251    let v: serde_json::Value = match serde_json::from_str(&unsafe { c_to_str(params_json) }) {
2252        Ok(v) => v,
2253        Err(e) => return json_err(&format!("invalid params: {e}")),
2254    };
2255    let Some(name) = v.get("name").and_then(|x| x.as_str()).map(str::to_string) else {
2256        return json_err("missing 'name'");
2257    };
2258    let params = UntaintParams {
2259        reason: v
2260            .get("reason")
2261            .and_then(|x| x.as_str())
2262            .unwrap_or("")
2263            .to_string(),
2264        proof: v.get("proof").and_then(|x| x.as_str()).map(str::to_string),
2265        agent_id: v
2266            .get("agent_id")
2267            .and_then(|x| x.as_str())
2268            .unwrap_or("")
2269            .to_string(),
2270    };
2271    match repo.inner.untaint(&ref_name, &path, &name, params) {
2272        Ok(()) => json_ok(&serde_json::json!({ "ok": true })),
2273        Err(e) => json_err(&e.to_string()),
2274    }
2275}
2276
2277/// Apply a quarantine. `params_json`:
2278/// `{"name","reason","severity","authorized_agents":[...],"expires","propagate","agent_id"}`.
2279#[no_mangle]
2280pub extern "C" fn agentstategraph_quarantine_apply(
2281    repo: *const SgRepo,
2282    ref_name: *const c_char,
2283    path: *const c_char,
2284    params_json: *const c_char,
2285) -> *mut c_char {
2286    let Some(repo) = (unsafe { repo.as_ref() }) else {
2287        return ptr::null_mut();
2288    };
2289    let ref_name = unsafe { c_to_str(ref_name) };
2290    let path = unsafe { c_to_str(path) };
2291    let v: serde_json::Value = match serde_json::from_str(&unsafe { c_to_str(params_json) }) {
2292        Ok(v) => v,
2293        Err(e) => return json_err(&format!("invalid params: {e}")),
2294    };
2295    let Some(name) = v.get("name").and_then(|x| x.as_str()).map(str::to_string) else {
2296        return json_err("missing 'name'");
2297    };
2298    let authorized: Vec<String> = v
2299        .get("authorized_agents")
2300        .and_then(|x| x.as_array())
2301        .map(|arr| {
2302            arr.iter()
2303                .filter_map(|x| x.as_str().map(str::to_string))
2304                .collect()
2305        })
2306        .unwrap_or_default();
2307    let params = QuarantineParams {
2308        name,
2309        reason: v
2310            .get("reason")
2311            .and_then(|x| x.as_str())
2312            .unwrap_or("")
2313            .to_string(),
2314        severity: parse_taint_severity_ffi(v.get("severity").and_then(|x| x.as_str())),
2315        authorized_agents: authorized,
2316        expires_at: v
2317            .get("expires")
2318            .and_then(|x| x.as_str())
2319            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
2320            .map(|d| d.with_timezone(&chrono::Utc)),
2321        propagate: v.get("propagate").and_then(|x| x.as_bool()).unwrap_or(true),
2322        agent_id: v
2323            .get("agent_id")
2324            .and_then(|x| x.as_str())
2325            .unwrap_or("")
2326            .to_string(),
2327    };
2328    match repo.inner.quarantine(&ref_name, &path, params) {
2329        Ok(id) => json_ok(&serde_json::json!({ "ok": true, "id": id })),
2330        Err(e) => json_err(&e.to_string()),
2331    }
2332}
2333
2334/// Release a quarantine. `params_json` shape matches taint_remove.
2335#[no_mangle]
2336pub extern "C" fn agentstategraph_quarantine_release(
2337    repo: *const SgRepo,
2338    ref_name: *const c_char,
2339    path: *const c_char,
2340    params_json: *const c_char,
2341) -> *mut c_char {
2342    let Some(repo) = (unsafe { repo.as_ref() }) else {
2343        return ptr::null_mut();
2344    };
2345    let ref_name = unsafe { c_to_str(ref_name) };
2346    let path = unsafe { c_to_str(path) };
2347    let v: serde_json::Value = match serde_json::from_str(&unsafe { c_to_str(params_json) }) {
2348        Ok(v) => v,
2349        Err(e) => return json_err(&format!("invalid params: {e}")),
2350    };
2351    let Some(name) = v.get("name").and_then(|x| x.as_str()).map(str::to_string) else {
2352        return json_err("missing 'name'");
2353    };
2354    let params = UntaintParams {
2355        reason: v
2356            .get("reason")
2357            .and_then(|x| x.as_str())
2358            .unwrap_or("")
2359            .to_string(),
2360        proof: v.get("proof").and_then(|x| x.as_str()).map(str::to_string),
2361        agent_id: v
2362            .get("agent_id")
2363            .and_then(|x| x.as_str())
2364            .unwrap_or("")
2365            .to_string(),
2366    };
2367    match repo.inner.unquarantine(&ref_name, &path, &name, params) {
2368        Ok(()) => json_ok(&serde_json::json!({ "ok": true })),
2369        Err(e) => json_err(&e.to_string()),
2370    }
2371}
2372
2373/// Apply a watch. `params_json`:
2374/// `{"name","reason","metric","threshold","direction":"above|below",
2375///   "check_interval_secs","expires","severity","propagate","agent_id"}`.
2376#[no_mangle]
2377pub extern "C" fn agentstategraph_watch_apply(
2378    repo: *const SgRepo,
2379    ref_name: *const c_char,
2380    path: *const c_char,
2381    params_json: *const c_char,
2382) -> *mut c_char {
2383    let Some(repo) = (unsafe { repo.as_ref() }) else {
2384        return ptr::null_mut();
2385    };
2386    let ref_name = unsafe { c_to_str(ref_name) };
2387    let path = unsafe { c_to_str(path) };
2388    let v: serde_json::Value = match serde_json::from_str(&unsafe { c_to_str(params_json) }) {
2389        Ok(v) => v,
2390        Err(e) => return json_err(&format!("invalid params: {e}")),
2391    };
2392    let Some(name) = v.get("name").and_then(|x| x.as_str()).map(str::to_string) else {
2393        return json_err("missing 'name'");
2394    };
2395    let direction = match v
2396        .get("direction")
2397        .and_then(|x| x.as_str())
2398        .unwrap_or("above")
2399    {
2400        "below" => WatchDirection::Below,
2401        _ => WatchDirection::Above,
2402    };
2403    let params = WatchParams {
2404        name,
2405        reason: v
2406            .get("reason")
2407            .and_then(|x| x.as_str())
2408            .unwrap_or("")
2409            .to_string(),
2410        metric: v.get("metric").and_then(|x| x.as_str()).map(str::to_string),
2411        threshold: v.get("threshold").and_then(|x| x.as_f64()),
2412        direction,
2413        check_interval_secs: v.get("check_interval_secs").and_then(|x| x.as_u64()),
2414        expires_at: v
2415            .get("expires")
2416            .and_then(|x| x.as_str())
2417            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
2418            .map(|d| d.with_timezone(&chrono::Utc)),
2419        severity: parse_taint_severity_ffi(v.get("severity").and_then(|x| x.as_str())),
2420        propagate: v.get("propagate").and_then(|x| x.as_bool()).unwrap_or(true),
2421        agent_id: v
2422            .get("agent_id")
2423            .and_then(|x| x.as_str())
2424            .unwrap_or("")
2425            .to_string(),
2426    };
2427    match repo.inner.watch_path(&ref_name, &path, params) {
2428        Ok(id) => json_ok(&serde_json::json!({ "ok": true, "id": id })),
2429        Err(e) => json_err(&e.to_string()),
2430    }
2431}
2432
2433/// Remove a watch.
2434#[no_mangle]
2435pub extern "C" fn agentstategraph_watch_remove(
2436    repo: *const SgRepo,
2437    ref_name: *const c_char,
2438    path: *const c_char,
2439    params_json: *const c_char,
2440) -> *mut c_char {
2441    let Some(repo) = (unsafe { repo.as_ref() }) else {
2442        return ptr::null_mut();
2443    };
2444    let ref_name = unsafe { c_to_str(ref_name) };
2445    let path = unsafe { c_to_str(path) };
2446    let v: serde_json::Value = match serde_json::from_str(&unsafe { c_to_str(params_json) }) {
2447        Ok(v) => v,
2448        Err(e) => return json_err(&format!("invalid params: {e}")),
2449    };
2450    let Some(name) = v.get("name").and_then(|x| x.as_str()).map(str::to_string) else {
2451        return json_err("missing 'name'");
2452    };
2453    let params = UnwatchParams {
2454        reason: v.get("reason").and_then(|x| x.as_str()).map(str::to_string),
2455        agent_id: v
2456            .get("agent_id")
2457            .and_then(|x| x.as_str())
2458            .unwrap_or("")
2459            .to_string(),
2460    };
2461    match repo.inner.unwatch(&ref_name, &path, &name, params) {
2462        Ok(()) => json_ok(&serde_json::json!({ "ok": true })),
2463        Err(e) => json_err(&e.to_string()),
2464    }
2465}
2466
2467/// List active taints / quarantines / watches. `kind_or_null` accepts
2468/// "taint" | "quarantine" | "watch" or NULL to list all.
2469#[no_mangle]
2470pub extern "C" fn agentstategraph_list_taints(
2471    repo: *const SgRepo,
2472    path_prefix_or_null: *const c_char,
2473    kind_or_null: *const c_char,
2474    include_resolved: bool,
2475) -> *mut c_char {
2476    let Some(repo) = (unsafe { repo.as_ref() }) else {
2477        return ptr::null_mut();
2478    };
2479    let prefix = if path_prefix_or_null.is_null() {
2480        None
2481    } else {
2482        Some(unsafe { c_to_str(path_prefix_or_null) })
2483    };
2484    let kind = if kind_or_null.is_null() {
2485        None
2486    } else {
2487        let s = unsafe { c_to_str(kind_or_null) };
2488        match parse_taint_kind_ffi(&s) {
2489            Some(k) => Some(k),
2490            None => return json_err(&format!("unknown kind: {s}")),
2491        }
2492    };
2493    match repo
2494        .inner
2495        .list_taints(prefix.as_deref(), kind, include_resolved)
2496    {
2497        Ok(list) => json_ok(&serde_json::json!({ "ok": true, "taints": list })),
2498        Err(e) => json_err(&e.to_string()),
2499    }
2500}
2501
2502/// Check the taint status for `path` given `agent_id` + `confidence`.
2503#[no_mangle]
2504pub extern "C" fn agentstategraph_check_taint(
2505    repo: *const SgRepo,
2506    path: *const c_char,
2507    agent_id: *const c_char,
2508    confidence: f64,
2509) -> *mut c_char {
2510    let Some(repo) = (unsafe { repo.as_ref() }) else {
2511        return ptr::null_mut();
2512    };
2513    let path = unsafe { c_to_str(path) };
2514    let agent_id = unsafe { c_to_str(agent_id) };
2515    match repo.inner.check_taint(&path, &agent_id, confidence) {
2516        Ok(c) => json_ok(&serde_json::json!({ "ok": true, "check": c })),
2517        Err(e) => json_err(&e.to_string()),
2518    }
2519}
2520
2521fn parse_situation(s: &str) -> Result<Situation, serde_json::Error> {
2522    if s.is_empty() {
2523        return Ok(Situation::default());
2524    }
2525    // Situation is `#[serde(transparent)]` over HashMap<String, String>,
2526    // so a flat {"k": "v"} JSON object deserializes directly.
2527    serde_json::from_str(s)
2528}
2529
2530// ===========================================================================
2531// Migration FFI
2532// ===========================================================================
2533
2534/// Check the migration status of a repository. Returns a JSON report:
2535/// `{"status":"up_to_date","version":"0.4.0"}`, `"upgrade_available"`,
2536/// `"downgrade"`, `"unversioned"`, `"corrupt"`. `target` may be null to
2537/// use the binary's current `SCHEMA_VERSION`.
2538#[no_mangle]
2539pub extern "C" fn agentstategraph_migrate_check(
2540    repo: *const SgRepo,
2541    ref_name: *const c_char,
2542    target: *const c_char,
2543) -> *mut c_char {
2544    let Some(repo) = (unsafe { repo.as_ref() }) else {
2545        return ptr::null_mut();
2546    };
2547    let ref_name = unsafe { c_to_str(ref_name) };
2548    let target_str = if target.is_null() {
2549        SCHEMA_VERSION.to_string()
2550    } else {
2551        let s = unsafe { c_to_str(target) };
2552        if s.is_empty() {
2553            SCHEMA_VERSION.to_string()
2554        } else {
2555            s
2556        }
2557    };
2558    let target_version = match Version::parse(&target_str) {
2559        Ok(v) => v,
2560        Err(e) => return json_err(&format!("invalid target: {e}")),
2561    };
2562    let registry = Registry::builtin();
2563    match agentstategraph_migrate::check(&repo.inner, &ref_name, &target_version, &registry) {
2564        Ok(r) => to_c_string(&check_result_json(&r)),
2565        Err(e) => json_err(&e.to_string()),
2566    }
2567}
2568
2569/// Run migrations. `mode` is `"apply"` or `"dry-run"`. Returns a JSON
2570/// report describing each step.
2571#[no_mangle]
2572pub extern "C" fn agentstategraph_migrate_run(
2573    repo: *const SgRepo,
2574    ref_name: *const c_char,
2575    target: *const c_char,
2576    mode: *const c_char,
2577) -> *mut c_char {
2578    let Some(repo) = (unsafe { repo.as_ref() }) else {
2579        return ptr::null_mut();
2580    };
2581    let ref_name = unsafe { c_to_str(ref_name) };
2582    let target_str = if target.is_null() {
2583        SCHEMA_VERSION.to_string()
2584    } else {
2585        let s = unsafe { c_to_str(target) };
2586        if s.is_empty() {
2587            SCHEMA_VERSION.to_string()
2588        } else {
2589            s
2590        }
2591    };
2592    let target_version = match Version::parse(&target_str) {
2593        Ok(v) => v,
2594        Err(e) => return json_err(&format!("invalid target: {e}")),
2595    };
2596    let mode_str = unsafe { c_to_str(mode) };
2597    let run_mode = match mode_str.to_lowercase().as_str() {
2598        "apply" => RunMode::Apply,
2599        "dry-run" | "dryrun" | "dry_run" => RunMode::DryRun,
2600        other => return json_err(&format!("invalid mode: {other}")),
2601    };
2602    let registry = Registry::builtin();
2603    match registry.run(&repo.inner, &ref_name, &target_version, run_mode) {
2604        Ok(r) => to_c_string(&report_json(&r)),
2605        Err(e) => json_err(&e.to_string()),
2606    }
2607}
2608
2609fn check_result_json(r: &CheckResult) -> String {
2610    let v = match r {
2611        CheckResult::UpToDate { version } => serde_json::json!({
2612            "status": "up_to_date",
2613            "version": version.to_string(),
2614        }),
2615        CheckResult::UpgradeAvailable {
2616            from,
2617            to,
2618            migrations,
2619        } => serde_json::json!({
2620            "status": "upgrade_available",
2621            "from": from.to_string(),
2622            "to": to.to_string(),
2623            "migrations": migrations,
2624        }),
2625        CheckResult::Downgrade { db, binary } => serde_json::json!({
2626            "status": "downgrade",
2627            "db": db.to_string(),
2628            "binary": binary.to_string(),
2629        }),
2630        CheckResult::Unversioned { implicit } => serde_json::json!({
2631            "status": "unversioned",
2632            "implicit": implicit.to_string(),
2633        }),
2634        CheckResult::Corrupt(msg) => serde_json::json!({
2635            "status": "corrupt",
2636            "message": msg,
2637        }),
2638    };
2639    serde_json::to_string(&v).unwrap_or_default()
2640}
2641
2642fn report_json(r: &agentstategraph_migrate::Report) -> String {
2643    let mode = match r.mode {
2644        RunMode::DryRun => "dry-run",
2645        RunMode::Apply => "apply",
2646    };
2647    let steps: Vec<serde_json::Value> = r
2648        .steps
2649        .iter()
2650        .map(|s| {
2651            serde_json::json!({
2652                "name": s.name,
2653                "describe": s.describe,
2654                "from": s.from.to_string(),
2655                "to": s.to.to_string(),
2656                "status": step_status_str(s.status),
2657                "commit_id": s.commit_id.as_ref().map(|c| c.to_string()),
2658                "notes": s.notes,
2659            })
2660        })
2661        .collect();
2662    let v = serde_json::json!({
2663        "from": r.from.to_string(),
2664        "target": r.target.to_string(),
2665        "final_version": r.final_version.to_string(),
2666        "mode": mode,
2667        "steps": steps,
2668    });
2669    serde_json::to_string(&v).unwrap_or_default()
2670}
2671
2672fn step_status_str(s: StepStatus) -> &'static str {
2673    match s {
2674        StepStatus::WouldApply => "would-apply",
2675        StepStatus::WouldSkip => "would-skip",
2676        StepStatus::Applied => "applied",
2677        StepStatus::Skipped => "skipped",
2678        StepStatus::Failed => "failed",
2679    }
2680}
2681
2682// ---------------------------------------------------------------------------
2683// Helpers
2684// ---------------------------------------------------------------------------
2685
2686unsafe fn c_to_str(ptr: *const c_char) -> String {
2687    if ptr.is_null() {
2688        String::new()
2689    } else {
2690        unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() }
2691    }
2692}
2693
2694unsafe fn parse_repo_ref_path<'a>(
2695    repo: *const SgRepo,
2696    ref_name: *const c_char,
2697    path: *const c_char,
2698) -> Option<(&'a SgRepo, String, String)> {
2699    let repo = unsafe { repo.as_ref()? };
2700    let ref_name = unsafe { c_to_str(ref_name) };
2701    let path = unsafe { c_to_str(path) };
2702    Some((repo, ref_name, path))
2703}
2704
2705fn parse_category(s: &str) -> IntentCategory {
2706    match s.to_lowercase().as_str() {
2707        "explore" | "exploration" => IntentCategory::Explore,
2708        "refine" | "refinement" => IntentCategory::Refine,
2709        "fix" | "correction" => IntentCategory::Fix,
2710        "rollback" => IntentCategory::Rollback,
2711        "checkpoint" => IntentCategory::Checkpoint,
2712        "merge" => IntentCategory::Merge,
2713        // SECURITY (threat model v2, finding C3): the FFI boundary has no
2714        // capability check — a host embedding ASG via C ABI can pass any
2715        // string. Map "migrate" to a Custom category so `/_meta/*` writes
2716        // are rejected by the substrate's reserved-path guard. Hosts that
2717        // genuinely need migrations should construct `IntentCategory::Migrate`
2718        // directly in Rust (not via this parser).
2719        "migrate" => IntentCategory::Custom("Migrate-requested".into()),
2720        "plan" => IntentCategory::Plan,
2721        other => IntentCategory::Custom(other.to_string()),
2722    }
2723}
2724
2725fn parse_priority(s: &str) -> Priority {
2726    match s.to_lowercase().as_str() {
2727        "low" => Priority::Low,
2728        "high" => Priority::High,
2729        "critical" => Priority::Critical,
2730        _ => Priority::Medium,
2731    }
2732}
2733
2734fn parse_plan_status(s: &str) -> Option<agentstategraph_tasks::PlanStatus> {
2735    match s.to_lowercase().as_str() {
2736        "" => None,
2737        "active" => Some(agentstategraph_tasks::PlanStatus::Active),
2738        "completed" => Some(agentstategraph_tasks::PlanStatus::Completed),
2739        "archived" => Some(agentstategraph_tasks::PlanStatus::Archived),
2740        _ => None,
2741    }
2742}
2743
2744fn parse_proof_kind(s: &str) -> Option<ProofKind> {
2745    match s.to_lowercase().as_str() {
2746        "commit" => Some(ProofKind::Commit),
2747        "file" => Some(ProofKind::File),
2748        "test" => Some(ProofKind::Test),
2749        "text" => Some(ProofKind::Text),
2750        _ => None,
2751    }
2752}
2753
2754fn to_c_string(s: &str) -> *mut c_char {
2755    match CString::new(s) {
2756        Ok(cs) => cs.into_raw(),
2757        Err(_) => ptr::null_mut(),
2758    }
2759}