ai-memory 0.7.1

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! Admin HTTP handlers — agent registration, quota, stats, gc, export,
//! import, and the parity `tools/list` mirror.
//!
//! Extracted from [`super::http`] under issue #650 follow-up 2. The
//! handler bodies are unchanged; only the module-routing import surface
//! moved. Wire compatibility preserved via `pub use admin::*` in
//! [`super`].

#![allow(clippy::too_many_lines)]

use crate::models::field_names;
use axum::{
    Json,
    extract::State,
    http::{HeaderMap, StatusCode},
    response::IntoResponse,
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
#[cfg(feature = "sal")]
use uuid::Uuid;

use crate::db;
#[cfg(feature = "sal")]
use crate::models::{ConfidenceSource, Tier};
use crate::models::{Memory, MemoryLink, RegisterAgentBody};
use crate::validate;

use super::AppState;
use super::MAX_BULK_SIZE;
#[cfg(feature = "sal")]
use super::StorageBackend;
use super::admin_role::require_admin;
#[cfg(feature = "sal")]
use super::store_err_to_response;

pub async fn register_agent(
    State(app): State<AppState>,
    headers: HeaderMap,
    Json(body): Json<RegisterAgentBody>,
) -> impl IntoResponse {
    if let Err(e) = validate::validate_agent_id(&body.agent_id) {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": e.to_string()})),
        )
            .into_response();
    }
    if let Err(e) = validate::validate_agent_type(&body.agent_type) {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": e.to_string()})),
        )
            .into_response();
    }
    // #869 audit (Category B — safe default): `capabilities` is
    // `Option<Vec<String>>`; an absent field is semantically equivalent
    // to "agent advertises no capabilities yet" which is exactly the
    // empty-vec default. No serialisation involved.
    let capabilities = body.capabilities.unwrap_or_default();
    if let Err(e) = validate::validate_capabilities(&capabilities) {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": e.to_string()})),
        )
            .into_response();
    }

    // #911 (security-medium / SOC2, 2026-05-19) — admin action audit.
    // `register_agent` and `archive_purge` are admin-class state-changing
    // surfaces whose forensic-chain entry was previously silent. The
    // caller agent_id is resolved via the X-Agent-Id header (the same
    // primitive `resolve_http_agent_id` other handlers use); when no
    // header is provided we record the synthesized `anonymous:req-…`
    // actor so the chain entry pins the unattested call. Emitted
    // BEFORE any storage write to preserve the audit trail even if
    // the storage layer fails downstream.
    let header_agent_id = headers
        .get(crate::HEADER_AGENT_ID)
        .and_then(|v| v.to_str().ok());
    let caller = crate::identity::resolve_http_agent_id(None, header_agent_id)
        .unwrap_or_else(|_| crate::identity::sentinels::ANONYMOUS_INVALID.to_string());
    crate::governance::audit::record_decision(
        &caller,
        "allow",
        "register_agent",
        "",
        json!({
            "new_agent_id": body.agent_id,
            (field_names::AGENT_TYPE): body.agent_type,
            (field_names::CAPABILITIES): capabilities,
        }),
    );

    // v0.7.0 Wave-3 Continuation 3 — postgres-backed daemons route the
    // agent-registration write through `app.store` so the row lands in
    // the same postgres `_agents` namespace that `list_agents` projects
    // from. Pre-fix this handler wrote through `db::register_agent`
    // against the sqlite scratch `app.db`, leaving postgres-backed
    // daemons with POST→sqlite and GET→postgres asymmetry — registered
    // agents never appeared in the list. Mirrors the import_memories +
    // bulk_create dual-backend dispatch pattern. Federation fanout
    // remains sqlite-only (broadcast_store_quorum uses sqlite-coupled
    // fed-tracker state).
    #[cfg(feature = "sal")]
    if matches!(app.storage_backend, StorageBackend::Postgres) {
        // #910 — admin surface (registration / list_agents / stats);
        // bypass the SAL visibility filter so admin endpoints see the
        // full row set regardless of metadata.scope.
        let ctx =
            crate::store::CallerContext::for_admin(crate::identity::sentinels::DAEMON_PRINCIPAL);
        let now = Utc::now().to_rfc3339();
        let mut metadata = json!({
            "agent_id": &body.agent_id,
            (field_names::AGENT_TYPE): &body.agent_type,
        });
        if let Some(obj) = metadata.as_object_mut() {
            obj.insert(
                field_names::CAPABILITIES.to_string(),
                serde_json::to_value(&capabilities).unwrap_or_else(|_| json!([])),
            );
        }
        let agent_mem = Memory {
            id: Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: crate::models::AGENTS_NAMESPACE.to_string(),
            title: crate::models::agent_registration_title(&body.agent_id),
            content: format!("agent registration for {}", &body.agent_id),
            tags: vec!["_agent_registration".to_string()],
            priority: 5,
            confidence: 1.0,
            source: "api".to_string(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata,
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
        };
        return match app.store.store(&ctx, &agent_mem).await {
            Ok(id) => (
                StatusCode::CREATED,
                Json(json!({
                    "id": id,
                    "agent_id": body.agent_id,
                    (field_names::AGENT_TYPE): body.agent_type,
                    (field_names::CAPABILITIES): capabilities,
                    (field_names::STORAGE_BACKEND): "postgres",
                })),
            )
                .into_response(),
            Err(e) => store_err_to_response(e),
        };
    }

    let lock = app.db.lock().await;
    let register_result =
        db::register_agent(&lock.0, &body.agent_id, &body.agent_type, &capabilities);
    // Read the persisted `_agents` row back so we can fan it out to peers.
    // The cluster-wide S12 invariant is that an agent registered on node-1
    // is visible on node-4 — which only holds when the `_agents` namespace
    // replicates via `broadcast_store_quorum`.
    let registered_mem = match &register_result {
        Ok(id) => db::get(&lock.0, id).ok().flatten(),
        Err(_) => None,
    };
    drop(lock);

    match register_result {
        Ok(id) => {
            if let (Some(fed), Some(mem)) = (app.federation.as_ref(), registered_mem.as_ref()) {
                match crate::federation::broadcast_store_quorum(fed, mem).await {
                    Ok(tracker) => {
                        if let Err(err) = crate::federation::finalise_quorum(&tracker) {
                            // #869 — typed 503 envelope via the shared helper.
                            let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
                            return super::quorum_not_met_response(&payload);
                        }
                    }
                    Err(e) => {
                        tracing::warn!("register_agent fanout error (local committed): {e:?}");
                    }
                }
            }
            (
                StatusCode::CREATED,
                Json(json!({
                    (field_names::REGISTERED): true,
                    "id": id,
                    "agent_id": body.agent_id,
                    (field_names::AGENT_TYPE): body.agent_type,
                    (field_names::CAPABILITIES): capabilities,
                })),
            )
                .into_response()
        }
        Err(e) => crate::handlers::errors::handler_error_500(&e),
    }
}

/// #1539 — `PUT /api/v1/agents/{id}/pubkey`. Binds an Ed25519
/// attestation public key to a registered agent so attesting HTTP
/// clients no longer need an out-of-band DB write (the do-1461
/// provisioning previously bound via ssh+psql on the region pg node
/// under `REQUIRE_AGENT_ATTESTATION=1`). Admin-gated via
/// [`require_admin`] (the #1582 authn-trusted predicate); the bind
/// routes through the SAL `MemoryStore::bind_agent_pubkey`, which both
/// adapters implement, so sqlite- and postgres-backed daemons get the
/// same callable surface. The pubkey is validated as a real 32-byte
/// Ed25519 curve point BEFORE any store call
/// ([`validate::validate_agent_pubkey_b64`]).
/// #1539 — canonical action/endpoint label for the pubkey-bind
/// surface: the `require_admin` endpoint tag, the #911 audit action,
/// and the postgres adapter's error label all share this spelling.
pub const BIND_AGENT_PUBKEY_ACTION: &str = "bind_agent_pubkey";

pub async fn bind_agent_pubkey(
    State(app): State<AppState>,
    headers: HeaderMap,
    axum::extract::Path(agent_id): axum::extract::Path<String>,
    Json(body): Json<crate::models::BindAgentPubkeyBody>,
) -> impl IntoResponse {
    let caller = match require_admin(&app, &headers, BIND_AGENT_PUBKEY_ACTION) {
        Ok(c) => c,
        Err(resp) => return resp,
    };
    if let Err(e) = validate::validate_agent_id(&agent_id) {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": e.to_string()})),
        )
            .into_response();
    }
    if let Err(e) = validate::validate_agent_pubkey_b64(&body.pubkey_b64) {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": e.to_string()})),
        )
            .into_response();
    }
    // Admin action audit (the #911 discipline): emitted BEFORE the
    // store call so the forensic chain pins the attempt even when the
    // bind later fails. The pubkey itself is public material; logging
    // it aids enrollment forensics rather than leaking a secret.
    crate::governance::audit::record_decision(
        &caller,
        "allow",
        BIND_AGENT_PUBKEY_ACTION,
        "",
        json!({
            "agent_id": agent_id,
            "pubkey_b64": body.pubkey_b64,
        }),
    );
    #[cfg(feature = "sal")]
    {
        let ctx =
            crate::store::CallerContext::for_admin(crate::identity::sentinels::DAEMON_PRINCIPAL);
        match app
            .store
            .bind_agent_pubkey(&ctx, &agent_id, body.pubkey_b64.trim())
            .await
        {
            Ok(()) => (
                StatusCode::OK,
                Json(json!({
                    "bound": true,
                    "agent_id": agent_id,
                })),
            )
                .into_response(),
            Err(e) => store_err_to_response(e),
        }
    }
    #[cfg(not(feature = "sal"))]
    {
        let lock = app.db.lock().await;
        match db::bind_agent_pubkey(&lock.0, &agent_id, body.pubkey_b64.trim()) {
            Ok(()) => (
                StatusCode::OK,
                Json(json!({
                    "bound": true,
                    "agent_id": agent_id,
                })),
            )
                .into_response(),
            Err(e) => crate::handlers::errors::handler_error_500(&e),
        }
    }
}

pub async fn list_agents(
    State(app): State<AppState>,
    headers: axum::http::HeaderMap,
) -> impl IntoResponse {
    // #946 SECURITY-medium (Track A QC sweep, 2026-05-20) — admin-
    // only gate. Pre-fix any caller could enumerate the full NHI
    // population + agent capabilities + registration timestamps.
    // The handler uses `CallerContext::for_admin` below to bypass
    // the SAL visibility filter; that's correct for operators but
    // was unauthenticated. Mirror the #957 admin pattern.
    if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "list_agents") {
        return resp;
    }
    // v0.7.0 ARCH-2 followup (FX-C2-batch3) — postgres-backed daemons
    // route through `MemoryStore::list_agents`, which parses the
    // `_agents`-namespace metadata blob into the canonical
    // `AgentRegistration` shape exactly like SQLite's `db::list_agents`.
    // Replaces the previous `list()` + client-side metadata-walk
    // fold, which was a Drift cleanup target (audit doc, FX-C2 sub-
    // batch dispatch plan §FX-C2-b).
    #[cfg(feature = "sal-postgres")]
    if matches!(app.storage_backend, StorageBackend::Postgres) {
        return match app.store.list_agents().await {
            Ok(agents) => (
                StatusCode::OK,
                Json(json!({"count": agents.len(), "agents": agents})),
            )
                .into_response(),
            Err(e) => store_err_to_response(e),
        };
    }

    let lock = app.db.lock().await;
    match db::list_agents(&lock.0) {
        Ok(agents) => (
            StatusCode::OK,
            Json(json!({"count": agents.len(), "agents": agents})),
        )
            .into_response(),
        Err(e) => crate::handlers::errors::handler_error_500(&e),
    }
}

/// JSON body for `POST /api/v1/quota/status`.
///
/// `agent_id` is required when the caller wants a single-agent
/// snapshot; omitting it returns the full table (operator surface).
///
/// `namespace` (v0.7.0 #1156 — per-namespace K8 dimension):
/// - Supplied with `agent_id`: returns the single
///   `(agent_id, namespace)` row.
/// - Supplied without `agent_id`: returns every agent's row in that
///   namespace (operator-scoped, admin-gated).
/// - Omitted with `agent_id` supplied: returns the **aggregate**
///   view, summing counters across every namespace the agent has
///   written into (preserves pre-#1156 single-row response shape).
/// - Omitted with `agent_id` also omitted: full-substrate listing.
#[derive(Debug, Deserialize)]
pub struct QuotaStatusBody {
    #[serde(default)]
    pub agent_id: Option<String>,
    #[serde(default)]
    pub namespace: Option<String>,
}

/// `POST /api/v1/quota/status` — read the agent's quota row, or the
/// full table when `agent_id` is omitted. Returns the canonical
/// `QuotaStatus` JSON projection.
///
/// Dispatches via `app.store.quota_status(agent_id)` so postgres-backed
/// daemons read from the postgres `agent_quotas` table rather than the
/// scratch sqlite connection.
pub async fn quota_status_handler(
    State(app): State<AppState>,
    headers: HeaderMap,
    Json(body): Json<QuotaStatusBody>,
) -> impl IntoResponse {
    // #909 (security-medium, 2026-05-19) — sibling of #874/#901/#905/#907.
    // The pre-#909 path accepted `body.agent_id` with no authn binding —
    // any caller could probe `POST /api/v1/quota/status {agent_id:"alice"}`
    // and read alice's quota row (cross-tenant disclosure: count of
    // memories stored, last-reset timestamp, namespace usage stats).
    // Authenticate via `X-Agent-Id` header; when `body.agent_id` is
    // supplied it must MATCH the authenticated caller else 403. The
    // operator-facing list path (body.agent_id absent) is preserved.
    let header_agent_id = headers
        .get(crate::HEADER_AGENT_ID)
        .and_then(|v| v.to_str().ok());
    let caller = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
        Ok(id) => id,
        Err(e) => {
            return (
                StatusCode::BAD_REQUEST,
                Json(json!({"error": crate::errors::msg::invalid("agent_id", e)})),
            )
                .into_response();
        }
    };
    if let Some(agent_id) = body.agent_id.as_deref() {
        if let Err(e) = validate::validate_agent_id(agent_id) {
            return (
                StatusCode::BAD_REQUEST,
                Json(json!({"error": crate::errors::msg::invalid("agent_id", e)})),
            )
                .into_response();
        }
        if agent_id != caller {
            return (
                StatusCode::FORBIDDEN,
                Json(json!({"error": crate::errors::msg::AGENT_ID_BODY_MISMATCH})),
            )
                .into_response();
        }

        // Postgres-backed daemons MUST take the SAL trait dispatch — the
        // scratch sqlite connection at `app.db` has no `agent_quotas`
        // rows.
        #[cfg(feature = "sal")]
        if matches!(app.storage_backend, StorageBackend::Postgres) {
            return match app.store.quota_status(agent_id).await {
                Ok(status) => Json(json!(status)).into_response(),
                Err(e) => store_err_to_response(e),
            };
        }

        // v0.7.0 #1156 — per-namespace K8 dimension. When the caller
        // supplies an explicit `namespace`, return the single
        // `(agent_id, namespace)` row; otherwise roll up the aggregate
        // view across every namespace the agent has written into so
        // the pre-#1156 single-row response shape is preserved.
        let lock = app.db.lock().await;
        let result = match body.namespace.as_deref() {
            Some(ns) => crate::quotas::get_status(&lock.0, agent_id, ns),
            None => crate::quotas::get_aggregate_status(&lock.0, agent_id),
        };
        return match result {
            Ok(status) => Json(json!(status)).into_response(),
            Err(e) => {
                tracing::error!("quota_status handler error: {e}");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
                )
                    .into_response()
            }
        };
    }

    // No agent_id supplied — operator-facing list path.
    //
    // #960 SECURITY-medium (Track A QC sweep, 2026-05-20) — admin-
    // only gate on the list path. Pre-fix any HTTP caller posting
    // `{}` could enumerate the full per-agent quota table. Sibling
    // of #909 (per-agent path) — same disclosure shape.
    if let Err(resp) =
        crate::handlers::admin_role::require_admin(&app, &headers, "quota_status_list")
    {
        return resp;
    }
    #[cfg(feature = "sal")]
    if matches!(app.storage_backend, StorageBackend::Postgres) {
        return match app.store.quota_status_list().await {
            Ok(rows) => Json(json!({"quotas": rows, "count": rows.len()})).into_response(),
            Err(e) => store_err_to_response(e),
        };
    }

    // v0.7.0 #1156 — optional `?namespace=` filter on the operator
    // listing path. When supplied, restricts the response to rows
    // in that namespace; otherwise returns the full table.
    let lock = app.db.lock().await;
    match crate::quotas::list_status(&lock.0, body.namespace.as_deref()) {
        Ok(rows) => {
            let count = rows.len();
            Json(json!({"quotas": rows, "count": count})).into_response()
        }
        Err(e) => {
            tracing::error!("quota_status list handler error: {e}");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
            )
                .into_response()
        }
    }
}

pub async fn get_stats(
    State(app): State<AppState>,
    headers: axum::http::HeaderMap,
) -> impl IntoResponse {
    // #946 SECURITY-medium (Track A QC sweep, 2026-05-20) — admin-only
    // gate. Pre-fix any caller could enumerate full per-tier counts +
    // per-namespace stats + WAL counters; admin-class endpoint.
    if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "get_stats") {
        return resp;
    }
    // v0.7.0 ARCH-2 followup (FX-C2-batch3) — postgres-backed daemons
    // now route stats through `MemoryStore::stats`, which projects the
    // full `Stats` shape via SQL aggregates (total + per-tier + per-
    // namespace + expiring_soon + links_count + table-size). This
    // replaces the previous client-side fold over a 1M-limit
    // `list()` scan which was a Drift cleanup target and would not
    // scale to large deployments.
    #[cfg(feature = "sal-postgres")]
    if matches!(app.storage_backend, StorageBackend::Postgres) {
        return match app.store.stats().await {
            Ok(s) => {
                // Project the SAL Stats shape into the postgres wire
                // shape: by_tier as a wire-string-keyed map (mirrors
                // the previous postgres envelope), per-namespace as
                // a `{namespace: count}` map.
                let mut by_tier_map = serde_json::Map::new();
                for tc in &s.by_tier {
                    by_tier_map.insert(tc.tier.clone(), json!(tc.count));
                }
                let mut by_namespace_map = serde_json::Map::new();
                for nc in &s.by_namespace {
                    by_namespace_map.insert(nc.namespace.clone(), json!(nc.count));
                }
                Json(json!({
                    (field_names::TOTAL_MEMORIES): s.total,
                    "by_tier": by_tier_map,
                    (field_names::BY_NAMESPACE): by_namespace_map,
                    "expiring_soon": s.expiring_soon,
                    "links_count": s.links_count,
                    "db_size_bytes": s.db_size_bytes,
                    (field_names::STORAGE_BACKEND): "postgres",
                }))
                .into_response()
            }
            Err(e) => store_err_to_response(e),
        };
    }

    let lock = app.db.lock().await;
    match db::stats(&lock.0, &lock.1) {
        Ok(s) => Json(json!(s)).into_response(),
        Err(e) => crate::handlers::errors::handler_error_500(&e),
    }
}

pub async fn run_gc(State(app): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    // #1027 (security-critical, 2026-05-21) — admin-role gate. GC
    // permanently sweeps expired rows; pre-#1027 the handler logged
    // the caller to the forensic chain but accepted ANY API-key
    // holder (no admin allowlist membership required). An attacker
    // with the shared API key could force-purge mid-tier-expired
    // rows across tenants in advance of any restore window. The
    // require_admin gate now matches the shape of export_memories
    // (#957) / forget_memories (#956): non-admin callers get a 403
    // FORBIDDEN before any state change.
    let caller = match require_admin(&app, &headers, "run_gc") {
        Ok(c) => c,
        Err(resp) => return resp,
    };

    // #913 (security-medium / SOC2, 2026-05-19) — admin/destructive
    // state-change audit. GC permanently sweeps expired rows; the
    // forensic-chain entry MUST land before the storage write so the
    // audit trail captures the operator who triggered the sweep even
    // when the downstream collector errors.
    crate::governance::audit::record_decision(&caller, "allow", "run_gc", "", json!({}));

    // v0.7.0 Wave-3 Continuation 3 (Phase 17) — postgres-backed daemons
    // route through the SAL trait. Returns the same `{expired_deleted}`
    // envelope so wire shape is backend-blind.
    #[cfg(feature = "sal")]
    if matches!(app.storage_backend, StorageBackend::Postgres) {
        let archive_flag = {
            let lock = app.db.lock().await;
            lock.3
        };
        return match app.store.run_gc(archive_flag).await {
            Ok(n) => {
                Json(json!({(field_names::EXPIRED_DELETED): n, (field_names::STORAGE_BACKEND): "postgres"}))
                    .into_response()
            }
            Err(e) => store_err_to_response(e),
        };
    }

    let lock = app.db.lock().await;
    match db::gc(&lock.0, lock.3) {
        Ok(n) => Json(json!({(field_names::EXPIRED_DELETED): n})).into_response(),
        Err(e) => crate::handlers::errors::handler_error_500(&e),
    }
}

pub async fn export_memories(State(app): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    // #957 (security-critical, 2026-05-20) — admin-role gate.
    // Pre-#957 the handler took NO headers, accepted no caller, and
    // dispatched directly to the export path which intentionally
    // bypasses every visibility filter (postgres SAL branch uses
    // `for_agent("export")` — see `src/store/postgres.rs:8577` — and
    // the sqlite branch reads the whole `memories` table via
    // `db::export_all`). The legacy `api_key_auth` middleware passes
    // through when `api_key` is unset (the default — see #946 RCA),
    // so the endpoint was open by default and any authenticated
    // caller could dump every memory across every owner, every
    // namespace, every scope (including `scope=private`) plus every
    // link in the graph.
    //
    // Fix: require the caller's resolved `agent_id` (from
    // `X-Agent-Id`, the same primitive every other handler uses)
    // to appear in the operator-configured `[admin].agent_ids`
    // allowlist before the corpus dump fires. Non-admin callers
    // get `403 Forbidden` with the sanitised
    // `{"error":"admin role required"}` body — intentionally
    // generic so the rejection does not leak the allowlist
    // configuration. The role decision is forensic-chain audited
    // via `governance::audit::record_decision` whether admitted
    // or rejected (`handlers::admin_role::require_admin`).
    let caller = match crate::handlers::admin_role::require_admin(&app, &headers, "export_memories")
    {
        Ok(c) => c,
        Err(resp) => return resp,
    };

    // v0.7.0 Wave-3 Continuation 3 (Phase 18) — postgres-backed daemons
    // route through the SAL trait. Wire shape preserved:
    // `{memories, links, count, exported_at}`. The admin gate above
    // is the load-bearing authorisation check; the SAL-level
    // `for_admin(caller)` context just preserves the full-fidelity
    // backup semantic (admin export round-trips every row regardless
    // of `metadata.scope`).
    #[cfg(feature = "sal")]
    if matches!(app.storage_backend, StorageBackend::Postgres) {
        let _ = &caller; // resolved + audited above; SAL methods are
        // owner-blind under the operator export contract.
        let mems = match app.store.export_memories().await {
            Ok(v) => v,
            Err(e) => return store_err_to_response(e),
        };
        let links = match app.store.export_links().await {
            Ok(v) => v,
            Err(e) => return store_err_to_response(e),
        };
        let count = mems.len();
        return Json(json!({
            "memories": mems,
            "links": links,
            "count": count,
            (field_names::EXPORTED_AT): Utc::now().to_rfc3339(),
            (field_names::STORAGE_BACKEND): "postgres",
        }))
        .into_response();
    }

    let _ = &caller;
    let lock = app.db.lock().await;
    match (db::export_all(&lock.0), db::export_links(&lock.0)) {
        (Ok(memories), Ok(links)) => {
            let count = memories.len();
            Json(json!({"memories": memories, "links": links, "count": count, (field_names::EXPORTED_AT): Utc::now().to_rfc3339()})).into_response()
        }
        (Err(e), _) | (_, Err(e)) => {
            tracing::error!("export error: {e}");
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
            )
                .into_response()
        }
    }
}

pub async fn import_memories(
    State(app): State<AppState>,
    headers: HeaderMap,
    Json(body): Json<ImportBody>,
) -> impl IntoResponse {
    if body.memories.len() > MAX_BULK_SIZE {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": format!("import limited to {} memories", MAX_BULK_SIZE)})),
        )
            .into_response();
    }

    // #956 (security-medium, 2026-05-20) — admin-role gate + provenance
    // restamp on `/api/v1/import`. Pre-#956 the handler resolved the
    // caller from `X-Agent-Id` but then took `mem.metadata.agent_id`
    // verbatim from the request body. Any authenticated caller could
    // submit `{"memories":[{"metadata":{"agent_id":"alice", ...}}]}`
    // and stamp alice's name on the imported row — same forge primitive
    // #874/#901/#905/#907/#909 closed across other surfaces. Mirrors
    // #957 (export) and the CLI `--trust-source`-off branch at
    // `src/cli/io.rs:97-118`.
    //
    // 1. Gate via `handlers::admin_role::require_admin` — sanitised
    //    `403 {"error":"admin role required"}` on non-admin callers,
    //    audited via `governance::audit::record_decision` whether
    //    admitted or rejected. Empty allowlist (v0.7.0 default) closes
    //    the endpoint to every caller (safe-by-default).
    //
    // 2. For each admitted row, restamp `metadata.agent_id` to the
    //    admin caller and preserve the body's original claim under
    //    `metadata.imported_from_agent_id` (only when the original
    //    differs from the caller — no provenance noise on identical
    //    writes). Mirrors the CLI restamp contract exactly.
    let caller = match crate::handlers::admin_role::require_admin(&app, &headers, "import_memories")
    {
        Ok(c) => c,
        Err(resp) => return resp,
    };

    // #913 (security-medium / SOC2, 2026-05-19) — admin/bulk-write audit.
    // Import landings can move thousands of memories in one call; emit a
    // single forensic-chain entry BEFORE the storage writes so the audit
    // trail captures the batch size + caller identity even on partial
    // success.
    crate::governance::audit::record_decision(
        &caller,
        "allow",
        "import_memories",
        "",
        json!({
            "memory_count": body.memories.len(),
            "link_count": body.links.as_ref().map(Vec::len).unwrap_or(0),
        }),
    );

    // #956 provenance restamp closure. Applied per-row on both
    // backends BEFORE validate / governance / store so all downstream
    // consumers (governance enforce, store.store / db::insert) see
    // the admin caller as the row's principal.
    let restamp_agent_id = |mem: &mut Memory| {
        if !mem.metadata.is_object() {
            mem.metadata = json!({});
        }
        if let Some(obj) = mem.metadata.as_object_mut() {
            let original = obj
                .get("agent_id")
                .and_then(serde_json::Value::as_str)
                .map(ToString::to_string);
            obj.insert(
                "agent_id".to_string(),
                serde_json::Value::String(caller.clone()),
            );
            if let Some(orig) = original
                && orig != caller
            {
                obj.insert(
                    field_names::IMPORTED_FROM_AGENT_ID.to_string(),
                    serde_json::Value::String(orig),
                );
            }
        }
    };
    // v0.7.0 Wave-3 Continuation 3 (Phase 18) — postgres-backed daemons
    // route through the SAL trait. We re-use `app.store.store(...)` per
    // memory (the upsert path that preserves agent_id immutability) and
    // `app.store.link(...)` for each link; partial-success surfaces the
    // same `{imported, errors}` envelope as the sqlite path.
    #[cfg(feature = "sal")]
    if matches!(app.storage_backend, StorageBackend::Postgres) {
        // QC P1 fix (2026-05-20): import_memories now stamps the
        // imported rows under the authenticated `caller` (resolved
        // from X-Agent-Id, line 564) instead of a synthetic
        // "http-import" principal. The SAL store path still applies
        // its `metadata.agent_id` preservation contract — body-
        // supplied agent_id wins when valid (e.g., legitimate
        // re-import of memories already authored by another agent),
        // but the ctx is the auth principal so visibility filters
        // applied INSIDE store_inner (e.g., upsert dedup lookup)
        // see the actual caller.
        let ctx = crate::store::CallerContext::for_agent(caller.clone());
        let mut imported = 0usize;
        let mut errors: Vec<String> = Vec::new();
        let mut pending: Vec<serde_json::Value> = Vec::new();
        for mut mem in body.memories {
            // #956 — restamp before validate / governance / store.
            restamp_agent_id(&mut mem);
            if let Err(e) = validate::RequestValidator::validate_memory(&mem) {
                // Issue #851: never echo the raw `e` to the wire paired
                // with the user-supplied id (the combo reflects the
                // caller's request). Sanitize + log instead.
                tracing::warn!(
                    "import_memories(postgres): validate_memory failed for {}: {e}",
                    mem.id
                );
                errors.push(super::sanitize_bulk_row_error(&e.to_string()).to_string());
                continue;
            }

            // F-A2A1.5 (#705) — governance enforcement on the postgres
            // import path. Mirrors the F-A2A1.2 delete/promote gates and
            // the Wave-3 Continuation 3 create_memory gate: each imported
            // row is a Store action and must be gated by the destination
            // namespace's standard. Deny rows accumulate into `errors`
            // alongside other per-row failures; Pending rows accumulate
            // into `pending` with their pending_id so the caller can
            // drive consensus. Without this gate, postgres-backed
            // daemons silently bypassed namespace governance on the
            // bulk-import surface (same A2A bypass cluster fold-A2A1.2
            // closed on delete/promote/create paths).
            use crate::models::GovernanceDecision;
            // Post-#956 restamp, agent_id is always the admin caller.
            let agent_id = mem
                .metadata
                .get("agent_id")
                .and_then(|v| v.as_str())
                .unwrap_or(caller.as_str());
            let payload_for_pending = serde_json::to_value(&mem).unwrap_or_else(|_| json!({}));
            match app
                .store
                .enforce_governance_action(
                    crate::store::GovernedAction::Store,
                    &mem.namespace,
                    agent_id,
                    None,
                    None,
                    &payload_for_pending,
                )
                .await
            {
                Ok(GovernanceDecision::Allow) => {}
                Ok(GovernanceDecision::Deny(refusal)) => {
                    let mut msg =
                        String::with_capacity(mem.id.len() + 2 + 50 + refusal.reason.len());
                    msg.push_str(&mem.id);
                    msg.push_str(": ");
                    msg.push_str(&crate::governance::deny_message(
                        "import",
                        crate::governance::DenyGate::Governance,
                        &refusal.reason,
                    ));
                    errors.push(msg);
                    continue;
                }
                Ok(GovernanceDecision::Pending(pending_id)) => {
                    pending.push(json!({
                        "id": mem.id,
                        "namespace": mem.namespace,
                        (field_names::PENDING_ID): pending_id,
                    }));
                    continue;
                }
                Err(e) => {
                    errors.push(format!("{}: governance error: {e}", mem.id));
                    continue;
                }
            }

            match app.store.store(&ctx, &mem).await {
                Ok(_) => imported += 1,
                Err(e) => {
                    // Issue #851: SAL `store.store` errors can carry raw
                    // sqlx/sqlite text — sanitize before echoing.
                    tracing::warn!(
                        "import_memories(postgres): store.store failed for {}: {e}",
                        mem.id
                    );
                    errors.push(super::sanitize_bulk_row_error(&e.to_string()).to_string());
                }
            }
        }
        // #869 audit (Category B — safe default): `body.links` is
        // `Option<Vec<MemoryLink>>`; an absent field means the bulk
        // import payload carried no links. Empty-vec default produces
        // a zero-iteration loop, which is the documented behaviour.
        for link in body.links.unwrap_or_default() {
            if validate::RequestValidator::validate_link_triple(
                &link.source_id,
                &link.target_id,
                link.relation.as_str(),
            )
            .is_err()
            {
                continue;
            }
            let _ = app.store.link(&ctx, &link).await;
        }
        return Json(json!({
            "imported": imported,
            "errors": errors,
            "pending": pending,
            (field_names::STORAGE_BACKEND): "postgres",
        }))
        .into_response();
    }

    let lock = app.db.lock().await;
    let mut imported = 0usize;
    let mut errors = Vec::new();
    for mut mem in body.memories {
        // #956 — restamp before validate / insert.
        restamp_agent_id(&mut mem);
        if let Err(e) = validate::RequestValidator::validate_memory(&mem) {
            // Issue #851: never echo `<id>: <validate error>` paired —
            // the combo reflects the caller's request and the inner
            // string can carry validate template detail. Sanitize + log.
            tracing::warn!(
                "import_memories: validate_memory failed for {}: {e}",
                mem.id
            );
            errors.push(super::sanitize_bulk_row_error(&e.to_string()).to_string());
            continue;
        }
        match db::insert(&lock.0, &mem) {
            Ok(_) => imported += 1,
            Err(e) => {
                // Issue #851: db::insert errors include raw rusqlite
                // text (SQL fragments, constraint names). Sanitize.
                tracing::warn!("import_memories: db::insert failed for {}: {e}", mem.id);
                errors.push(super::sanitize_bulk_row_error(&e.to_string()).to_string());
            }
        }
    }
    // #869 audit (Category B — safe default): sqlite branch mirror of
    // the postgres-branch links loop above; same empty-vec semantics.
    for link in body.links.unwrap_or_default() {
        if validate::RequestValidator::validate_link_triple(
            &link.source_id,
            &link.target_id,
            link.relation.as_str(),
        )
        .is_err()
        {
            continue;
        }
        let _ = db::create_link(
            &lock.0,
            &link.source_id,
            &link.target_id,
            link.relation.as_str(),
        );
    }
    Json(json!({"imported": imported, "errors": errors})).into_response()
}

#[derive(serde::Deserialize)]
pub struct ImportBody {
    pub memories: Vec<Memory>,
    #[serde(default)]
    pub links: Option<Vec<MemoryLink>>,
}

/// `GET /api/v1/tools/list` — enumerate the MCP tools currently
/// advertised under the daemon's resolved [`Profile`]. The response
/// shape mirrors MCP `tools/list`: `{tools: [{name, description, ...}],
/// schema_version: <tag>}`. Backend-agnostic — works on both sqlite
/// and postgres daemons because the data is configuration, not user
/// content.
pub async fn tools_list(State(app): State<AppState>) -> impl IntoResponse {
    // `tool_definitions_for_profile` already applies the C2 / C4
    // trims that match the MCP `tools/list` shape. No further shaping
    // is needed for the HTTP wire — the field names line up with the
    // MCP JSON-RPC payload exactly.
    let defs = crate::mcp::tool_definitions_for_profile(app.profile.as_ref());
    (StatusCode::OK, Json(defs)).into_response()
}