ccp-protocol 0.4.0__py3-none-any.whl

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.
ccp/stores.py ADDED
@@ -0,0 +1,166 @@
1
+ """Thread-safe stores: grants, manifests, schemas, replay, limits."""
2
+
3
+ import threading
4
+
5
+
6
+ class GrantStore:
7
+ """In-memory local grant store (§9.1)."""
8
+
9
+ def __init__(self, grants=None):
10
+ self._grants = {}
11
+ self._lock = threading.RLock()
12
+ for grant in grants or []:
13
+ self.add(grant)
14
+
15
+ def add(self, grant):
16
+ with self._lock:
17
+ self._grants[grant.id] = grant
18
+ return grant
19
+
20
+ def get(self, grant_id):
21
+ with self._lock:
22
+ return self._grants.get(grant_id)
23
+
24
+ def find(self, requester_key, manifest_digest):
25
+ with self._lock:
26
+ for grant in self._grants.values():
27
+ if (grant.requester_key == requester_key
28
+ and grant.manifest_digest == manifest_digest):
29
+ return grant
30
+ return None
31
+
32
+ def remove(self, grant_id):
33
+ with self._lock:
34
+ return self._grants.pop(grant_id, None)
35
+
36
+ def all(self):
37
+ with self._lock:
38
+ return list(self._grants.values())
39
+
40
+
41
+ class ManifestStore:
42
+ """Approved manifests, keyed by digest."""
43
+
44
+ def __init__(self, manifests=None):
45
+ self._by_digest = {}
46
+ self._lock = threading.RLock()
47
+ for manifest in manifests or []:
48
+ self.add(manifest)
49
+
50
+ def add(self, manifest):
51
+ with self._lock:
52
+ self._by_digest[manifest.digest] = manifest
53
+ return manifest
54
+
55
+ def get(self, digest):
56
+ with self._lock:
57
+ return self._by_digest.get(digest)
58
+
59
+
60
+ class SchemaRegistry:
61
+ """Maps `schema:<...>` identifiers to their schemas (§25.3)."""
62
+
63
+ def __init__(self, schemas=None):
64
+ self._schemas = dict(schemas or {})
65
+ self._lock = threading.RLock()
66
+
67
+ def register(self, schema_id, schema):
68
+ with self._lock:
69
+ self._schemas[schema_id] = schema
70
+
71
+ def get(self, schema_id):
72
+ with self._lock:
73
+ return self._schemas.get(schema_id)
74
+
75
+
76
+ class ReplayStore:
77
+ """Durable replay protection keyed on `request_id` (§10.3 step 8)."""
78
+
79
+ def __init__(self):
80
+ self._seen = set()
81
+ self._lock = threading.Lock()
82
+
83
+ def seen(self, request_id):
84
+ with self._lock:
85
+ return request_id in self._seen
86
+
87
+ def mark(self, request_id):
88
+ with self._lock:
89
+ self._seen.add(request_id)
90
+
91
+ def mark_if_new(self, request_id):
92
+ """Atomically check-and-mark; returns False if already seen."""
93
+ with self._lock:
94
+ if request_id in self._seen:
95
+ return False
96
+ self._seen.add(request_id)
97
+ return True
98
+
99
+ def snapshot(self):
100
+ with self._lock:
101
+ return set(self._seen)
102
+
103
+
104
+ class LimitTracker:
105
+ """Per-grant cumulative counters with atomic reservation (§9.2)."""
106
+
107
+ def __init__(self):
108
+ self._counters = {}
109
+ self._lock = threading.RLock()
110
+
111
+ def try_reserve(self, grant_id, limits):
112
+ """Atomically reserve one unit against every limit, or none."""
113
+ with self._lock:
114
+ counters = self._counters.setdefault(grant_id, {})
115
+ for name, maximum in limits.items():
116
+ if counters.get(name, 0) + 1 > maximum:
117
+ return False
118
+ for name in limits:
119
+ counters[name] = counters.get(name, 0) + 1
120
+ return True
121
+
122
+ def release(self, grant_id, limits):
123
+ with self._lock:
124
+ counters = self._counters.setdefault(grant_id, {})
125
+ for name in limits:
126
+ counters[name] = max(0, counters.get(name, 0) - 1)
127
+
128
+ def commit(self, grant_id, limits):
129
+ """A reservation becomes permanent; no counter change is needed."""
130
+ return None
131
+
132
+ def counters(self, grant_id):
133
+ with self._lock:
134
+ return dict(self._counters.get(grant_id, {}))
135
+
136
+
137
+ class Pending:
138
+ """A confirmation awaiting a trusted decision (§9.5)."""
139
+
140
+ def __init__(self, pending_id, signal, grant, intent_spec,
141
+ state="awaiting_confirmation"):
142
+ self.pending_id = pending_id
143
+ self.signal = signal
144
+ self.grant = grant
145
+ self.intent_spec = intent_spec
146
+ self.state = state
147
+
148
+
149
+ class PendingStore:
150
+ """In-memory pending confirmations; ``take`` resolves at most once."""
151
+
152
+ def __init__(self):
153
+ self._pending = {}
154
+ self._lock = threading.RLock()
155
+
156
+ def put(self, pending):
157
+ with self._lock:
158
+ self._pending[pending.pending_id] = pending
159
+
160
+ def get(self, pending_id):
161
+ with self._lock:
162
+ return self._pending.get(pending_id)
163
+
164
+ def take(self, pending_id):
165
+ with self._lock:
166
+ return self._pending.pop(pending_id, None)
ccp/stores_sqlite.py ADDED
@@ -0,0 +1,442 @@
1
+ """Durable SQLite stores for the CCP runtime.
2
+
3
+ Implements the six store contracts from ``docs/guides/persistence.md`` using
4
+ the Python standard-library ``sqlite3`` module, so no third-party driver is
5
+ required.
6
+
7
+ A single connection is shared by every store, which lets ``":memory:"`` work
8
+ for tests and a file path work across processes. The operations the runtime
9
+ relies on for correctness -- replay insertion, limit reservation, and pending
10
+ confirmation removal -- run as single statements or inside an explicit
11
+ ``BEGIN IMMEDIATE`` transaction.
12
+ """
13
+
14
+ import json
15
+ import threading
16
+
17
+ from .audit import utcnow
18
+ from .model import Grant, Manifest, Signal
19
+ from .stores import Pending
20
+
21
+ SCHEMA = """
22
+ CREATE TABLE IF NOT EXISTS ccp_grants (
23
+ id TEXT PRIMARY KEY,
24
+ requester_key TEXT NOT NULL,
25
+ contact_id TEXT NOT NULL,
26
+ manifest_digest TEXT NOT NULL,
27
+ manifest_version INTEGER NOT NULL,
28
+ intents TEXT NOT NULL,
29
+ limits TEXT NOT NULL,
30
+ created_at TEXT NOT NULL,
31
+ expires_at TEXT NOT NULL,
32
+ confirmation TEXT NOT NULL,
33
+ capabilities TEXT NOT NULL,
34
+ placement TEXT NOT NULL,
35
+ egress TEXT NOT NULL,
36
+ state TEXT NOT NULL,
37
+ state_reason TEXT
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS ccp_grants_lookup
41
+ ON ccp_grants (requester_key, manifest_digest);
42
+
43
+ CREATE TABLE IF NOT EXISTS ccp_manifests (
44
+ digest TEXT PRIMARY KEY,
45
+ document TEXT NOT NULL
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS ccp_replay (
49
+ request_id TEXT PRIMARY KEY,
50
+ seen_at TEXT NOT NULL
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS ccp_limits (
54
+ grant_id TEXT NOT NULL,
55
+ name TEXT NOT NULL,
56
+ value INTEGER NOT NULL CHECK (value >= 0),
57
+ PRIMARY KEY (grant_id, name)
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS ccp_pending (
61
+ id TEXT PRIMARY KEY,
62
+ payload TEXT NOT NULL,
63
+ created_at TEXT NOT NULL
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS ccp_audit (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ at TEXT NOT NULL,
69
+ event TEXT NOT NULL
70
+ );
71
+ """
72
+
73
+ _GRANT_COLUMNS = (
74
+ "id",
75
+ "requester_key",
76
+ "contact_id",
77
+ "manifest_digest",
78
+ "manifest_version",
79
+ "intents",
80
+ "limits",
81
+ "created_at",
82
+ "expires_at",
83
+ "confirmation",
84
+ "capabilities",
85
+ "placement",
86
+ "egress",
87
+ "state",
88
+ "state_reason",
89
+ )
90
+ _JSON_GRANT_FIELDS = frozenset({
91
+ "intents",
92
+ "limits",
93
+ "confirmation",
94
+ "capabilities",
95
+ "placement",
96
+ "egress",
97
+ })
98
+
99
+
100
+ def _dumps(value):
101
+ return json.dumps(value, sort_keys=True, separators=(",", ":"),
102
+ ensure_ascii=False)
103
+
104
+
105
+ def _grant_params(grant):
106
+ data = grant.to_dict()
107
+ return tuple(
108
+ _dumps(data[name]) if name in _JSON_GRANT_FIELDS else data[name]
109
+ for name in _GRANT_COLUMNS
110
+ )
111
+
112
+
113
+ def _grant_from_row(row):
114
+ data = {
115
+ name: (json.loads(value) if name in _JSON_GRANT_FIELDS else value)
116
+ for name, value in zip(_GRANT_COLUMNS, row)
117
+ }
118
+ return Grant.from_dict(data)
119
+
120
+
121
+ def _pending_from_payload(payload, pending_id):
122
+ data = json.loads(payload)
123
+ return Pending(
124
+ pending_id=pending_id,
125
+ signal=Signal.parse(data["signal_document"]),
126
+ grant=Grant.from_dict(data["grant"]),
127
+ intent_spec=data["intent_spec"],
128
+ state=data.get("state", "awaiting_confirmation"),
129
+ )
130
+
131
+
132
+ class _Store:
133
+ """Shared connection, lock, and transaction helper."""
134
+
135
+ def __init__(self, connection, lock):
136
+ self._conn = connection
137
+ self._lock = lock
138
+
139
+ def _transaction(self, function):
140
+ with self._lock:
141
+ self._conn.execute("BEGIN IMMEDIATE")
142
+ try:
143
+ result = function()
144
+ except Exception:
145
+ self._conn.execute("ROLLBACK")
146
+ raise
147
+ self._conn.execute("COMMIT")
148
+ return result
149
+
150
+
151
+ class SqliteGrantStore(_Store):
152
+ """Local grants (§9.1). Revocation is visible to the next lookup."""
153
+
154
+ def __init__(self, connection, lock):
155
+ super().__init__(connection, lock)
156
+ self._columns = ", ".join(_GRANT_COLUMNS)
157
+ self._placeholders = ", ".join("?" for _ in _GRANT_COLUMNS)
158
+
159
+ def add(self, grant):
160
+ with self._lock:
161
+ self._conn.execute(
162
+ "INSERT OR REPLACE INTO ccp_grants (%s) VALUES (%s)"
163
+ % (self._columns, self._placeholders),
164
+ _grant_params(grant),
165
+ )
166
+ return grant
167
+
168
+ def get(self, grant_id):
169
+ with self._lock:
170
+ row = self._conn.execute(
171
+ "SELECT %s FROM ccp_grants WHERE id = ?" % self._columns,
172
+ (grant_id,),
173
+ ).fetchone()
174
+ return _grant_from_row(row) if row is not None else None
175
+
176
+ def find(self, requester_key, manifest_digest):
177
+ with self._lock:
178
+ row = self._conn.execute(
179
+ "SELECT %s FROM ccp_grants "
180
+ "WHERE requester_key = ? AND manifest_digest = ? LIMIT 1"
181
+ % self._columns,
182
+ (requester_key, manifest_digest),
183
+ ).fetchone()
184
+ return _grant_from_row(row) if row is not None else None
185
+
186
+ def remove(self, grant_id):
187
+ def run():
188
+ row = self._conn.execute(
189
+ "SELECT %s FROM ccp_grants WHERE id = ?" % self._columns,
190
+ (grant_id,),
191
+ ).fetchone()
192
+ if row is None:
193
+ return None
194
+ self._conn.execute("DELETE FROM ccp_grants WHERE id = ?",
195
+ (grant_id,))
196
+ return _grant_from_row(row)
197
+
198
+ return self._transaction(run)
199
+
200
+ def all(self):
201
+ with self._lock:
202
+ rows = self._conn.execute(
203
+ "SELECT %s FROM ccp_grants" % self._columns
204
+ ).fetchall()
205
+ return [_grant_from_row(row) for row in rows]
206
+
207
+
208
+ class SqliteManifestStore(_Store):
209
+ """Verified manifests keyed by digest. Immutable and synchronous."""
210
+
211
+ def add(self, manifest):
212
+ with self._lock:
213
+ self._conn.execute(
214
+ "INSERT OR REPLACE INTO ccp_manifests (digest, document) "
215
+ "VALUES (?, ?)",
216
+ (manifest.digest, manifest.document),
217
+ )
218
+ return manifest
219
+
220
+ def get(self, digest):
221
+ with self._lock:
222
+ row = self._conn.execute(
223
+ "SELECT document FROM ccp_manifests WHERE digest = ?",
224
+ (digest,),
225
+ ).fetchone()
226
+ return Manifest.parse(row[0]) if row is not None else None
227
+
228
+
229
+ class SqliteReplayStore(_Store):
230
+ """Replay protection keyed on ``request_id`` (§10.3 step 8)."""
231
+
232
+ def __init__(self, connection, lock):
233
+ super().__init__(connection, lock)
234
+
235
+ def seen(self, request_id):
236
+ with self._lock:
237
+ row = self._conn.execute(
238
+ "SELECT 1 FROM ccp_replay WHERE request_id = ?",
239
+ (request_id,),
240
+ ).fetchone()
241
+ return row is not None
242
+
243
+ def mark(self, request_id):
244
+ with self._lock:
245
+ self._conn.execute(
246
+ "INSERT OR IGNORE INTO ccp_replay (request_id, seen_at) "
247
+ "VALUES (?, ?)",
248
+ (request_id, utcnow()),
249
+ )
250
+
251
+ def mark_if_new(self, request_id):
252
+ """Insert atomically; return False when the id already exists."""
253
+ with self._lock:
254
+ cursor = self._conn.execute(
255
+ "INSERT OR IGNORE INTO ccp_replay (request_id, seen_at) "
256
+ "VALUES (?, ?)",
257
+ (request_id, utcnow()),
258
+ )
259
+ return cursor.rowcount == 1
260
+
261
+ def snapshot(self):
262
+ with self._lock:
263
+ rows = self._conn.execute(
264
+ "SELECT request_id FROM ccp_replay"
265
+ ).fetchall()
266
+ return {row[0] for row in rows}
267
+
268
+ def expire(self, before):
269
+ """Delete replay rows seen before an RFC 3339 timestamp."""
270
+ with self._lock:
271
+ cursor = self._conn.execute(
272
+ "DELETE FROM ccp_replay WHERE seen_at < ?", (before,)
273
+ )
274
+ return cursor.rowcount
275
+
276
+
277
+ class SqliteLimitTracker(_Store):
278
+ """Per-grant cumulative counters with atomic reservation (§9.2)."""
279
+
280
+ def __init__(self, connection, lock):
281
+ super().__init__(connection, lock)
282
+
283
+ def try_reserve(self, grant_id, limits):
284
+ """Reserve one unit against every limit, or none, in one transaction."""
285
+
286
+ def run():
287
+ for name, maximum in limits.items():
288
+ row = self._conn.execute(
289
+ "SELECT value FROM ccp_limits "
290
+ "WHERE grant_id = ? AND name = ?",
291
+ (grant_id, name),
292
+ ).fetchone()
293
+ current = row[0] if row is not None else 0
294
+ if current + 1 > maximum:
295
+ return False
296
+ for name in limits:
297
+ self._conn.execute(
298
+ "INSERT INTO ccp_limits (grant_id, name, value) "
299
+ "VALUES (?, ?, 1) "
300
+ "ON CONFLICT (grant_id, name) "
301
+ "DO UPDATE SET value = value + 1",
302
+ (grant_id, name),
303
+ )
304
+ return True
305
+
306
+ return self._transaction(run)
307
+
308
+ def release(self, grant_id, limits):
309
+ def run():
310
+ for name in limits:
311
+ self._conn.execute(
312
+ "UPDATE ccp_limits SET value = "
313
+ "CASE WHEN value > 0 THEN value - 1 ELSE 0 END "
314
+ "WHERE grant_id = ? AND name = ?",
315
+ (grant_id, name),
316
+ )
317
+
318
+ self._transaction(run)
319
+
320
+ def commit(self, grant_id, limits):
321
+ """A reservation becomes permanent; no counter change is needed."""
322
+ return None
323
+
324
+ def counters(self, grant_id):
325
+ with self._lock:
326
+ rows = self._conn.execute(
327
+ "SELECT name, value FROM ccp_limits WHERE grant_id = ?",
328
+ (grant_id,),
329
+ ).fetchall()
330
+ return {name: value for name, value in rows}
331
+
332
+
333
+ class SqlitePendingStore(_Store):
334
+ """Pending confirmations. ``take`` resolves at most once, atomically."""
335
+
336
+ def __init__(self, connection, lock):
337
+ super().__init__(connection, lock)
338
+
339
+ def put(self, pending):
340
+ payload = _dumps({
341
+ "signal_document": pending.signal.document,
342
+ "grant": pending.grant.to_dict(),
343
+ "intent_spec": pending.intent_spec,
344
+ "state": pending.state,
345
+ })
346
+ with self._lock:
347
+ self._conn.execute(
348
+ "INSERT OR REPLACE INTO ccp_pending (id, payload, created_at) "
349
+ "VALUES (?, ?, ?)",
350
+ (pending.pending_id, payload, utcnow()),
351
+ )
352
+
353
+ def get(self, pending_id):
354
+ with self._lock:
355
+ row = self._conn.execute(
356
+ "SELECT payload FROM ccp_pending WHERE id = ?",
357
+ (pending_id,),
358
+ ).fetchone()
359
+ return _pending_from_payload(row[0], pending_id) if row else None
360
+
361
+ def take(self, pending_id):
362
+ def run():
363
+ row = self._conn.execute(
364
+ "SELECT payload FROM ccp_pending WHERE id = ?",
365
+ (pending_id,),
366
+ ).fetchone()
367
+ if row is None:
368
+ return None
369
+ self._conn.execute("DELETE FROM ccp_pending WHERE id = ?",
370
+ (pending_id,))
371
+ return _pending_from_payload(row[0], pending_id)
372
+
373
+ return self._transaction(run)
374
+
375
+
376
+ class SqliteAuditLog(_Store):
377
+ """Append-only private audit log (§16, §26.1)."""
378
+
379
+ def __init__(self, connection, lock):
380
+ super().__init__(connection, lock)
381
+
382
+ def append(self, event):
383
+ record = dict(event)
384
+ record.setdefault("at", utcnow())
385
+ with self._lock:
386
+ self._conn.execute(
387
+ "INSERT INTO ccp_audit (at, event) VALUES (?, ?)",
388
+ (record["at"], _dumps(record)),
389
+ )
390
+ return record
391
+
392
+ def events(self):
393
+ with self._lock:
394
+ rows = self._conn.execute(
395
+ "SELECT event FROM ccp_audit ORDER BY id"
396
+ ).fetchall()
397
+ return [json.loads(row[0]) for row in rows]
398
+
399
+ def __len__(self):
400
+ with self._lock:
401
+ return self._conn.execute(
402
+ "SELECT COUNT(*) FROM ccp_audit"
403
+ ).fetchone()[0]
404
+
405
+
406
+ class SqliteStores:
407
+ """All six durable stores over one SQLite database.
408
+
409
+ Pass ``":memory:"`` for tests or a file path for a durable, multi-process
410
+ deployment. Close the bundle (or use it as a context manager) to release
411
+ the connection.
412
+ """
413
+
414
+ def __init__(self, path=":memory:"):
415
+ import sqlite3
416
+
417
+ self.path = str(path)
418
+ self._connection = sqlite3.connect(
419
+ self.path, check_same_thread=False, isolation_level=None
420
+ )
421
+ self._connection.execute("PRAGMA busy_timeout = 5000")
422
+ if self.path != ":memory:":
423
+ self._connection.execute("PRAGMA journal_mode = WAL")
424
+ self._connection.executescript(SCHEMA)
425
+ self._lock = threading.RLock()
426
+
427
+ self.grants = SqliteGrantStore(self._connection, self._lock)
428
+ self.manifests = SqliteManifestStore(self._connection, self._lock)
429
+ self.replay = SqliteReplayStore(self._connection, self._lock)
430
+ self.limits = SqliteLimitTracker(self._connection, self._lock)
431
+ self.pending = SqlitePendingStore(self._connection, self._lock)
432
+ self.audit = SqliteAuditLog(self._connection, self._lock)
433
+
434
+ def close(self):
435
+ self._connection.close()
436
+
437
+ def __enter__(self):
438
+ return self
439
+
440
+ def __exit__(self, exc_type, exc_value, traceback):
441
+ self.close()
442
+ return False