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/__init__.py +97 -0
- ccp/adapters/__init__.py +5 -0
- ccp/adapters/fastapi.py +97 -0
- ccp/adapters/mcp.py +208 -0
- ccp/agent.py +66 -0
- ccp/audit.py +39 -0
- ccp/canonical.py +192 -0
- ccp/consumer.py +75 -0
- ccp/crypto.py +96 -0
- ccp/decision.py +81 -0
- ccp/errors.py +35 -0
- ccp/hosts.py +52 -0
- ccp/model.py +187 -0
- ccp/requester.py +66 -0
- ccp/runtime.py +200 -0
- ccp/schema.py +134 -0
- ccp/signing.py +14 -0
- ccp/state_machine.py +33 -0
- ccp/stores.py +166 -0
- ccp/stores_sqlite.py +442 -0
- ccp_protocol-0.4.0.dist-info/METADATA +256 -0
- ccp_protocol-0.4.0.dist-info/RECORD +24 -0
- ccp_protocol-0.4.0.dist-info/WHEEL +5 -0
- ccp_protocol-0.4.0.dist-info/top_level.txt +1 -0
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
|