ijima-core 0.2.1

Core schema, stores, error types, and API contract for the Ijima centralized agentic memory backend
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
// Copyright (C) 2026 Industrial Algebra
// SPDX-License-Identifier: Apache-2.0

//! The federation control-API wire contract — Ijima's server-side DTOs for
//! the surface Dominic's [`FederationClient`](../../Dominic) consumes.
//!
//! This module is the canonical home for the federation wire types; dominic-core
//! currently carries a duplicate set (its topology/federation modules) which it
//! anticipates unifying here once it depends on `ijima-core` (see the doc
//! comment on dominic-core's `InstanceId`). The types mirror dominic-core's
//! shapes **exactly** so the JSON is byte-compatible without either crate
//! depending on the other — the wire format is the shared spec.
//!
//! Status: **scaffold** (ADR `docs/adr/federation-control-api.md`). The DTOs +
//! routes are in place; non-bypassable boundary enforcement (trust-tier egress
//! filtering, scope/airgap deny, boundary transformation) is deferred — see the
//! ADR's "Deferred" section. Today these routes apply writes locally with
//! provenance stamping but do not yet enforce the federation safety floor.
//!
//! (Federation design seed: `docs/discovery/networked-instances-federation.md`;
//! provenance foundation: `provenance.rs` + ADR `provenance-tier-model.md`.)

#![cfg(feature = "federation")]

use serde::{Deserialize, Serialize};

/// A stable instance identifier (`"local"` for a single-instance 0.1.0
/// deployment). Newtype — serde serializes it as its inner string.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct InstanceId(pub String);

impl InstanceId {
    /// Constructs an instance id.
    #[must_use]
    pub fn new(id: impl Into<String>) -> Self {
        Self(id.into())
    }

    /// The local instance id (single-instance default).
    #[must_use]
    pub fn local() -> Self {
        Self("local".to_string())
    }
}

impl Default for InstanceId {
    fn default() -> Self {
        Self::local()
    }
}

/// The role an Ijima instance plays in the federation topology (federation
/// seed §3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InstanceRole {
    /// Aggregator / authoritative hub.
    Unifying,
    /// Cold storage / backup / larger-storage tier.
    Archive,
    /// Source-of-truth for a specific domain; others defer to it there.
    DomainAuthority,
    /// Offline-capable replica that syncs to a central instance.
    Edge,
    /// Sovereign; default-deny egress.
    Airgapped,
}

/// The scope (namespace/project) an instance is authoritative for. Drives
/// source-authority conflict resolution.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AuthoritativeScope {
    /// Namespace the instance is authoritative for.
    pub namespace: String,
    /// Project the instance is authoritative for.
    pub project: String,
}

impl AuthoritativeScope {
    /// Constructs an authoritative scope.
    #[must_use]
    pub fn new(namespace: impl Into<String>, project: impl Into<String>) -> Self {
        Self {
            namespace: namespace.into(),
            project: project.into(),
        }
    }
}

/// Direction of memory flow on a federation link.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LinkDirection {
    /// A → B, read-only.
    Replica,
    /// Bidirectional.
    Sync,
    /// B pulls.
    Subscribe,
    /// Hard deny (no flow).
    Airgap,
}

/// How write conflicts are resolved when two instances touch the same scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConflictResolution {
    /// The authoritative instance for the scope wins (default).
    SourceAuthority,
    /// Highest timestamp wins.
    LastWriteWins,
    /// CRDT-style merge.
    CrdtMerge,
}

/// How fresh cross-instance data must be.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Freshness {
    /// Streaming.
    Realtime,
    /// Scheduled + offline queue.
    Batched,
}

/// A federation link policy between two instances (federation seed §4).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkPolicy {
    /// Direction of memory flow.
    pub direction: LinkDirection,
    /// Conflict resolution strategy.
    pub conflict: ConflictResolution,
    /// Freshness requirement.
    pub freshness: Freshness,
}

/// An outbound link this instance declares to a peer (the wire form carried in
/// [`FederationState::outbound_links`]; the graph edge's `source` is the
/// instance itself).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutboundLink {
    /// The peer instance the link points to.
    pub target: InstanceId,
    /// The link's policy.
    pub policy: LinkPolicy,
}

/// A routed-write operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum WriteOperation {
    /// Create a new record.
    Create,
    /// Update an existing record.
    Update,
    /// Delete a record.
    Delete,
}

/// `GET /federation/state` response: an instance's federated view + a
/// reference to its capability policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FederationState {
    /// The instance this state describes.
    pub instance_id: InstanceId,
    /// The instance's federation role.
    pub role: InstanceRole,
    /// Scopes this instance is authoritative for.
    pub authoritative_scopes: Vec<AuthoritativeScope>,
    /// Outbound links this instance declares to peers.
    pub outbound_links: Vec<OutboundLink>,
    /// Hash/reference of the instance's capability policy (`policy.toml`),
    /// for local AccessController construction.
    pub capability_policy_ref: Option<String>,
    /// Cache/validation tag.
    pub etag: Option<String>,
}

/// `POST /federation/routed-write` request: Dominic asks an instance to apply
/// a write under its authoritative scope.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutedWrite {
    /// The target instance.
    pub target: InstanceId,
    /// The scope being written.
    pub scope: AuthoritativeScope,
    /// The write operation.
    pub operation: WriteOperation,
    /// The opaque write payload. Scaffold contract: a [`crate::Memory`]-shaped
    /// JSON object (the per-scope payload contract is a follow-on).
    pub payload: serde_json::Value,
}

/// `POST /federation/routed-write` receipt: Ijima's authoritative confirmation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutedWriteReceipt {
    /// Whether the write was accepted.
    pub accepted: bool,
    /// The instance that produced the receipt.
    pub instance: InstanceId,
    /// The scope written.
    pub scope: AuthoritativeScope,
    /// The resulting commit id, when accepted.
    pub commit: Option<String>,
    /// Non-fatal warnings (downgrades, scope narrowing, freshness, scaffold
    /// deferrals).
    pub warnings: Vec<String>,
}

/// `POST /federation/conflict-signal`: Ijima tells Dominic a conflict needs
/// adjudication.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConflictSignal {
    /// The contested scope.
    pub scope: AuthoritativeScope,
    /// The instances in conflict.
    pub instances: Vec<InstanceId>,
    /// The conflict resolution Ijima applied (or requests).
    pub resolution: ConflictResolution,
    /// Human-readable detail.
    pub detail: Option<String>,
}

/// An instance's federation self-description — the source for
/// [`FederationState`]. Constructed at server startup (from config); the
/// single-instance default is `Unifying` / local scope / no links.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceFederationConfig {
    /// The instance's stable id.
    pub instance_id: InstanceId,
    /// The instance's federation role.
    pub role: InstanceRole,
    /// Scopes this instance is authoritative for.
    pub authoritative_scopes: Vec<AuthoritativeScope>,
    /// Outbound links this instance declares.
    pub outbound_links: Vec<OutboundLink>,
    /// Reference to the capability policy.
    pub capability_policy_ref: Option<String>,
}

impl InstanceFederationConfig {
    /// Render the config as a [`FederationState`] (the `GET /federation/state`
    /// payload). `etag` is left `None` (cache validation is a follow-on).
    #[must_use]
    pub fn to_state(&self) -> FederationState {
        FederationState {
            instance_id: self.instance_id.clone(),
            role: self.role,
            authoritative_scopes: self.authoritative_scopes.clone(),
            outbound_links: self.outbound_links.clone(),
            capability_policy_ref: self.capability_policy_ref.clone(),
            etag: None,
        }
    }

    /// Whether this instance is authoritative for `scope` — the non-bypassable
    /// scope filter on federation ingress. Matches if any declared
    /// authoritative scope agrees on namespace and project, where `*` is a
    /// wildcard on either axis (so `{"local","*"}` accepts any project in the
    /// `local` namespace, and `{"*","*"}` accepts everything).
    #[must_use]
    pub fn accepts_scope(&self, scope: &AuthoritativeScope) -> bool {
        self.authoritative_scopes.iter().any(|s| {
            (s.namespace == scope.namespace || s.namespace == "*")
                && (s.project == scope.project || s.project == "*")
        })
    }
}

impl Default for InstanceFederationConfig {
    /// The single-instance 0.1.0 default: the local instance, `Unifying`,
    /// authoritative for the local namespace, no peer links.
    fn default() -> Self {
        Self {
            instance_id: InstanceId::local(),
            role: InstanceRole::Unifying,
            authoritative_scopes: vec![AuthoritativeScope::new("local", "*")],
            outbound_links: Vec::new(),
            capability_policy_ref: None,
        }
    }
}

impl std::str::FromStr for InstanceRole {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_ascii_lowercase().as_str() {
            "unifying" => Ok(Self::Unifying),
            "archive" => Ok(Self::Archive),
            "domain-authority" | "domain_authority" => Ok(Self::DomainAuthority),
            "edge" => Ok(Self::Edge),
            "airgapped" => Ok(Self::Airgapped),
            other => Err(format!(
                "unknown instance role '{other}' (expected unifying | archive | domain-authority | edge | airgapped)"
            )),
        }
    }
}

impl std::str::FromStr for AuthoritativeScope {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (ns, proj) = s
            .split_once(':')
            .ok_or_else(|| format!("authoritative scope '{s}' must be 'namespace:project'"))?;
        Ok(Self::new(ns, proj))
    }
}

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

    /// Wire-compat: the JSON shapes must match dominic-core's DTOs byte-for-byte
    /// (InstanceId as a plain string, AuthoritativeScope as {namespace,project},
    /// enums as PascalCase). This is the contract that lets Dominic's
    /// FederationClient deserialize Ijima's responses without a shared crate.
    #[test]
    fn federation_state_serializes_to_the_wire_contract() {
        let state = FederationState {
            instance_id: InstanceId::new("ijima-1"),
            role: InstanceRole::Unifying,
            authoritative_scopes: vec![AuthoritativeScope::new("shared", "Dominic")],
            outbound_links: vec![OutboundLink {
                target: InstanceId::new("ijima-2"),
                policy: LinkPolicy {
                    direction: LinkDirection::Replica,
                    conflict: ConflictResolution::SourceAuthority,
                    freshness: Freshness::Realtime,
                },
            }],
            capability_policy_ref: Some("sha256:abc".into()),
            etag: Some("w1".into()),
        };
        let json = serde_json::to_string(&state).expect("serialize");
        // InstanceId newtype → plain string (not {"0": ...})
        assert!(
            json.contains(r#""instance_id":"ijima-1""#),
            "InstanceId must serialize as a plain string: {json}"
        );
        // AuthoritativeScope → {namespace, project} struct
        assert!(
            json.contains(r#""authoritative_scopes":[{"namespace":"shared","project":"Dominic"}]"#),
            "AuthoritativeScope must be a struct: {json}"
        );
        // Enums → PascalCase
        assert!(json.contains(r#""role":"Unifying""#), "role: {json}");
        assert!(
            json.contains(r#""direction":"Replica""#),
            "direction: {json}"
        );
        assert!(
            json.contains(r#""conflict":"SourceAuthority""#),
            "conflict: {json}"
        );
        // round-trips
        let back: FederationState = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(state, back);
    }

    #[test]
    fn config_default_is_local_unifying() {
        let cfg = InstanceFederationConfig::default();
        assert_eq!(cfg.instance_id, InstanceId::local());
        assert_eq!(cfg.role, InstanceRole::Unifying);
        assert!(cfg.outbound_links.is_empty());
        let state = cfg.to_state();
        assert_eq!(state.instance_id, InstanceId::local());
        assert_eq!(state.role, InstanceRole::Unifying);
        assert!(state.etag.is_none());
    }

    #[test]
    fn accepts_scope_matches_namespace_and_wildcard_project() {
        let cfg = InstanceFederationConfig::default(); // authoritative for {local, *}
        // in scope: local namespace, any project
        assert!(cfg.accepts_scope(&AuthoritativeScope::new("local", "Dominic")));
        assert!(cfg.accepts_scope(&AuthoritativeScope::new("local", "anything")));
        // out of scope: different namespace
        assert!(!cfg.accepts_scope(&AuthoritativeScope::new("shared", "Dominic")));
        // a fully-wildcard config accepts everything
        let open = InstanceFederationConfig {
            authoritative_scopes: vec![AuthoritativeScope::new("*", "*")],
            ..InstanceFederationConfig::default()
        };
        assert!(open.accepts_scope(&AuthoritativeScope::new("shared", "X")));
    }

    #[test]
    fn instance_role_from_str_is_lowercase_tolerant() {
        use std::str::FromStr;
        assert_eq!(
            InstanceRole::from_str("unifying").unwrap(),
            InstanceRole::Unifying
        );
        assert_eq!(
            InstanceRole::from_str("Airgapped").unwrap(),
            InstanceRole::Airgapped
        );
        assert_eq!(
            InstanceRole::from_str("domain-authority").unwrap(),
            InstanceRole::DomainAuthority
        );
        assert!(InstanceRole::from_str("bogus").is_err());
    }

    #[test]
    fn authoritative_scope_from_str_parses_namespace_project() {
        use std::str::FromStr;
        let s = AuthoritativeScope::from_str("shared:Dominic").unwrap();
        assert_eq!(s.namespace, "shared");
        assert_eq!(s.project, "Dominic");
        assert!(AuthoritativeScope::from_str("nocolon").is_err());
    }

    #[test]
    fn routed_write_and_receipt_round_trip() {
        let write = RoutedWrite {
            target: InstanceId::new("ijima-1"),
            scope: AuthoritativeScope::new("shared", "Dominic"),
            operation: WriteOperation::Create,
            payload: serde_json::json!({"content": "hello"}),
        };
        let json = serde_json::to_string(&write).expect("serialize");
        let back: RoutedWrite = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(write.target, back.target);
        assert_eq!(write.scope, back.scope);
        assert_eq!(write.operation, back.operation);

        let receipt = RoutedWriteReceipt {
            accepted: true,
            instance: InstanceId::new("ijima-1"),
            scope: AuthoritativeScope::new("shared", "Dominic"),
            commit: Some("mem_1".into()),
            warnings: vec![],
        };
        let rj = serde_json::to_string(&receipt).expect("serialize");
        let _: RoutedWriteReceipt = serde_json::from_str(&rj).expect("deserialize");
    }
}