sage-agent-sdk 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,950 @@
1
+ Metadata-Version: 2.4
2
+ Name: sage-agent-sdk
3
+ Version: 1.0.0
4
+ Summary: Python SDK for SAGE — Sovereign Agent Governed Experience. Persistent, consensus-validated memory for AI agents.
5
+ Author-email: Dhillon Andrew Kannabhiran <dhillon@levelupctf.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/l33tdawg/sage
8
+ Project-URL: Documentation, https://l33tdawg.github.io/sage/
9
+ Project-URL: Repository, https://github.com/l33tdawg/sage
10
+ Project-URL: Issues, https://github.com/l33tdawg/sage/issues
11
+ Keywords: sage,ai,agent,memory,bft,consensus,llm,mcp
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: httpx>=0.25.0
23
+ Requires-Dist: pydantic>=2.0.0
24
+ Requires-Dist: PyNaCl>=1.5.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
28
+ Requires-Dist: respx>=0.21; extra == "dev"
29
+
30
+ # SAGE Python SDK
31
+
32
+ Python client for the SAGE (Sovereign Agent Governed Experience) protocol -- a governed, verifiable institutional memory layer for multi-agent systems.
33
+
34
+ **Requires Python 3.10+**
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ # From source (development)
40
+ git clone <repo-url>
41
+ cd sage/sdk/python
42
+ pip install -e .
43
+
44
+ # With dev/test dependencies
45
+ pip install -e ".[dev]"
46
+ ```
47
+
48
+ ## Quickstart
49
+
50
+ ```python
51
+ from sage_sdk import SageClient, AgentIdentity
52
+
53
+ # Generate a new agent identity (Ed25519 keypair)
54
+ identity = AgentIdentity.generate()
55
+
56
+ # Save for reuse across sessions
57
+ identity.to_file("my_agent.key")
58
+
59
+ # Connect to a SAGE node
60
+ client = SageClient(base_url="http://localhost:8080", identity=identity)
61
+
62
+ # Submit a memory
63
+ result = client.propose(
64
+ content="Flask web challenges with SQLi require prepared statements bypass",
65
+ memory_type="fact",
66
+ domain_tag="challenge_generation",
67
+ confidence=0.85,
68
+ )
69
+ print(f"Memory {result.memory_id} submitted (tx: {result.tx_hash})")
70
+
71
+ # Query by vector similarity
72
+ matches = client.query(
73
+ embedding=[0.1] * 768, # 768-dim (nomic-embed-text)
74
+ domain_tag="challenge_generation",
75
+ min_confidence=0.7,
76
+ top_k=5,
77
+ )
78
+ for mem in matches.results:
79
+ print(f" [{mem.status.value}] {mem.content[:80]}")
80
+
81
+ # Vote on a proposed memory
82
+ client.vote(result.memory_id, decision="accept", rationale="Verified correct")
83
+ ```
84
+
85
+ ## Authentication
86
+
87
+ SAGE uses Ed25519 keypairs for agent identity. Every API request is signed with the agent's private key.
88
+
89
+ ```python
90
+ from sage_sdk import AgentIdentity
91
+
92
+ # Generate a new identity
93
+ identity = AgentIdentity.generate()
94
+
95
+ # The agent_id is the hex-encoded public key
96
+ print(identity.agent_id) # e.g. "a1b2c3d4..."
97
+
98
+ # Persist to disk
99
+ identity.to_file("agent.key")
100
+
101
+ # Load from disk
102
+ identity = AgentIdentity.from_file("agent.key")
103
+
104
+ # Create from a known 32-byte seed (deterministic)
105
+ identity = AgentIdentity.from_seed(b"\x00" * 32)
106
+ ```
107
+
108
+ Request signing is handled automatically by the client. Each request includes three headers:
109
+
110
+ | Header | Description |
111
+ |--------|-------------|
112
+ | `X-Agent-ID` | Hex-encoded public verify key |
113
+ | `X-Signature` | Ed25519 signature of `SHA256(body) || timestamp` |
114
+ | `X-Timestamp` | Unix timestamp (seconds) |
115
+
116
+ ## API Reference
117
+
118
+ ### `propose()`
119
+
120
+ Submit a new memory to the network for validation.
121
+
122
+ ```python
123
+ result = client.propose(
124
+ content="The observation text",
125
+ memory_type="fact", # "fact", "observation", or "inference"
126
+ domain_tag="security",
127
+ confidence=0.9, # 0.0 - 1.0
128
+ embedding=[0.1, 0.2, ...], # Optional: precomputed vector
129
+ knowledge_triples=[ # Optional: structured knowledge
130
+ KnowledgeTriple(subject="SQLi", predicate="bypasses", object_="prepared_statements")
131
+ ],
132
+ parent_hash="abc123", # Optional: link to parent memory
133
+ )
134
+ # Returns: MemorySubmitResponse(memory_id, tx_hash, status)
135
+ ```
136
+
137
+ **Server endpoint:** `POST /v1/memory/submit`
138
+
139
+ ### `query()`
140
+
141
+ Search memories by vector similarity. All parameters are sent in the POST body.
142
+
143
+ ```python
144
+ results = client.query(
145
+ embedding=[0.1] * 768, # 768-dim (nomic-embed-text) # Required: query vector
146
+ domain_tag="security", # Optional: filter by domain
147
+ min_confidence=0.7, # Optional: minimum confidence threshold
148
+ top_k=10, # Number of results (default: 10)
149
+ status_filter="committed", # Optional: filter by status
150
+ cursor="abc123", # Optional: pagination cursor
151
+ )
152
+ # Returns: MemoryQueryResponse(results, next_cursor, total_count)
153
+ for memory in results.results:
154
+ print(f"{memory.memory_id}: {memory.content}")
155
+ ```
156
+
157
+ **Server endpoint:** `POST /v1/memory/query`
158
+
159
+ ### `get_memory()`
160
+
161
+ Retrieve a single memory by ID.
162
+
163
+ ```python
164
+ memory = client.get_memory("550e8400-e29b-41d4-a716-446655440000")
165
+ # Returns: MemoryRecord
166
+ print(memory.content, memory.status, memory.confidence_score)
167
+ ```
168
+
169
+ **Server endpoint:** `GET /v1/memory/{id}`
170
+
171
+ ### `vote()`
172
+
173
+ Cast a vote on a proposed memory.
174
+
175
+ ```python
176
+ result = client.vote(
177
+ memory_id="550e8400-...",
178
+ decision="accept", # "accept", "reject", or "abstain"
179
+ rationale="Verified correct", # Optional
180
+ )
181
+ ```
182
+
183
+ **Server endpoint:** `POST /v1/memory/{id}/vote`
184
+
185
+ ### `challenge()`
186
+
187
+ Challenge a committed memory with evidence.
188
+
189
+ ```python
190
+ result = client.challenge(
191
+ memory_id="550e8400-...",
192
+ reason="Outdated information",
193
+ evidence="See CVE-2024-XXXX", # Optional
194
+ )
195
+ ```
196
+
197
+ **Server endpoint:** `POST /v1/memory/{id}/challenge`
198
+
199
+ ### `corroborate()`
200
+
201
+ Corroborate an existing memory to strengthen its confidence.
202
+
203
+ ```python
204
+ result = client.corroborate(
205
+ memory_id="550e8400-...",
206
+ evidence="Independently verified via testing", # Optional
207
+ )
208
+ ```
209
+
210
+ **Server endpoint:** `POST /v1/memory/{id}/corroborate`
211
+
212
+ ### `get_profile()`
213
+
214
+ Get the current agent's profile and Proof of Experience weight.
215
+
216
+ ```python
217
+ profile = client.get_profile()
218
+ print(f"Agent: {profile.agent_id}")
219
+ print(f"PoE Weight: {profile.poe_weight}")
220
+ print(f"Votes Cast: {profile.vote_count}")
221
+ ```
222
+
223
+ **Server endpoint:** `GET /v1/agent/me`
224
+
225
+ ### `register_domain()`
226
+
227
+ Register a new domain. The registering agent becomes the domain owner and can control access.
228
+
229
+ ```python
230
+ result = client.register_domain(
231
+ name="security.crypto", # Domain name (hierarchical with dots)
232
+ description="Cryptographic security knowledge", # Optional
233
+ parent="security", # Optional: parent domain
234
+ )
235
+ ```
236
+
237
+ **Server endpoint:** `POST /v1/domain/register`
238
+
239
+ ### `get_domain()`
240
+
241
+ Look up domain info including owner.
242
+
243
+ ```python
244
+ info = client.get_domain("security.crypto")
245
+ print(f"Owner: {info['owner_agent_id']}")
246
+ ```
247
+
248
+ **Server endpoint:** `GET /v1/domain/{name}`
249
+
250
+ ### `request_access()`
251
+
252
+ Request access to a domain owned by another agent.
253
+
254
+ ```python
255
+ result = client.request_access(
256
+ domain="security.crypto",
257
+ justification="Need to submit cryptographic observations",
258
+ level=1, # Clearance level (1-4)
259
+ )
260
+ ```
261
+
262
+ **Server endpoint:** `POST /v1/access/request`
263
+
264
+ ### `grant_access()`
265
+
266
+ Grant access to a domain you own.
267
+
268
+ ```python
269
+ result = client.grant_access(
270
+ grantee_id="a1b2c3...", # Agent to grant access to
271
+ domain="security.crypto",
272
+ level=1, # Clearance level (1-4)
273
+ expires_at=0, # Unix timestamp, 0 = never expires
274
+ )
275
+ ```
276
+
277
+ **Server endpoint:** `POST /v1/access/grant`
278
+
279
+ ### `revoke_access()`
280
+
281
+ Revoke a previously granted access.
282
+
283
+ ```python
284
+ result = client.revoke_access(
285
+ grantee_id="a1b2c3...",
286
+ domain="security.crypto",
287
+ reason="Agent decommissioned",
288
+ )
289
+ ```
290
+
291
+ **Server endpoint:** `POST /v1/access/revoke`
292
+
293
+ ### `list_grants()`
294
+
295
+ List active access grants for an agent.
296
+
297
+ ```python
298
+ grants = client.list_grants() # Current agent's grants
299
+ grants = client.list_grants("a1b2c3") # Specific agent's grants
300
+ ```
301
+
302
+ **Server endpoint:** `GET /v1/access/grants/{agent_id}`
303
+
304
+ ## Access Control
305
+
306
+ SAGE uses a hierarchical access control model. See the **Deployment Guide** section below for the complete setup walkthrough.
307
+
308
+ ```
309
+ Organization → Department → Domain → Agent (with clearance 0-4)
310
+ ```
311
+
312
+ Key points:
313
+ - **Register domains before submitting memories** — unregistered domains have no access control
314
+ - **Department boundaries are enforced** — agents in one dept cannot see another dept's memories
315
+ - **Federation enables cross-org access** — scoped by department and clearance cap
316
+ - **All access control operations are on-chain BFT transactions** — immutable once committed
317
+
318
+ ## Deployment Guide: Access Control Setup
319
+
320
+ > **CRITICAL: Access controls are on-chain and immutable. You MUST define your organization structure, departments, domains, and agent memberships BEFORE agents start submitting memories. Memories submitted before access controls exist cannot be retroactively restricted. Plan your access hierarchy NOW, not later.**
321
+
322
+ ### Architecture Overview
323
+
324
+ SAGE has two layers of identity:
325
+
326
+ 1. **Validator nodes** — the 4+ CometBFT nodes running consensus. These are infrastructure.
327
+ 2. **Application agents** — the AI agents that submit, query, and vote on memories. Each needs its own Ed25519 keypair, org membership, and department assignment.
328
+
329
+ The access control hierarchy:
330
+
331
+ ```
332
+ Organization (on-chain entity — controls all access within)
333
+ ├── Department A (subdivision — scopes agent access)
334
+ │ ├── Domain "security.crypto" (knowledge category — access-controlled)
335
+ │ │ ├── Agent 1 (clearance 2: can read+write)
336
+ │ │ └── Agent 2 (clearance 1: read-only)
337
+ │ └── Domain "security.web"
338
+ │ └── Agent 3 (clearance 2)
339
+ └── Department B
340
+ └── Domain "research.ml"
341
+ └── Agent 4 (clearance 3)
342
+ ```
343
+
344
+ **Access rules:**
345
+ - An agent in Dept A can access Dept A's domains — but NOT Dept B's domains (same org, different dept)
346
+ - An agent in Org X cannot access ANY memories in Org Y — unless an explicit federation agreement exists
347
+ - Federation agreements are scoped: "Org X allows Org Y's Engineering dept to access our data, max clearance 2"
348
+ - An agent always has access to memories it submitted, regardless of RBAC
349
+
350
+ ### Step 0: Deploy the Chain
351
+
352
+ ```bash
353
+ # Generate 4-node validator configs
354
+ make init
355
+
356
+ # Start the BFT network (4 CometBFT + 4 ABCI + PostgreSQL + Ollama)
357
+ make up
358
+
359
+ # Verify all nodes are healthy
360
+ make status
361
+ ```
362
+
363
+ The chain is now running with 4 validator nodes. No application agents exist yet — the validators handle consensus only.
364
+
365
+ ### Step 1: Create Org Admin Identity
366
+
367
+ The org admin is the first agent registered. This keypair has permanent admin authority over the organization. **Store it securely — it cannot be changed.**
368
+
369
+ ```python
370
+ from sage_sdk import SageClient, AgentIdentity
371
+
372
+ # Generate the org admin keypair
373
+ admin = AgentIdentity.generate()
374
+ admin.to_file("org_admin.key") # BACK THIS UP — it's your org's root authority
375
+
376
+ admin_client = SageClient(base_url="http://localhost:8080", identity=admin)
377
+ ```
378
+
379
+ ### Step 2: Register Your Organization
380
+
381
+ ```python
382
+ org = admin_client.register_org("Acme Corp", description="AI security research")
383
+ org_id = org["org_id"]
384
+ print(f"Organization registered: {org_id}")
385
+ # Save org_id — you'll need it for every subsequent operation
386
+ ```
387
+
388
+ This is an on-chain BFT transaction. Once committed, the registering agent becomes the permanent admin.
389
+
390
+ ### Step 3: Create ALL Departments
391
+
392
+ Define every department your organization needs. Each department is an access boundary — agents in one department cannot see memories in another.
393
+
394
+ ```python
395
+ # Create departments for each team/function
396
+ eng = admin_client.register_dept(org_id, name="Engineering", description="Core engineering team")
397
+ eng_dept = eng["dept_id"]
398
+
399
+ security = admin_client.register_dept(org_id, name="Security", description="Security research")
400
+ sec_dept = security["dept_id"]
401
+
402
+ research = admin_client.register_dept(org_id, name="Research", description="ML research")
403
+ res_dept = research["dept_id"]
404
+
405
+ # Sub-departments are supported (optional)
406
+ crypto = admin_client.register_dept(
407
+ org_id, name="Cryptography", description="Crypto team", parent_dept=sec_dept
408
+ )
409
+ crypto_dept = crypto["dept_id"]
410
+ ```
411
+
412
+ ### Step 4: Register ALL Domains
413
+
414
+ **This is the most critical step.** Unregistered domains have NO access control — any agent can read and write. You must register every domain your agents will use.
415
+
416
+ ```python
417
+ # Register domains — the admin agent becomes the owner
418
+ admin_client.register_domain(name="security.crypto", description="Cryptographic security knowledge")
419
+ admin_client.register_domain(name="security.web", description="Web security knowledge")
420
+ admin_client.register_domain(name="research.ml", description="ML research findings")
421
+ admin_client.register_domain(name="engineering.infra", description="Infrastructure knowledge")
422
+
423
+ # Hierarchical domains: register parent first, then children
424
+ admin_client.register_domain(name="security", description="All security knowledge")
425
+ admin_client.register_domain(name="security.vuln_intel", description="Vulnerability intelligence", parent="security")
426
+ ```
427
+
428
+ After this step, these domains are access-controlled. Only agents with explicit clearance can read or write to them.
429
+
430
+ ### Step 5: Generate Agent Identities
431
+
432
+ Create a keypair for each AI agent that will interact with the chain. Each agent is a separate identity.
433
+
434
+ ```python
435
+ # Generate keypairs for all your agents
436
+ designer_agent = AgentIdentity.generate()
437
+ designer_agent.to_file("agents/designer.key")
438
+
439
+ evaluator_agent = AgentIdentity.generate()
440
+ evaluator_agent.to_file("agents/evaluator.key")
441
+
442
+ validator_agent = AgentIdentity.generate()
443
+ validator_agent.to_file("agents/validator.key")
444
+
445
+ orchestrator_agent = AgentIdentity.generate()
446
+ orchestrator_agent.to_file("agents/orchestrator.key")
447
+ ```
448
+
449
+ ### Step 6: Add Agents to Organization + Departments
450
+
451
+ Each agent must be added to the org first, then to their specific department(s).
452
+
453
+ ```python
454
+ # Add agents to org with clearance level
455
+ admin_client.add_org_member(org_id, designer_agent.agent_id, clearance=2, role="member")
456
+ admin_client.add_org_member(org_id, evaluator_agent.agent_id, clearance=2, role="member")
457
+ admin_client.add_org_member(org_id, validator_agent.agent_id, clearance=3, role="member")
458
+ admin_client.add_org_member(org_id, orchestrator_agent.agent_id, clearance=2, role="member")
459
+
460
+ # Assign agents to departments — this determines what domains they can access
461
+ admin_client.add_dept_member(org_id, sec_dept, designer_agent.agent_id, clearance=2)
462
+ admin_client.add_dept_member(org_id, sec_dept, evaluator_agent.agent_id, clearance=2)
463
+ admin_client.add_dept_member(org_id, sec_dept, validator_agent.agent_id, clearance=3)
464
+ admin_client.add_dept_member(org_id, eng_dept, orchestrator_agent.agent_id, clearance=2)
465
+ ```
466
+
467
+ ### Step 7: Agents Can Now Operate
468
+
469
+ Only NOW should agents start submitting and querying memories.
470
+
471
+ ```python
472
+ # Designer agent submits to its department's domain
473
+ designer_client = SageClient(base_url="http://localhost:8080", identity=designer_agent)
474
+ designer_client.propose(
475
+ content="AES-GCM nonce reuse leads to key recovery",
476
+ memory_type="fact",
477
+ domain_tag="security.crypto",
478
+ confidence=0.95,
479
+ )
480
+
481
+ # Evaluator in the SAME department can query it
482
+ evaluator_client = SageClient(base_url="http://localhost:8080", identity=evaluator_agent)
483
+ results = evaluator_client.query(
484
+ embedding=evaluator_client.embed("AES vulnerabilities"),
485
+ domain_tag="security.crypto",
486
+ status_filter="committed",
487
+ )
488
+ # Returns results — evaluator has clearance in the Security department
489
+
490
+ # Orchestrator in ENGINEERING department CANNOT query Security domain
491
+ orchestrator_client = SageClient(base_url="http://localhost:8080", identity=orchestrator_agent)
492
+ results = orchestrator_client.query(
493
+ embedding=orchestrator_client.embed("AES vulnerabilities"),
494
+ domain_tag="security.crypto",
495
+ status_filter="committed",
496
+ )
497
+ # Returns EMPTY — orchestrator is in Engineering, not Security
498
+ ```
499
+
500
+ ### Write-Side Domain Enforcement
501
+
502
+ ```
503
+ ┌─────────────────────────────────────────────┐
504
+ │ (S)AGE ABCI State Machine │
505
+ │ │
506
+ Agent A ──propose()──►│ processMemorySubmit() │
507
+ (dept: security) │ ├─ Ed25519 signature ✓ (on-chain) │
508
+ │ ├─ Domain tag check ? (YOUR app) ◄────┼─── You implement this
509
+ │ └─ Store to BadgerDB + PostgreSQL │
510
+ │ │
511
+ Agent B ──query()────►│ processMemoryQuery() │
512
+ (dept: engineering) │ ├─ Ed25519 signature ✓ (on-chain) │
513
+ │ ├─ Domain access gate ✓ (on-chain) ◄────┼─── Already enforced
514
+ │ └─ Return results (filtered by RBAC) │
515
+ └─────────────────────────────────────────────┘
516
+
517
+ Read-side: ON-CHAIN — consensus-enforced, cannot be bypassed
518
+ Write-side: YOUR APP — implement in ABCI handler or application layer
519
+ ```
520
+
521
+ Read-side access control is enforced on-chain by the ABCI state machine — agents can only query domains they have clearance for. **Write-side enforcement is your responsibility** when building your ABCI application.
522
+
523
+ The base (S)AGE ABCI accepts any `domain_tag` on memory submissions. This is by design — your application defines what domain taxonomy rules to enforce. Without write-side checks, any agent can submit memories tagged to any domain, which **pollutes retrieval** for all downstream consumers.
524
+
525
+ ```
526
+ WITHOUT write-side enforcement:
527
+
528
+ Telemetry Agent ──► domain: "security.analysis" ──► "Status OK, 289s"
529
+ Telemetry Agent ──► domain: "security.analysis" ──► "Status OK, 311s"
530
+ Telemetry Agent ──► domain: "security.analysis" ──► "Status OK, 254s"
531
+ Analyst Agent ──► domain: "security.analysis" ──► "CVE-2026-1234 requires..."
532
+ Telemetry Agent ──► domain: "security.analysis" ──► "Status OK, 304s"
533
+
534
+ Designer queries "security.analysis" (top_k=5) → gets 4 status lines + 1 analysis
535
+ Result: Designer has no useful knowledge. Performance regresses.
536
+
537
+ WITH write-side enforcement:
538
+
539
+ Telemetry Agent ──► domain: "ops.telemetry" ──► "Status OK, 289s" (correct domain)
540
+ Analyst Agent ──► domain: "security.analysis" ──► "CVE-2026-1234 requires..."
541
+
542
+ Designer queries "security.analysis" (top_k=5) → gets 5 curated analyses
543
+ Result: Designer has full institutional knowledge. Performance improves.
544
+ ```
545
+
546
+ **Why this matters:** In a production deployment with 10+ agents, a telemetry agent submitting one-line status updates to the same domain as curated analysis reports will drown out the signal. Semantic search returns the telemetry noise instead of the analysis. The knowledge base degrades silently — no errors, just wrong results.
547
+
548
+ **Pattern 1: ABCI-level enforcement (recommended)**
549
+
550
+ Add a domain-tag check in your `processMemorySubmit` handler:
551
+
552
+ ```go
553
+ func (app *MyApp) processMemorySubmit(parsedTx *tx.ParsedTx, height int64, blockTime time.Time) *abcitypes.ExecTxResult {
554
+ submit := parsedTx.MemorySubmit
555
+ agentID := auth.PublicKeyToAgentID(parsedTx.PublicKey)
556
+
557
+ // Enforce write-side domain access
558
+ hasWriteAccess, err := app.badgerStore.HasAccessMultiOrg(
559
+ submit.DomainTag, agentID, 2, blockTime, // level 2 = write
560
+ )
561
+ if err != nil || !hasWriteAccess {
562
+ return &abcitypes.ExecTxResult{
563
+ Code: 13,
564
+ Log: fmt.Sprintf("agent %s has no write access to domain %s", agentID[:16], submit.DomainTag),
565
+ }
566
+ }
567
+
568
+ // ... proceed with memory creation
569
+ }
570
+ ```
571
+
572
+ **Pattern 2: Application-layer gatekeeper**
573
+
574
+ If you prefer flexibility over strictness, enforce domain tagging in your orchestrator/CEO agent before submission reaches the chain:
575
+
576
+ ```python
577
+ # CEO validates domain tag before forwarding to SAGE
578
+ AGENT_DOMAIN_MAP = {
579
+ "designer": ["design.generation", "design.patterns"],
580
+ "evaluator": ["evaluation.calibration", "evaluation.hardening"],
581
+ "red_team_auditor": ["red_team.verification"],
582
+ "solution_verifier": ["red_team.solver"], # NOT red_team.verification!
583
+ "quality": ["quality.scoring", "quality.testing"],
584
+ }
585
+
586
+ def validate_submission(agent_name: str, domain_tag: str) -> bool:
587
+ """Check if agent is allowed to write to this domain."""
588
+ allowed = AGENT_DOMAIN_MAP.get(agent_name, [])
589
+ return any(domain_tag.startswith(prefix) for prefix in allowed)
590
+ ```
591
+
592
+ **Pattern 3: Domain prefix convention**
593
+
594
+ Use a naming convention that maps departments to domain prefixes: `{dept}.{subdomain}.{category}`. Then validate that the submitting agent's department matches the domain prefix:
595
+
596
+ ```python
597
+ # Agent in "red_team" dept can only write to "red_team.*" domains
598
+ agent_dept = get_agent_department(agent_id) # from on-chain RBAC
599
+ domain_prefix = domain_tag.split(".")[0]
600
+ if agent_dept != domain_prefix:
601
+ raise ValueError(f"Agent in {agent_dept} cannot write to {domain_tag}")
602
+ ```
603
+
604
+ **Choose based on your threat model:**
605
+ - Pattern 1 (ABCI): Strongest — consensus-enforced, cannot be bypassed
606
+ - Pattern 2 (Gatekeeper): Flexible — easy to update rules without chain changes
607
+ - Pattern 3 (Convention): Lightweight — works without modifying the ABCI app
608
+
609
+ ### Setup Checklist
610
+
611
+ Run through this before any agent submits its first memory:
612
+
613
+ - [ ] Chain deployed and healthy (`make init && make up && make status`)
614
+ - [ ] Org admin keypair generated and backed up securely
615
+ - [ ] Organization registered on-chain
616
+ - [ ] ALL departments created (you can add more later, but plan ahead)
617
+ - [ ] ALL domains registered (unregistered domains have NO access control)
618
+ - [ ] Every agent has a unique Ed25519 keypair
619
+ - [ ] Every agent added to the organization with correct clearance level
620
+ - [ ] Every agent assigned to their department(s)
621
+ - [ ] Write-side domain enforcement implemented (ABCI, gatekeeper, or convention)
622
+ - [ ] Domain taxonomy reviewed — each agent type writes to a distinct domain prefix
623
+ - [ ] Federation agreements established (if cross-org access needed)
624
+ - [ ] Test: submit a memory from Agent A, verify Agent B in same dept can query it
625
+ - [ ] Test: verify Agent C in a different dept CANNOT query it
626
+ - [ ] Test: verify Agent A CANNOT write to Agent C's domain (write-side enforcement)
627
+
628
+ ### Development Mode (No RBAC)
629
+
630
+ For local development and testing only, agents can skip the full hierarchy. Any agent with an Ed25519 keypair can immediately submit and query unregistered domains:
631
+
632
+ ```python
633
+ from sage_sdk import SageClient, AgentIdentity
634
+
635
+ # Generate keypair — that's it, you're onboarded
636
+ identity = AgentIdentity.generate()
637
+ client = SageClient(base_url="http://localhost:8080", identity=identity)
638
+
639
+ # Submit to any unregistered domain immediately — no access control
640
+ client.propose(
641
+ content="Dev mode observation",
642
+ memory_type="observation",
643
+ domain_tag="testing", # unregistered domain = open access
644
+ confidence=0.8,
645
+ )
646
+ ```
647
+
648
+ **WARNING:** Dev mode is for local testing only. In production, unregistered domains are a security gap — any agent can read and write to them.
649
+
650
+ ### Cross-Organization Federation
651
+
652
+ Federation allows controlled data sharing between separate organizations. Access is scoped by department and clearance level.
653
+
654
+ ```python
655
+ # --- Org A admin proposes federation ---
656
+ fed = admin_client_a.propose_federation(
657
+ target_org_id=org_b_id,
658
+ allowed_depts=["Engineering"], # Only Org B's Engineering dept gets access
659
+ max_clearance=2, # Cap at Confidential (won't see Secret/Top Secret)
660
+ requires_approval=True, # Org B must explicitly approve
661
+ )
662
+
663
+ # --- Org B admin approves ---
664
+ # Look up the federation ID (generated on-chain)
665
+ feds = admin_client_b.list_federations(org_b_id)
666
+ fed_id = feds[0]["federation_id"]
667
+ admin_client_b.approve_federation(fed_id)
668
+
669
+ # Now: Org B agents in "Engineering" dept can query Org A's data
670
+ # up to Confidential clearance level
671
+ # Org B agents in "Research" dept still CANNOT see Org A's data
672
+
673
+ # --- Revoke when partnership ends ---
674
+ admin_client_a.revoke_federation(fed_id, reason="Partnership ended")
675
+ ```
676
+
677
+ **Federation rules:**
678
+ - Both org admins must agree (propose + approve)
679
+ - `allowed_depts` restricts which departments in the TARGET org can access your data
680
+ - `max_clearance` caps the clearance level — even if an agent has clearance 4, federation cap applies
681
+ - Revocation is immediate and on-chain
682
+ - Omit `allowed_depts` to allow all departments (use with caution)
683
+
684
+ ### Organization API Reference
685
+
686
+ | Method | Endpoint | SDK Method |
687
+ |--------|----------|------------|
688
+ | `POST` | `/v1/org/register` | `register_org(name, description)` |
689
+ | `GET` | `/v1/org/{org_id}` | `get_org(org_id)` |
690
+ | `POST` | `/v1/org/{org_id}/member` | `add_org_member(org_id, agent_id, clearance, role)` |
691
+ | `DELETE` | `/v1/org/{org_id}/member/{agent_id}` | `remove_org_member(org_id, agent_id)` |
692
+ | `POST` | `/v1/org/{org_id}/clearance` | `set_org_clearance(org_id, agent_id, clearance)` |
693
+ | `GET` | `/v1/org/{org_id}/members` | `list_org_members(org_id)` |
694
+
695
+ ### Department API Reference
696
+
697
+ | Method | Endpoint | SDK Method |
698
+ |--------|----------|------------|
699
+ | `POST` | `/v1/org/{org_id}/dept` | `register_dept(org_id, name, description, parent_dept)` |
700
+ | `GET` | `/v1/org/{org_id}/dept/{dept_id}` | `get_dept(org_id, dept_id)` |
701
+ | `GET` | `/v1/org/{org_id}/depts` | `list_depts(org_id)` |
702
+ | `POST` | `/v1/org/{org_id}/dept/{dept_id}/member` | `add_dept_member(org_id, dept_id, agent_id, clearance, role)` |
703
+ | `DELETE` | `/v1/org/{org_id}/dept/{dept_id}/member/{agent_id}` | `remove_dept_member(org_id, dept_id, agent_id)` |
704
+ | `GET` | `/v1/org/{org_id}/dept/{dept_id}/members` | `list_dept_members(org_id, dept_id)` |
705
+
706
+ ### Federation API Reference
707
+
708
+ | Method | Endpoint | SDK Method |
709
+ |--------|----------|------------|
710
+ | `POST` | `/v1/federation/propose` | `propose_federation(target_org_id, allowed_depts, max_clearance)` |
711
+ | `POST` | `/v1/federation/{fed_id}/approve` | `approve_federation(fed_id)` |
712
+ | `POST` | `/v1/federation/{fed_id}/revoke` | `revoke_federation(fed_id, reason)` |
713
+ | `GET` | `/v1/federation/{fed_id}` | `get_federation(fed_id)` |
714
+ | `GET` | `/v1/federation/active/{org_id}` | `list_federations(org_id)` |
715
+
716
+ ### Domain & Access API Reference
717
+
718
+ | Method | Endpoint | SDK Method |
719
+ |--------|----------|------------|
720
+ | `POST` | `/v1/domain/register` | `register_domain(name, description, parent)` |
721
+ | `GET` | `/v1/domain/{name}` | `get_domain(name)` |
722
+ | `POST` | `/v1/access/request` | `request_access(domain, justification, level)` |
723
+ | `POST` | `/v1/access/grant` | `grant_access(grantee_id, domain, level, expires_at)` |
724
+ | `POST` | `/v1/access/revoke` | `revoke_access(grantee_id, domain, reason)` |
725
+ | `GET` | `/v1/access/grants/{agent_id}` | `list_grants(agent_id)` |
726
+
727
+ ### Clearance Levels
728
+
729
+ | Level | Name | Description |
730
+ |-------|------|-------------|
731
+ | 0 | Public | No registration needed |
732
+ | 1 | Internal | Default for registered domains |
733
+ | 2 | Confidential | Restricted access |
734
+ | 3 | Secret | High-security data |
735
+ | 4 | Top Secret | Maximum restriction |
736
+
737
+ ### Key Rules
738
+
739
+ - **Setup order matters.** Register org → departments → domains → agents BEFORE submitting memories.
740
+ - **The org admin is permanent.** The keypair that registered the org has irrevocable admin authority.
741
+ - **Unregistered domains are open.** Any agent can read/write — register all production domains.
742
+ - **Read-side RBAC is on-chain. Write-side RBAC is your responsibility.** The base ABCI enforces query access but accepts any domain tag on submissions. Implement write-side checks in your ABCI app or application layer (see "Write-Side Domain Enforcement" above).
743
+ - **Domain taxonomy determines retrieval quality.** Agents writing to the wrong domain silently degrades search results for all consumers. This is the most common source of knowledge base pollution.
744
+ - **Memories are permanent.** On-chain data cannot be retroactively access-controlled.
745
+ - **Department boundaries are enforced.** Agents in Dept A cannot see Dept B's memories.
746
+ - **Federation is opt-in.** Both orgs must agree. Scoped by department and clearance cap.
747
+ - **An agent always sees its own memories.** Regardless of RBAC, the submitter can always read back.
748
+ - **All operations are on-chain.** BFT consensus ensures no single node can bypass access controls.
749
+
750
+ ## Async Client
751
+
752
+ For async/concurrent workloads, use `AsyncSageClient`:
753
+
754
+ ```python
755
+ import asyncio
756
+ from sage_sdk import AsyncSageClient, AgentIdentity
757
+
758
+ async def main():
759
+ identity = AgentIdentity.generate()
760
+ async with AsyncSageClient(base_url="http://localhost:8080", identity=identity) as client:
761
+ # Submit a memory
762
+ result = await client.propose(
763
+ content="Async observation",
764
+ memory_type="observation",
765
+ domain_tag="testing",
766
+ confidence=0.75,
767
+ )
768
+
769
+ # Run concurrent queries
770
+ results = await asyncio.gather(
771
+ client.query(embedding=[0.1] * 768, domain_tag="security"),
772
+ client.query(embedding=[0.2] * 768, domain_tag="testing"),
773
+ client.query(embedding=[0.3] * 768, domain_tag="crypto"),
774
+ )
775
+ for r in results:
776
+ print(f"Found {r.total_count} memories")
777
+
778
+ asyncio.run(main())
779
+ ```
780
+
781
+ The async client has the same methods as `SageClient`, all returning awaitables.
782
+
783
+ ## Models
784
+
785
+ ### MemoryType
786
+
787
+ ```python
788
+ from sage_sdk.models import MemoryType
789
+
790
+ MemoryType.fact # Verified factual knowledge
791
+ MemoryType.observation # Agent-observed data
792
+ MemoryType.inference # Derived conclusion
793
+ ```
794
+
795
+ ### MemoryStatus
796
+
797
+ ```python
798
+ from sage_sdk.models import MemoryStatus
799
+
800
+ MemoryStatus.proposed # Awaiting validation
801
+ MemoryStatus.validated # Passed quorum vote
802
+ MemoryStatus.committed # Finalized on-chain
803
+ MemoryStatus.challenged # Under dispute
804
+ MemoryStatus.deprecated # Superseded or invalidated
805
+ ```
806
+
807
+ ### MemoryRecord
808
+
809
+ Returned by `get_memory()` and in query results:
810
+
811
+ | Field | Type | Description |
812
+ |-------|------|-------------|
813
+ | `memory_id` | `str` | Unique identifier |
814
+ | `submitting_agent` | `str` | Agent public key (hex) |
815
+ | `content` | `str` | Natural language content |
816
+ | `content_hash` | `str` | SHA-256 of content |
817
+ | `memory_type` | `MemoryType` | fact, observation, inference |
818
+ | `domain_tag` | `str` | Domain classification |
819
+ | `confidence_score` | `float` | 0.0 - 1.0 |
820
+ | `status` | `MemoryStatus` | Lifecycle state |
821
+ | `created_at` | `datetime` | Submission timestamp |
822
+ | `similarity_score` | `float \| None` | Populated in query results |
823
+
824
+ ### KnowledgeTriple
825
+
826
+ Structured knowledge for the knowledge graph:
827
+
828
+ ```python
829
+ from sage_sdk.models import KnowledgeTriple
830
+
831
+ triple = KnowledgeTriple(
832
+ subject="SQL injection",
833
+ predicate="mitigated_by",
834
+ object_="parameterized queries", # Note: object_ (Python keyword)
835
+ )
836
+ ```
837
+
838
+ Serializes to `{"subject": "...", "predicate": "...", "object": "..."}` via the `object` alias.
839
+
840
+ ### AgentProfile
841
+
842
+ Returned by `get_profile()`:
843
+
844
+ | Field | Type | Description |
845
+ |-------|------|-------------|
846
+ | `agent_id` | `str` | Agent public key (hex) |
847
+ | `poe_weight` | `float` | Current Proof of Experience weight |
848
+ | `vote_count` | `int` | Total votes cast |
849
+
850
+ ## Error Handling
851
+
852
+ The SDK raises typed exceptions for API errors following RFC 7807 Problem Details:
853
+
854
+ ```python
855
+ from sage_sdk.exceptions import (
856
+ SageError, # Base exception
857
+ SageAPIError, # Any API error (has status_code, detail)
858
+ SageAuthError, # 401/403 authentication failure
859
+ SageNotFoundError, # 404 resource not found
860
+ SageValidationError, # 422 validation error
861
+ )
862
+
863
+ try:
864
+ memory = client.get_memory("nonexistent-id")
865
+ except SageNotFoundError as e:
866
+ print(f"Not found: {e.detail}")
867
+ except SageAuthError as e:
868
+ print(f"Auth failed: {e}")
869
+ except SageAPIError as e:
870
+ print(f"API error {e.status_code}: {e.detail}")
871
+ ```
872
+
873
+ ## Configuration
874
+
875
+ ```python
876
+ from sage_sdk import SageClient, AgentIdentity
877
+
878
+ identity = AgentIdentity.from_file("agent.key")
879
+
880
+ client = SageClient(
881
+ base_url="http://localhost:8080", # SAGE node URL
882
+ identity=identity,
883
+ timeout=30.0, # Request timeout in seconds (default: 30)
884
+ )
885
+
886
+ # Use as context manager for automatic cleanup
887
+ with SageClient(base_url="http://localhost:8080", identity=identity) as client:
888
+ profile = client.get_profile()
889
+ ```
890
+
891
+ ## Embeddings
892
+
893
+ SAGE uses 768-dimensional vectors (Ollama `nomic-embed-text` model). You can generate embeddings in three ways:
894
+
895
+ ### 1. Direct Ollama (recommended for local agents)
896
+
897
+ ```python
898
+ import httpx
899
+
900
+ resp = httpx.post(
901
+ "http://localhost:11434/api/embed",
902
+ json={"model": "nomic-embed-text", "input": "your text here"},
903
+ timeout=30.0,
904
+ )
905
+ embedding = resp.json()["embeddings"][0] # 768-dim float list
906
+ ```
907
+
908
+ ### 2. SAGE Embed Endpoint (for remote agents without local Ollama)
909
+
910
+ ```python
911
+ # Uses the SAGE network's Ollama instance via authenticated REST endpoint
912
+ result = client.embed("your text here")
913
+ embedding = result["embedding"] # 768-dim float list
914
+ ```
915
+
916
+ **Server endpoint:** `POST /v1/embed`
917
+
918
+ ### 3. Hash Embedding (testing/fallback only)
919
+
920
+ Deterministic SHA-256 pseudo-embedding. Not semantic — only matches near-identical text. Useful for testing without Ollama.
921
+
922
+ ```python
923
+ import hashlib, struct
924
+
925
+ def hash_embed(text: str, dim: int = 768) -> list[float]:
926
+ rounds = (dim * 4 + 31) // 32
927
+ raw = b""
928
+ current = text.encode("utf-8")
929
+ for i in range(rounds):
930
+ current = hashlib.sha256(current + struct.pack(">I", i)).digest()
931
+ raw += current
932
+ return [(struct.unpack(">I", raw[j*4:j*4+4])[0] / 2147483647.5) - 1.0 for j in range(dim)]
933
+ ```
934
+
935
+ ## Development
936
+
937
+ ```bash
938
+ # Install with dev dependencies
939
+ pip install -e ".[dev]"
940
+
941
+ # Run tests
942
+ python -m pytest tests/ -v
943
+
944
+ # Run async tests
945
+ python -m pytest tests/test_async_client.py -v
946
+ ```
947
+
948
+ ## License
949
+
950
+ See the project root LICENSE file.