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
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! v0.7.0 federation security — peer attestation + scope-allowlist
//! substrate for `/api/v1/sync/push` and `/api/v1/sync/since`.
//!
//! ## Gap context (red-team #230, issues #238 + #239)
//!
//! - **#238** — `SyncPushBody::sender_agent_id` is a body-claimed
//!   identity. Pre-v0.7.0 the receiver logged it for audit and used
//!   it to charge per-agent quotas, but never attested it against
//!   anything. A peer with a valid mTLS cert could claim ANY
//!   `agent_id` in the body, defeating per-agent audit-trail
//!   integrity.
//! - **#239** — `/api/v1/sync/since` returned every memory newer
//!   than the watermark with no per-peer namespace scope. Compromise
//!   of one mTLS peer key exfiltrated the entire database.
//!
//! ## Substrate honesty (operator-must-read)
//!
//! The cryptographic anchor for "this connection is from an authorised
//! peer" today is the mTLS client-cert fingerprint pin
//! (`src/tls.rs::FingerprintAllowlistVerifier`). axum-server 0.8 does
//! **not** propagate the verified peer certificate (or its SAN/CN) to
//! axum handlers — there is no per-request extension that exposes the
//! rustls server connection. Closing that gap requires either a
//! non-trivial axum-server PR or a new x509-parser dependency wired
//! into a custom `ClientCertVerifier` that stashes per-connection
//! state. **That work is escalated to v0.8.0** and tracked under the
//! follow-up to issues #238/#239 in the PR body that landed this
//! module.
//!
//! What this module DOES give v0.7.0:
//!
//! 1. A NEW required outbound header `x-peer-id` carrying the peer's
//!    self-claim of its `sender_agent_id`. The federation client
//!    (`src/federation/sync.rs::post_once`) attaches it on every
//!    outbound `/sync/push` and `/sync/since` request. The receiver
//!    cross-checks `body.sender_agent_id` against this header — the
//!    body field can no longer silently disagree with the wire-level
//!    peer-id without an explicit operator override.
//! 2. An operator-configured allowlist that binds **claimed peer-id**
//!    to **allowed sender_agent_ids** + **allowed namespaces**.
//!    Loaded from the env var `AI_MEMORY_FED_PEER_ATTESTATION` (JSON;
//!    see [`PeerAttestationConfig::from_env`] for the schema). Peers
//!    not in the allowlist still get a clear refusal envelope.
//! 3. Opt-in env bypasses so the live Mac Mini test cell and the
//!    DigitalOcean campaign keep working without config updates
//!    (`AI_MEMORY_FED_TRUST_BODY_AGENT_ID=1`,
//!    `AI_MEMORY_FED_SYNC_TRUST_PEER=1`).
//!
//! The end-to-end trust chain in v0.7.0 is therefore:
//!
//! ```text
//! Operator configures mTLS allowlist (fingerprints)
//!  └─ rustls verifies peer client cert at handshake
//!     └─ HTTP request reaches handler ONLY if cert was pinned
//!        └─ handler reads `x-peer-id` header (operator-bound to
//!           fingerprints via deployment runbook, NOT cryptographic-
//!           ally tied to the cert TODAY)
//!           └─ this module validates body.sender_agent_id /
//!              filters /sync/since projection.
//! ```
//!
//! The weak link is the operator-bound binding between fingerprint
//! and `x-peer-id`. v0.8.0 will replace that with the cert-SAN
//! attestation surface and remove this caveat.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Env var carrying the operator's per-peer attestation allowlist
/// (JSON). Absent / parse-error = empty allowlist (default-deny on
/// `/sync/since` unless [`SYNC_TRUST_PEER_ENV`] is set).
pub const PEER_ATTESTATION_ENV: &str = "AI_MEMORY_FED_PEER_ATTESTATION";

/// Env var that, when set to `"1"`, disables the #238 attestation
/// check and reverts `/sync/push` to its pre-v0.7.0 posture (accept
/// any body-claimed `sender_agent_id`). Backwards-compat for test
/// cells where the operator hasn't yet wired the allowlist.
pub const TRUST_BODY_AGENT_ID_ENV: &str = "AI_MEMORY_FED_TRUST_BODY_AGENT_ID";

/// Env var that, when set to `"1"`, disables the #239 namespace-
/// allowlist check and reverts `/sync/since` to its pre-v0.7.0
/// "full dump" posture. Backwards-compat for the v0.6.x federation
/// mesh and the live test cells that don't yet ship a peer-scope
/// allowlist.
pub const SYNC_TRUST_PEER_ENV: &str = "AI_MEMORY_FED_SYNC_TRUST_PEER";

/// HTTP header carrying the peer's self-claim of `sender_agent_id`.
/// Lowercase per the HTTP/2 wire convention; axum's `HeaderMap`
/// lookups are case-insensitive.
pub const PEER_ID_HEADER: &str = "x-peer-id";

/// Allowlist row for a single peer (keyed by claimed peer-id).
///
/// The `allowed_sender_agent_ids` field, when empty, is interpreted
/// as "peer may push memories where `body.sender_agent_id` equals
/// the peer-id itself" — the minimal-trust default for a peer that
/// only authors as itself. When non-empty, it overrides that default
/// and the list (exact strings, no glob) is the authoritative set of
/// `body.sender_agent_id` values the peer may claim.
///
/// `allowed_namespaces` follows the glob convention used elsewhere
/// in the codebase: `*` matches a single segment, `**` matches any
/// suffix. Empty = peer may not pull any namespace (default-deny).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct PeerScope {
    /// Exact `body.sender_agent_id` values this peer may claim on
    /// `/sync/push`. Empty = only the peer-id itself.
    #[serde(default)]
    pub allowed_sender_agent_ids: Vec<String>,
    /// Glob patterns matched against `Memory::namespace` on
    /// `/sync/since`. Empty = peer may not pull any rows
    /// (default-deny) unless [`SYNC_TRUST_PEER_ENV`] is set.
    #[serde(default)]
    pub allowed_namespaces: Vec<String>,
}

/// Operator-configured federation peer-attestation map. Loaded from
/// the [`PEER_ATTESTATION_ENV`] env var as JSON:
///
/// ```json
/// {
///   "peer-node-1": {
///     "allowed_sender_agent_ids": ["ai:peer-node-1@host", "alice"],
///     "allowed_namespaces": ["public/*", "shared/team-x/**"]
///   },
///   "peer-node-2": {
///     "allowed_namespaces": ["public/*"]
///   }
/// }
/// ```
///
/// The empty map (`{}` or no env var at all) is a valid state. It
/// triggers the default-deny posture on `/sync/since` and the
/// "header must equal body" posture on `/sync/push`.
#[derive(Clone, Debug, Default)]
pub struct PeerAttestationConfig {
    pub peers: HashMap<String, PeerScope>,
}

/// Reason a body-claimed `sender_agent_id` failed attestation against
/// the wire-level `x-peer-id` header.
#[derive(Debug, Clone)]
pub enum AttestError {
    /// `x-peer-id` header absent AND env bypass NOT set. Caller
    /// should return 403.
    HeaderMissing,
    /// `x-peer-id` header present, body field present, no allowlist
    /// row exists for this peer-id, AND `body.sender_agent_id` does
    /// not equal the header. The peer is claiming an identity it has
    /// no operator-configured permission to claim.
    Mismatch {
        claimed: String,
        peer_header: String,
    },
}

impl AttestError {
    /// Stable machine-readable tag for the error envelope.
    #[must_use]
    pub fn tag(&self) -> &'static str {
        match self {
            Self::HeaderMissing => "peer_id_header_missing",
            Self::Mismatch { .. } => "sender_agent_id_mismatch",
        }
    }
}

impl PeerAttestationConfig {
    /// Load the allowlist from the [`PEER_ATTESTATION_ENV`] env var.
    /// Missing env var = empty config (default-deny). Parse error =
    /// empty config + a `tracing::warn!` so the operator sees the
    /// typo immediately. Refusing to start on a malformed allowlist
    /// would be a self-DOS hazard during config rollouts.
    #[must_use]
    pub fn from_env() -> Self {
        match std::env::var(PEER_ATTESTATION_ENV) {
            Ok(s) if !s.trim().is_empty() => {
                match serde_json::from_str::<HashMap<String, PeerScope>>(&s) {
                    Ok(peers) => Self { peers },
                    Err(e) => {
                        tracing::warn!(
                            target: "federation::peer_attestation",
                            env = PEER_ATTESTATION_ENV,
                            error = %e,
                            "failed to parse peer-attestation env var as JSON — \
                             falling back to empty allowlist (default-deny on \
                             /sync/since, header-must-equal-body on /sync/push)"
                        );
                        Self::default()
                    }
                }
            }
            _ => Self::default(),
        }
    }

    /// Lookup scope for a claimed peer-id. Returns `None` when the
    /// operator has not configured any row for this peer.
    #[must_use]
    pub fn scope_for(&self, peer_id: &str) -> Option<&PeerScope> {
        self.peers.get(peer_id)
    }

    /// v0.7.0 #1056 — whether the operator has enrolled at least
    /// one peer in the allowlist. Used by the federation handlers
    /// to distinguish the zero-config posture (no allowlist set =
    /// trust signed peer-ids on faith) from the configured posture
    /// (allowlist set = refuse any peer-id not in the map).
    #[must_use]
    pub fn has_allowlist(&self) -> bool {
        !self.peers.is_empty()
    }
}

/// Whether the operator has explicitly opted out of #238 attestation
/// (legacy behaviour: trust the body field).
#[must_use]
pub fn trust_body_agent_id_bypass() -> bool {
    matches!(std::env::var(TRUST_BODY_AGENT_ID_ENV).as_deref(), Ok("1"))
}

/// Whether the operator has explicitly opted out of #239 scope
/// filtering (legacy behaviour: full database dump per peer).
#[must_use]
pub fn sync_trust_peer_bypass() -> bool {
    matches!(std::env::var(SYNC_TRUST_PEER_ENV).as_deref(), Ok("1"))
}

/// #238 attestation core.
///
/// Validates that the body-claimed `sender_agent_id` is one this
/// peer (identified by the `x-peer-id` header) is operator-permitted
/// to claim.
///
/// Decision matrix:
///
/// | `peer_header` | `body_sender`         | allowlist row | result            |
/// |---------------|-----------------------|---------------|-------------------|
/// | `None`        | any                   | n/a           | [`AttestError::HeaderMissing`] |
/// | `Some(p)`     | `None` or empty       | n/a           | Ok (legacy unauthored push) |
/// | `Some(p)`     | `Some(s)` where `s == p` | n/a        | Ok (peer authoring as itself) |
/// | `Some(p)`     | `Some(s)` where `s != p` | None        | [`AttestError::Mismatch`] |
/// | `Some(p)`     | `Some(s)` where `s != p` | Some(scope), `s ∈ scope.allowed_sender_agent_ids` | Ok |
/// | `Some(p)`     | `Some(s)` where `s != p` | Some(scope), `s ∉ scope.allowed_sender_agent_ids` | [`AttestError::Mismatch`] |
///
/// `body_sender == Some("")` is treated as `None` to match the wire
/// reality (federation clients pre-v0.7.0 sometimes serialise the
/// field as the empty string instead of omitting it).
///
/// # Errors
///
/// Returns [`AttestError`] when the attestation contract is violated;
/// callers should render 403 with a structured error envelope.
pub fn attest_sender(
    peer_header: Option<&str>,
    body_sender: Option<&str>,
    config: &PeerAttestationConfig,
) -> Result<(), AttestError> {
    let peer = match peer_header.map(str::trim).filter(|s| !s.is_empty()) {
        Some(p) => p,
        None => return Err(AttestError::HeaderMissing),
    };
    let claimed = match body_sender.map(str::trim).filter(|s| !s.is_empty()) {
        Some(c) => c,
        // Legacy push with no body claim — peer is implicitly authoring as itself.
        None => return Ok(()),
    };
    if claimed == peer {
        return Ok(());
    }
    if let Some(scope) = config.scope_for(peer)
        && scope
            .allowed_sender_agent_ids
            .iter()
            .any(|a| a.as_str() == claimed)
    {
        return Ok(());
    }
    Err(AttestError::Mismatch {
        claimed: claimed.to_string(),
        peer_header: peer.to_string(),
    })
}

/// Glob match used by [`namespace_allowed`] — supports `*` (single
/// segment) and `**` (any suffix). Mirrors the convention used
/// elsewhere in the codebase (governance rules, allowlist patterns).
/// Pure-function ASCII glob; no regex engine to avoid a new dep.
///
/// Re-exported as [`namespace_allowed_test_glob`] for callers that
/// need to drive the per-pattern decision directly (the `sync_since`
/// handler iterates the scope's pattern list itself so the
/// `excluded_for_scope` count stays accurate against the pre-filter
/// projection).
#[must_use]
pub fn namespace_allowed_test_glob(pattern: &str, target: &str) -> bool {
    glob_match(pattern, target)
}

#[must_use]
fn glob_match(pattern: &str, target: &str) -> bool {
    if pattern == "**" || pattern == "*" {
        return true;
    }
    if let Some(prefix) = pattern.strip_suffix("/**") {
        // `prefix/**` matches `prefix` itself OR anything starting with `prefix/`.
        return target == prefix || target.starts_with(&format!("{prefix}/"));
    }
    if let Some(prefix) = pattern.strip_suffix("/*") {
        // `prefix/*` matches exactly one path-segment after `prefix/`.
        if let Some(rest) = target.strip_prefix(&format!("{prefix}/")) {
            return !rest.contains('/');
        }
        return false;
    }
    if let Some(suffix) = pattern.strip_prefix("*/") {
        // `*/suffix` matches exactly one path-segment before `/suffix`.
        if let Some(rest) = target.strip_suffix(&format!("/{suffix}")) {
            return !rest.contains('/');
        }
        return false;
    }
    pattern == target
}

/// #239 scope-filter core.
///
/// Returns `true` when `namespace` is allowed for the peer identified
/// by `peer_header`. Decision matrix:
///
/// | `peer_header` | scope row    | bypass env | result |
/// |---------------|--------------|------------|--------|
/// | `None`        | n/a          | unset      | false (default-deny) |
/// | `None`        | n/a          | set        | true (legacy full dump) |
/// | `Some(p)`     | None         | unset      | false (default-deny) |
/// | `Some(p)`     | None         | set        | true (legacy full dump) |
/// | `Some(p)`     | Some(scope)  | unset/set  | true iff any pattern in `scope.allowed_namespaces` matches `namespace` |
///
/// The bypass env (`AI_MEMORY_FED_SYNC_TRUST_PEER=1`) ONLY widens
/// the "no scope row" case; once a scope row exists for the peer,
/// its namespace list is the authoritative gate and the bypass is
/// ignored (operator's explicit allowlist wins over the legacy
/// override).
#[must_use]
pub fn namespace_allowed(
    peer_header: Option<&str>,
    namespace: &str,
    config: &PeerAttestationConfig,
) -> bool {
    let Some(peer) = peer_header.map(str::trim).filter(|s| !s.is_empty()) else {
        return sync_trust_peer_bypass();
    };
    match config.scope_for(peer) {
        Some(scope) => scope
            .allowed_namespaces
            .iter()
            .any(|p| glob_match(p, namespace)),
        None => sync_trust_peer_bypass(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg(rows: &[(&str, PeerScope)]) -> PeerAttestationConfig {
        let peers = rows
            .iter()
            .map(|(k, v)| ((*k).to_string(), v.clone()))
            .collect();
        PeerAttestationConfig { peers }
    }

    // ---- attest_sender ---------------------------------------------------

    #[test]
    fn attest_header_missing_errors() {
        let cfg = PeerAttestationConfig::default();
        let err = attest_sender(None, Some("alice"), &cfg).unwrap_err();
        assert!(matches!(err, AttestError::HeaderMissing));
        assert_eq!(err.tag(), "peer_id_header_missing");
    }

    #[test]
    fn attest_header_empty_treated_as_missing() {
        let cfg = PeerAttestationConfig::default();
        let err = attest_sender(Some("   "), Some("alice"), &cfg).unwrap_err();
        assert!(matches!(err, AttestError::HeaderMissing));
    }

    #[test]
    fn attest_body_missing_passes_legacy_unauthored() {
        // No body-claimed sender + peer header present = legacy pre-v0.7.0
        // peer that didn't author rows. Accept.
        let cfg = PeerAttestationConfig::default();
        attest_sender(Some("peer-1"), None, &cfg).unwrap();
        attest_sender(Some("peer-1"), Some(""), &cfg).unwrap();
    }

    #[test]
    fn attest_self_authoring_passes() {
        let cfg = PeerAttestationConfig::default();
        attest_sender(Some("peer-1"), Some("peer-1"), &cfg).unwrap();
    }

    #[test]
    fn attest_mismatch_no_allowlist_errors() {
        let cfg = PeerAttestationConfig::default();
        let err = attest_sender(Some("peer-1"), Some("alice"), &cfg).unwrap_err();
        match err {
            AttestError::Mismatch {
                claimed,
                peer_header,
            } => {
                assert_eq!(claimed, "alice");
                assert_eq!(peer_header, "peer-1");
            }
            other => panic!("expected Mismatch, got: {other:?}"),
        }
    }

    #[test]
    fn attest_mismatch_with_matching_allowlist_passes() {
        let cfg = cfg(&[(
            "peer-1",
            PeerScope {
                allowed_sender_agent_ids: vec!["alice".to_string(), "bob".to_string()],
                ..PeerScope::default()
            },
        )]);
        attest_sender(Some("peer-1"), Some("alice"), &cfg).unwrap();
        attest_sender(Some("peer-1"), Some("bob"), &cfg).unwrap();
    }

    #[test]
    fn attest_mismatch_outside_allowlist_errors() {
        let cfg = cfg(&[(
            "peer-1",
            PeerScope {
                allowed_sender_agent_ids: vec!["alice".to_string()],
                ..PeerScope::default()
            },
        )]);
        let err = attest_sender(Some("peer-1"), Some("eve"), &cfg).unwrap_err();
        assert!(matches!(err, AttestError::Mismatch { .. }));
    }

    // ---- glob_match -----------------------------------------------------

    #[test]
    fn glob_wildcard_all() {
        assert!(glob_match("*", "anything"));
        assert!(glob_match("**", "anything/even/nested"));
    }

    #[test]
    fn glob_prefix_double_star() {
        assert!(glob_match("public/**", "public"));
        assert!(glob_match("public/**", "public/a"));
        assert!(glob_match("public/**", "public/a/b/c"));
        assert!(!glob_match("public/**", "private"));
        assert!(!glob_match("public/**", "publicx"));
    }

    #[test]
    fn glob_prefix_single_star() {
        assert!(glob_match("public/*", "public/foo"));
        assert!(!glob_match("public/*", "public/foo/bar"));
        assert!(!glob_match("public/*", "public"));
    }

    #[test]
    fn glob_suffix_single_star() {
        assert!(glob_match("*/notes", "alice/notes"));
        assert!(!glob_match("*/notes", "alice/team/notes"));
        assert!(!glob_match("*/notes", "notes"));
    }

    #[test]
    fn glob_exact_literal() {
        assert!(glob_match("ai-memory-mcp", "ai-memory-mcp"));
        assert!(!glob_match("ai-memory-mcp", "ai-memory"));
    }

    // ---- namespace_allowed ----------------------------------------------

    #[test]
    fn namespace_no_header_no_bypass_denies() {
        // Make sure no test contamination from env vars.
        // SAFETY: the value cleared belongs to this test only;
        // serial-by-default cargo test isolation is sufficient.
        unsafe { std::env::remove_var(SYNC_TRUST_PEER_ENV) };
        let cfg = PeerAttestationConfig::default();
        assert!(!namespace_allowed(None, "any", &cfg));
        assert!(!namespace_allowed(Some(""), "any", &cfg));
    }

    #[test]
    fn namespace_match_via_glob() {
        let cfg = cfg(&[(
            "peer-1",
            PeerScope {
                allowed_namespaces: vec!["public/*".to_string(), "shared/team-x/**".to_string()],
                ..PeerScope::default()
            },
        )]);
        assert!(namespace_allowed(Some("peer-1"), "public/foo", &cfg));
        assert!(namespace_allowed(Some("peer-1"), "shared/team-x/a/b", &cfg));
        assert!(!namespace_allowed(Some("peer-1"), "private/foo", &cfg));
        assert!(!namespace_allowed(Some("peer-1"), "public/foo/bar", &cfg));
    }

    #[test]
    fn namespace_no_scope_row_denies_without_bypass() {
        unsafe { std::env::remove_var(SYNC_TRUST_PEER_ENV) };
        let cfg = PeerAttestationConfig::default();
        assert!(!namespace_allowed(Some("peer-1"), "any", &cfg));
    }

    // ---- PeerAttestationConfig::from_env --------------------------------
    //
    // These three tests all mutate the process-wide PEER_ATTESTATION_ENV
    // env var, so they MUST be serialised against each other under
    // `cargo test --test-threads=N` (N >= 2). Without the shared mutex
    // one test's set_var races another test's remove_var and the
    // assertion non-deterministically observes the wrong configuration.
    // The Coverage CI gate caught this at `--test-threads=2`:
    // `from_env_parse_error_is_empty` saw a valid JSON payload from a
    // concurrent `from_env_parses_valid_json` and failed
    // `cfg.peers.is_empty()`. Same idiom as the rules-store guard.

    static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());

    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
        ENV_GUARD
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }

    #[test]
    fn from_env_absent_is_empty() {
        let _g = lock_env();
        unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
        let cfg = PeerAttestationConfig::from_env();
        assert!(cfg.peers.is_empty());
    }

    #[test]
    fn has_allowlist_false_when_zero_config_1056() {
        // v0.7.0 #1056 — zero-config (no env var) means no allowlist,
        // so `has_allowlist()` returns false and the federation
        // handlers fall through to the legacy permissive posture.
        let _g = lock_env();
        unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
        let cfg = PeerAttestationConfig::from_env();
        assert!(
            !cfg.has_allowlist(),
            "#1056: zero-config PeerAttestationConfig MUST report has_allowlist()=false"
        );
    }

    #[test]
    fn has_allowlist_true_when_peers_enrolled_1056() {
        // v0.7.0 #1056 — once an operator enrols at least one peer,
        // `has_allowlist()` flips to true, and the federation
        // handlers' TOFU gate refuses any x-peer-id NOT in the map.
        let _g = lock_env();
        let body = r#"{"enrolled-peer": {"allowed_namespaces": ["ns/*"]}}"#;
        unsafe { std::env::set_var(PEER_ATTESTATION_ENV, body) };
        let cfg = PeerAttestationConfig::from_env();
        unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
        assert!(
            cfg.has_allowlist(),
            "#1056: configured PeerAttestationConfig MUST report has_allowlist()=true"
        );
        assert!(cfg.scope_for("enrolled-peer").is_some());
        assert!(
            cfg.scope_for("not-in-map").is_none(),
            "#1056: unknown peer MUST return None (handlers refuse)"
        );
    }

    #[test]
    fn from_env_parses_valid_json() {
        let _g = lock_env();
        let body = r#"{
            "peer-1": {
                "allowed_sender_agent_ids": ["alice", "bob"],
                "allowed_namespaces": ["public/*"]
            }
        }"#;
        unsafe { std::env::set_var(PEER_ATTESTATION_ENV, body) };
        let cfg = PeerAttestationConfig::from_env();
        unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
        let scope = cfg.scope_for("peer-1").expect("peer-1 row present");
        assert_eq!(scope.allowed_sender_agent_ids, vec!["alice", "bob"]);
        assert_eq!(scope.allowed_namespaces, vec!["public/*"]);
    }

    #[test]
    fn from_env_parse_error_is_empty() {
        let _g = lock_env();
        unsafe { std::env::set_var(PEER_ATTESTATION_ENV, "not json{{") };
        let cfg = PeerAttestationConfig::from_env();
        unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
        assert!(cfg.peers.is_empty());
    }
}