@contextableai/openclaw-memory-rebac 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,464 @@
1
+ # @contextableai/openclaw-memory-rebac
2
+
3
+ Two-layer memory plugin for OpenClaw: **SpiceDB** for authorization, **pluggable backends** for knowledge storage.
4
+
5
+ Agents remember conversations as structured knowledge. SpiceDB enforces who can read and write which memories — authorization lives at the data layer, not in prompts. The backend is swappable: start with Graphiti's knowledge graph today, add new storage engines tomorrow.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ ┌──────────────────────────────────────────────────┐
11
+ │ OpenClaw Agent │
12
+ │ │
13
+ │ memory_recall ──► SpiceDB ──► Backend Search │
14
+ │ memory_store ──► SpiceDB ──► Backend Write │
15
+ │ memory_forget ──► SpiceDB ──► Backend Delete │
16
+ │ auto-recall ──► SpiceDB ──► Backend Search │
17
+ │ auto-capture ──► SpiceDB ──► Backend Write │
18
+ └──────────────────────────────────────────────────┘
19
+ │ │
20
+ ┌────▼────┐ ┌─────▼─────┐
21
+ │ SpiceDB │ │ Backend │
22
+ │ (authz) │ │ (storage) │
23
+ └─────────┘ └───────────┘
24
+ ```
25
+
26
+ **SpiceDB** determines which `group_id`s a subject (agent or person) can access. The **backend** stores and searches memories scoped to those groups. Authorization is enforced before any read or write reaches the backend.
27
+
28
+ ### Why Two Layers?
29
+
30
+ Most memory systems bundle authorization with storage — you get dataset isolation, but it's tied to the storage engine's auth model. That creates conflicts when you need external authorization (like SpiceDB) or want to swap backends without re-implementing access control.
31
+
32
+ openclaw-memory-rebac separates these concerns:
33
+ - **SpiceDB** owns the authorization model (relationships, permissions, consistency)
34
+ - **Backends** own the storage model (indexing, search, extraction)
35
+ - The plugin orchestrates both — authorization check first, then backend operation
36
+
37
+ This means you can change your storage engine without touching authorization, and vice versa.
38
+
39
+ ## Backends
40
+
41
+ ### Graphiti (default)
42
+
43
+ [Graphiti](https://github.com/getzep/graphiti) builds a knowledge graph from conversations. It extracts entities, facts, and relationships, storing them in Neo4j for structured retrieval.
44
+
45
+ - **Storage**: Neo4j graph database
46
+ - **Transport**: Direct REST API to Graphiti FastAPI server
47
+ - **Extraction**: LLM-powered entity and relationship extraction (~300 embedding calls per episode)
48
+ - **Search**: Dual-mode — searches both nodes (entities) and facts (relationships) in parallel
49
+ - **Docker image**: Custom image (`docker/graphiti/`) with per-component LLM/embedder/reranker configuration, BGE reranker support, and runtime patches for local-model compatibility
50
+ - **Best for**: Rich entity-relationship extraction, structured knowledge
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ openclaw plugins install @contextableai/openclaw-memory-rebac
56
+ ```
57
+
58
+ Or with npm:
59
+
60
+ ```bash
61
+ npm install @contextableai/openclaw-memory-rebac
62
+ ```
63
+
64
+ Then restart the gateway. On first start, the plugin automatically:
65
+ - Writes the SpiceDB authorization schema (if not already present)
66
+ - Creates group membership for the configured agent in the default group
67
+
68
+ ### Prerequisites
69
+
70
+ - [Docker](https://docs.docker.com/get-docker/) and Docker Compose
71
+ - A running LLM endpoint (Graphiti uses an LLM for entity extraction and embeddings)
72
+
73
+ ### 1. Start Infrastructure
74
+
75
+ ```bash
76
+ cd docker
77
+ cp graphiti/.env.example graphiti/.env
78
+ # Edit graphiti/.env — set your LLM endpoint and API key
79
+ docker compose up -d
80
+ ```
81
+
82
+ This starts the full stack:
83
+ - **Neo4j** on port 7687 (graph database, browser on port 7474)
84
+ - **Graphiti** on port 8000 (FastAPI REST server)
85
+ - **PostgreSQL** on port 5432 (persistent datastore for SpiceDB)
86
+ - **SpiceDB** on port 50051 (authorization engine)
87
+
88
+ ### 2. Restart the Gateway
89
+
90
+ ```bash
91
+ openclaw gateway restart
92
+ ```
93
+
94
+ The plugin auto-initializes on startup — no manual `schema-write` or `add-member` needed for basic use.
95
+
96
+ ### 3. (Optional) Add More Group Members
97
+
98
+ ```bash
99
+ rebac-mem add-member family mom --type person
100
+ rebac-mem add-member family dad --type person
101
+ ```
102
+
103
+ ## Tools
104
+
105
+ The plugin registers four tools available to the agent:
106
+
107
+ ### memory_recall
108
+
109
+ Search memories across all authorized groups. Returns entities and facts the current subject is permitted to see.
110
+
111
+ | Parameter | Type | Default | Description |
112
+ |-----------|------|---------|-------------|
113
+ | `query` | string | *required* | Search query |
114
+ | `limit` | number | 10 | Max results |
115
+ | `scope` | string | `"all"` | `"session"`, `"long-term"`, or `"all"` |
116
+
117
+ Searches both nodes and facts across all authorized groups in parallel, then deduplicates and ranks by recency.
118
+
119
+ ### memory_store
120
+
121
+ Save information to the backend. The storage engine handles extraction and indexing.
122
+
123
+ | Parameter | Type | Default | Description |
124
+ |-----------|------|---------|-------------|
125
+ | `content` | string | *required* | Information to remember |
126
+ | `source_description` | string | `"conversation"` | Context about the source |
127
+ | `involves` | string[] | `[]` | Person/agent IDs involved |
128
+ | `group_id` | string | configured default | Target group for this memory |
129
+ | `longTerm` | boolean | `true` | `false` stores to the current session group |
130
+
131
+ Write authorization is enforced before storing:
132
+ - **Own session groups** auto-create membership (the agent gets exclusive access)
133
+ - **All other groups** require `contribute` permission in SpiceDB
134
+
135
+ ### memory_forget
136
+
137
+ Delete a memory fragment. Requires `delete` permission (only the subject who stored the memory can delete it).
138
+
139
+ | Parameter | Type | Description |
140
+ |-----------|------|-------------|
141
+ | `episode_id` | string | Fragment UUID to delete |
142
+
143
+ ### memory_status
144
+
145
+ Check the health of the backend and SpiceDB services. No parameters.
146
+
147
+ ## Automatic Behaviors
148
+
149
+ ### Auto-Recall
150
+
151
+ When enabled (default: `true`), the plugin searches relevant memories before each agent turn and injects them into the conversation context as `<relevant-memories>` blocks.
152
+
153
+ - Searches up to 5 long-term memories and 3 session memories per turn
154
+ - Deduplicates session results against long-term results
155
+ - Only triggers when the user prompt is at least 5 characters
156
+
157
+ ### Auto-Capture
158
+
159
+ When enabled (default: `true`), the plugin captures the last N messages from each completed agent turn and stores them as a batch episode.
160
+
161
+ - Captures up to `maxCaptureMessages` messages (default: 10)
162
+ - Stores to the current session group by default
163
+ - Skips messages shorter than 5 characters and injected context blocks
164
+ - Uses custom extraction instructions for entity/fact extraction
165
+
166
+ ## Authorization Model
167
+
168
+ The SpiceDB schema defines four object types:
169
+
170
+ ```
171
+ definition person {}
172
+
173
+ definition agent {
174
+ relation owner: person
175
+ permission act_as = owner
176
+ }
177
+
178
+ definition group {
179
+ relation member: person | agent
180
+ permission access = member
181
+ permission contribute = member
182
+ }
183
+
184
+ definition memory_fragment {
185
+ relation source_group: group
186
+ relation involves: person | agent
187
+ relation shared_by: person | agent
188
+
189
+ permission view = involves + shared_by + source_group->access
190
+ permission delete = shared_by
191
+ }
192
+ ```
193
+
194
+ ### Groups
195
+
196
+ Groups organize memories and control access. A subject must be a **member** of a group to read (`access`) or write (`contribute`) to it.
197
+
198
+ Membership is managed via the CLI (`rebac-mem add-member`) or programmatically via `ensureGroupMembership()`.
199
+
200
+ ### Memory Fragments
201
+
202
+ Each stored memory creates a `memory_fragment` with three relationships:
203
+ - **source_group** — which group the memory belongs to
204
+ - **shared_by** — who stored the memory (can delete it)
205
+ - **involves** — people/agents mentioned in the memory (can view it)
206
+
207
+ View permission is granted to anyone who is directly involved, shared the memory, or has access to the source group. Delete permission is restricted to the subject who shared (stored) the memory.
208
+
209
+ ### Session Groups
210
+
211
+ Session groups (`session-<id>`) provide per-conversation memory isolation:
212
+ - The agent that creates a session automatically gets exclusive membership
213
+ - Other agents cannot read or write to foreign session groups without explicit membership
214
+ - Session memories are searchable within the session scope and are deduplicated against long-term memories
215
+
216
+ ## Configuration Reference
217
+
218
+ | Key | Type | Default | Description |
219
+ |-----|------|---------|-------------|
220
+ | `backend` | string | `graphiti` | Storage backend (`graphiti`) |
221
+ | `spicedb.endpoint` | string | `localhost:50051` | SpiceDB gRPC endpoint |
222
+ | `spicedb.token` | string | *required* | SpiceDB pre-shared key (supports `${ENV_VAR}`) |
223
+ | `spicedb.insecure` | boolean | `true` | Allow insecure gRPC (for localhost dev) |
224
+ | `graphiti.endpoint` | string | `http://localhost:8000` | Graphiti REST server URL |
225
+ | `graphiti.defaultGroupId` | string | `main` | Default group for memory storage |
226
+ | `graphiti.uuidPollIntervalMs` | integer | `3000` | Polling interval for resolving episode UUIDs (ms) |
227
+ | `graphiti.uuidPollMaxAttempts` | integer | `60` | Max polling attempts (total timeout = interval x attempts) |
228
+ | `graphiti.requestTimeoutMs` | integer | `30000` | HTTP request timeout for Graphiti REST calls (ms) |
229
+ | `subjectType` | string | `agent` | SpiceDB subject type (`agent` or `person`) |
230
+ | `subjectId` | string | `default` | SpiceDB subject ID (supports `${ENV_VAR}`) |
231
+ | `autoCapture` | boolean | `true` | Auto-capture conversations |
232
+ | `autoRecall` | boolean | `true` | Auto-inject relevant memories |
233
+ | `customInstructions` | string | *(see below)* | Custom extraction instructions |
234
+ | `maxCaptureMessages` | integer | `10` | Max messages per auto-capture batch (1-50) |
235
+
236
+ ### Default Custom Instructions
237
+
238
+ When not overridden, the plugin uses these extraction instructions:
239
+
240
+ ```
241
+ Extract key facts about:
242
+ - Identity: names, roles, titles, contact info
243
+ - Preferences: likes, dislikes, preferred tools/methods
244
+ - Goals: objectives, plans, deadlines
245
+ - Relationships: connections between people, teams, organizations
246
+ - Decisions: choices made, reasoning, outcomes
247
+ - Routines: habits, schedules, recurring patterns
248
+ Do not extract: greetings, filler, meta-commentary about the conversation itself.
249
+ ```
250
+
251
+ ### Environment Variable Interpolation
252
+
253
+ String values in the config support `${ENV_VAR}` syntax:
254
+
255
+ ```json
256
+ {
257
+ "spicedb": {
258
+ "token": "${SPICEDB_TOKEN}"
259
+ },
260
+ "subjectId": "${OPENCLAW_AGENT_ID}"
261
+ }
262
+ ```
263
+
264
+ ## CLI Commands
265
+
266
+ All commands are under `rebac-mem`:
267
+
268
+ | Command | Description |
269
+ |---------|-------------|
270
+ | `rebac-mem search <query>` | Search memories with authorization. Options: `--limit`, `--scope` |
271
+ | `rebac-mem status` | Check SpiceDB + backend connectivity |
272
+ | `rebac-mem schema-write` | Write/update the SpiceDB authorization schema |
273
+ | `rebac-mem groups` | List authorized groups for the current subject |
274
+ | `rebac-mem add-member <group-id> <subject-id>` | Add a subject to a group. Options: `--type` |
275
+ | `rebac-mem import` | Import workspace markdown files. Options: `--workspace`, `--include-sessions`, `--group`, `--dry-run` |
276
+
277
+ Backend-specific commands (Graphiti):
278
+
279
+ | Command | Description |
280
+ |---------|-------------|
281
+ | `rebac-mem episodes` | List recent episodes. Options: `--last`, `--group` |
282
+ | `rebac-mem fact <uuid>` | Show details of a specific fact (entity edge) |
283
+ | `rebac-mem clear-graph <group-id>` | Delete all data in a group |
284
+
285
+ ### Standalone CLI
286
+
287
+ For development and testing, commands can be run directly without a full OpenClaw gateway:
288
+
289
+ ```bash
290
+ # Via npm script
291
+ npm run cli -- status
292
+ npm run cli -- search "some query"
293
+ npm run cli -- import --workspace /path/to/files --dry-run
294
+
295
+ # Via npx
296
+ npx tsx bin/rebac-mem.ts status
297
+ ```
298
+
299
+ **Configuration** is loaded from (highest priority first):
300
+
301
+ 1. **Environment variables** — `SPICEDB_TOKEN`, `SPICEDB_ENDPOINT`, `GRAPHITI_ENDPOINT`, etc.
302
+ 2. **JSON config file** — `--config <path>`, or auto-discovered from `./rebac-mem.config.json` or `~/.config/rebac-mem/config.json`
303
+ 3. **Built-in defaults** (see [Configuration Reference](#configuration-reference))
304
+
305
+ | Environment Variable | Config Equivalent |
306
+ |---------------------|-------------------|
307
+ | `SPICEDB_TOKEN` | `spicedb.token` |
308
+ | `SPICEDB_ENDPOINT` | `spicedb.endpoint` |
309
+ | `GRAPHITI_ENDPOINT` | `graphiti.endpoint` |
310
+ | `REBAC_MEM_DEFAULT_GROUP_ID` | `graphiti.defaultGroupId` |
311
+ | `REBAC_MEM_SUBJECT_TYPE` | `subjectType` |
312
+ | `REBAC_MEM_SUBJECT_ID` | `subjectId` |
313
+ | `REBAC_MEM_BACKEND` | `backend` |
314
+
315
+ ## OpenClaw Integration
316
+
317
+ ### Selecting the Memory Slot
318
+
319
+ OpenClaw has an exclusive `memory` slot — only one memory plugin is active at a time:
320
+
321
+ ```json
322
+ {
323
+ "plugins": {
324
+ "slots": {
325
+ "memory": "openclaw-memory-rebac"
326
+ },
327
+ "entries": {
328
+ "openclaw-memory-rebac": {
329
+ "enabled": true,
330
+ "config": {
331
+ "backend": "graphiti",
332
+ "spicedb": {
333
+ "endpoint": "localhost:50051",
334
+ "token": "dev_token",
335
+ "insecure": true
336
+ },
337
+ "graphiti": {
338
+ "endpoint": "http://localhost:8000",
339
+ "defaultGroupId": "main"
340
+ },
341
+ "subjectType": "agent",
342
+ "subjectId": "my-agent",
343
+ "autoCapture": true,
344
+ "autoRecall": true
345
+ }
346
+ }
347
+ }
348
+ }
349
+ }
350
+ ```
351
+
352
+ The plugin must be discoverable — either symlinked into `extensions/openclaw-memory-rebac` in the OpenClaw installation, or loaded via `plugins.load.paths`.
353
+
354
+ ### Migrating from openclaw-memory-graphiti
355
+
356
+ openclaw-memory-rebac is the successor to openclaw-memory-graphiti. The key difference: authorization and storage are decoupled, and the backend is pluggable. To migrate:
357
+
358
+ 1. Disable openclaw-memory-graphiti in `~/.openclaw/openclaw.json`
359
+ 2. Enable openclaw-memory-rebac with the same SpiceDB and Graphiti endpoints
360
+ 3. Existing memories in Graphiti are preserved — no data migration needed
361
+
362
+ The `import` command migrates workspace markdown files into the backend:
363
+
364
+ ```bash
365
+ # Preview what will be imported
366
+ rebac-mem import --dry-run
367
+
368
+ # Import workspace files
369
+ rebac-mem import
370
+
371
+ # Also import session transcripts
372
+ rebac-mem import --include-sessions
373
+ ```
374
+
375
+ ## Docker Compose
376
+
377
+ The `docker/` directory contains a modular Docker Compose stack. The top-level `docker/docker-compose.yml` includes both sub-stacks:
378
+
379
+ ```bash
380
+ # Start the full stack (Graphiti + SpiceDB)
381
+ cd docker
382
+ docker compose up -d
383
+ ```
384
+
385
+ ### Graphiti Stack (`docker/graphiti/`)
386
+
387
+ | Service | Port | Description |
388
+ |---------|------|-------------|
389
+ | `neo4j` | 7687, 7474 | Graph database (Bolt protocol) + browser UI |
390
+ | `graphiti` | 8000 | Custom Graphiti FastAPI server (REST) |
391
+
392
+ The custom Docker image extends `zepai/graphiti:latest` with:
393
+ - **`OpenClawGraphiti`** — subclass of base `Graphiti` (bypasses `ZepGraphiti` to properly forward embedder/cross_encoder)
394
+ - **`ExtendedSettings`** — per-component LLM, embedder, and reranker configuration
395
+ - **BGE reranker** — local sentence-transformers model (no API needed)
396
+ - **Runtime patches** — singleton client lifecycle, Neo4j attribute sanitization, resilient AsyncWorker, startup retry with backoff
397
+
398
+ ### SpiceDB Stack (`docker/spicedb/`)
399
+
400
+ | Service | Port | Description |
401
+ |---------|------|-------------|
402
+ | `postgres` | 5432 | SpiceDB backing store (PostgreSQL 16) |
403
+ | `spicedb-migrate` | — | One-shot: runs SpiceDB DB migrations |
404
+ | `spicedb` | 50051, 8080 | Authorization engine (gRPC, HTTP health) |
405
+
406
+ ### Running Stacks Independently
407
+
408
+ ```bash
409
+ # Graphiti only
410
+ cd docker/graphiti && docker compose up -d
411
+
412
+ # SpiceDB only
413
+ cd docker/spicedb && docker compose up -d
414
+ ```
415
+
416
+ ## Development
417
+
418
+ ### Running Tests
419
+
420
+ ```bash
421
+ # Unit tests (no running services required)
422
+ npm test
423
+
424
+ # E2E tests (requires running infrastructure)
425
+ OPENCLAW_LIVE_TEST=1 npm run test:e2e
426
+ ```
427
+
428
+ ### Project Structure
429
+
430
+ ```
431
+ ├── index.ts # Plugin entry: tools, hooks, CLI, service
432
+ ├── backend.ts # MemoryBackend interface (all backends implement this)
433
+ ├── config.ts # Config schema, validation, backend factory
434
+ ├── cli.ts # Shared CLI commands (plugin + standalone)
435
+ ├── search.ts # Multi-group parallel search, dedup, formatting
436
+ ├── authorization.ts # Authorization logic (SpiceDB operations)
437
+ ├── spicedb.ts # SpiceDB gRPC client wrapper
438
+ ├── schema.zed # SpiceDB authorization schema
439
+ ├── openclaw.plugin.json # Plugin manifest
440
+ ├── package.json
441
+ ├── backends/
442
+ │ └── graphiti.ts # Graphiti REST backend implementation
443
+ ├── bin/
444
+ │ └── rebac-mem.ts # Standalone CLI entry point
445
+ ├── docker/
446
+ │ ├── docker-compose.yml # Combined stack (includes both sub-stacks)
447
+ │ ├── graphiti/
448
+ │ │ ├── docker-compose.yml
449
+ │ │ ├── Dockerfile # Custom Graphiti image with patches
450
+ │ │ ├── config_overlay.py # ExtendedSettings (per-component config)
451
+ │ │ ├── graphiti_overlay.py # OpenClawGraphiti class
452
+ │ │ ├── startup.py # Runtime patches and uvicorn launch
453
+ │ │ └── .env.example
454
+ │ └── spicedb/
455
+ │ └── docker-compose.yml
456
+ ├── *.test.ts # Unit tests (96)
457
+ ├── e2e.test.ts # End-to-end tests (15, live services)
458
+ ├── vitest.config.ts # Unit test config
459
+ └── vitest.e2e.config.ts # E2E test config
460
+ ```
461
+
462
+ ## License
463
+
464
+ MIT
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Authorization Logic
3
+ *
4
+ * Bridges SpiceDB and the memory backend by managing:
5
+ * - Looking up which group_ids a subject can access
6
+ * - Writing fragment authorization relationships when memories are stored
7
+ * - Checking delete permissions
8
+ */
9
+
10
+ import type { SpiceDbClient, RelationshipTuple, ConsistencyMode } from "./spicedb.js";
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export type Subject = {
17
+ type: "agent" | "person";
18
+ id: string;
19
+ };
20
+
21
+ export type FragmentRelationships = {
22
+ fragmentId: string;
23
+ groupId: string;
24
+ sharedBy: Subject;
25
+ involves?: Subject[];
26
+ };
27
+
28
+ // ============================================================================
29
+ // Helpers
30
+ // ============================================================================
31
+
32
+ function tokenConsistency(zedToken?: string): ConsistencyMode | undefined {
33
+ return zedToken ? { mode: "at_least_as_fresh", token: zedToken } : undefined;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Authorization Operations
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Look up all group IDs that a subject has access to.
42
+ * Returns group resource IDs from SpiceDB where the subject has the "access" permission.
43
+ */
44
+ export async function lookupAuthorizedGroups(
45
+ spicedb: SpiceDbClient,
46
+ subject: Subject,
47
+ zedToken?: string,
48
+ ): Promise<string[]> {
49
+ return spicedb.lookupResources({
50
+ resourceType: "group",
51
+ permission: "access",
52
+ subjectType: subject.type,
53
+ subjectId: subject.id,
54
+ consistency: tokenConsistency(zedToken),
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Look up all memory fragment IDs that a subject can view.
60
+ * Used for fine-grained post-filtering when needed.
61
+ */
62
+ export async function lookupViewableFragments(
63
+ spicedb: SpiceDbClient,
64
+ subject: Subject,
65
+ zedToken?: string,
66
+ ): Promise<string[]> {
67
+ return spicedb.lookupResources({
68
+ resourceType: "memory_fragment",
69
+ permission: "view",
70
+ subjectType: subject.type,
71
+ subjectId: subject.id,
72
+ consistency: tokenConsistency(zedToken),
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Write authorization relationships for a newly stored memory fragment.
78
+ *
79
+ * Creates:
80
+ * - memory_fragment:<id> #source_group group:<groupId>
81
+ * - memory_fragment:<id> #shared_by <sharedBy>
82
+ * - memory_fragment:<id> #involves <person> (for each involved person)
83
+ */
84
+ export async function writeFragmentRelationships(
85
+ spicedb: SpiceDbClient,
86
+ params: FragmentRelationships,
87
+ ): Promise<string | undefined> {
88
+ const tuples: RelationshipTuple[] = [
89
+ {
90
+ resourceType: "memory_fragment",
91
+ resourceId: params.fragmentId,
92
+ relation: "source_group",
93
+ subjectType: "group",
94
+ subjectId: params.groupId,
95
+ },
96
+ {
97
+ resourceType: "memory_fragment",
98
+ resourceId: params.fragmentId,
99
+ relation: "shared_by",
100
+ subjectType: params.sharedBy.type,
101
+ subjectId: params.sharedBy.id,
102
+ },
103
+ ];
104
+
105
+ if (params.involves) {
106
+ for (const person of params.involves) {
107
+ tuples.push({
108
+ resourceType: "memory_fragment",
109
+ resourceId: params.fragmentId,
110
+ relation: "involves",
111
+ subjectType: person.type,
112
+ subjectId: person.id,
113
+ });
114
+ }
115
+ }
116
+
117
+ return spicedb.writeRelationships(tuples);
118
+ }
119
+
120
+ /**
121
+ * Remove all authorization relationships for a memory fragment.
122
+ * Uses filter-based deletion — no need to know the group, sharer, or involved parties.
123
+ */
124
+ export async function deleteFragmentRelationships(
125
+ spicedb: SpiceDbClient,
126
+ fragmentId: string,
127
+ ): Promise<string | undefined> {
128
+ return spicedb.deleteRelationshipsByFilter({
129
+ resourceType: "memory_fragment",
130
+ resourceId: fragmentId,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Check if a subject has delete permission on a memory fragment.
136
+ */
137
+ export async function canDeleteFragment(
138
+ spicedb: SpiceDbClient,
139
+ subject: Subject,
140
+ fragmentId: string,
141
+ zedToken?: string,
142
+ ): Promise<boolean> {
143
+ return spicedb.checkPermission({
144
+ resourceType: "memory_fragment",
145
+ resourceId: fragmentId,
146
+ permission: "delete",
147
+ subjectType: subject.type,
148
+ subjectId: subject.id,
149
+ consistency: tokenConsistency(zedToken),
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Check if a subject has write (contribute) permission on a group.
155
+ * Used to gate writes to non-session groups — prevents unauthorized memory injection.
156
+ */
157
+ export async function canWriteToGroup(
158
+ spicedb: SpiceDbClient,
159
+ subject: Subject,
160
+ groupId: string,
161
+ zedToken?: string,
162
+ ): Promise<boolean> {
163
+ return spicedb.checkPermission({
164
+ resourceType: "group",
165
+ resourceId: groupId,
166
+ permission: "contribute",
167
+ subjectType: subject.type,
168
+ subjectId: subject.id,
169
+ consistency: tokenConsistency(zedToken),
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Ensure a subject is registered as a member of a group.
175
+ * Idempotent (uses TOUCH operation).
176
+ */
177
+ export async function ensureGroupMembership(
178
+ spicedb: SpiceDbClient,
179
+ groupId: string,
180
+ member: Subject,
181
+ ): Promise<string | undefined> {
182
+ return spicedb.writeRelationships([
183
+ {
184
+ resourceType: "group",
185
+ resourceId: groupId,
186
+ relation: "member",
187
+ subjectType: member.type,
188
+ subjectId: member.id,
189
+ },
190
+ ]);
191
+ }