shakun-kernel 0.1.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.
Files changed (35) hide show
  1. shakun_kernel/__init__.py +26 -0
  2. shakun_kernel/daemon/__init__.py +3 -0
  3. shakun_kernel/daemon/__main__.py +42 -0
  4. shakun_kernel/daemon/caller_resolver.py +67 -0
  5. shakun_kernel/daemon/delegation_wire.py +61 -0
  6. shakun_kernel/daemon/dispatcher.py +213 -0
  7. shakun_kernel/daemon/nonce_store.py +133 -0
  8. shakun_kernel/daemon/server.py +136 -0
  9. shakun_kernel/daemon/signature_resolver.py +173 -0
  10. shakun_kernel/daemon/signed_invocation.py +66 -0
  11. shakun_kernel/factory.py +236 -0
  12. shakun_kernel/kernel/__init__.py +0 -0
  13. shakun_kernel/kernel/attestation.py +275 -0
  14. shakun_kernel/kernel/authorization.py +156 -0
  15. shakun_kernel/kernel/caller.py +42 -0
  16. shakun_kernel/kernel/clock.py +75 -0
  17. shakun_kernel/kernel/delegation.py +215 -0
  18. shakun_kernel/kernel/event_chain.py +47 -0
  19. shakun_kernel/kernel/identity.py +17 -0
  20. shakun_kernel/kernel/identity_keys.py +140 -0
  21. shakun_kernel/kernel/kernel.py +2570 -0
  22. shakun_kernel/kernel/revocation_store.py +96 -0
  23. shakun_kernel/kernel/rotation.py +132 -0
  24. shakun_kernel/kernel/schemas.py +263 -0
  25. shakun_kernel/kernel/validation.py +14 -0
  26. shakun_kernel/kernel/verification.py +89 -0
  27. shakun_kernel/storage/__init__.py +0 -0
  28. shakun_kernel/storage/sqlite_store.py +311 -0
  29. shakun_kernel/transport/__init__.py +0 -0
  30. shakun_kernel/transport/http.py +163 -0
  31. shakun_kernel-0.1.0.dist-info/METADATA +285 -0
  32. shakun_kernel-0.1.0.dist-info/RECORD +35 -0
  33. shakun_kernel-0.1.0.dist-info/WHEEL +5 -0
  34. shakun_kernel-0.1.0.dist-info/licenses/LICENSE +19 -0
  35. shakun_kernel-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,26 @@
1
+ """
2
+ Shakun — a trust and identity kernel for AI agents.
3
+
4
+ This module is the public surface. Everything a developer needs to stand
5
+ up a kernel, obtain a caller, register identities, and delegate authority
6
+ between them is exported here. Reaching into kernel internals or test
7
+ helpers should never be necessary for ordinary use; if it is, that is a
8
+ gap in this surface, not the intended path.
9
+ """
10
+
11
+ from shakun_kernel.factory import create_shakun
12
+ from shakun_kernel.kernel.caller import CallerContext
13
+ from shakun_kernel.kernel.identity_keys import generate_identity_keypair
14
+ from shakun_kernel.kernel.delegation import create_delegation
15
+ from shakun_kernel.kernel.authorization import AuthorizationContext
16
+ from shakun_kernel.kernel.revocation_store import InMemoryRevocationStore, RevocationRecord
17
+
18
+ __all__ = [
19
+ "create_shakun",
20
+ "CallerContext",
21
+ "generate_identity_keypair",
22
+ "create_delegation",
23
+ "AuthorizationContext",
24
+ "InMemoryRevocationStore",
25
+ "RevocationRecord",
26
+ ]
@@ -0,0 +1,3 @@
1
+ # Shakun daemon package. Intentionally empty: importing the package must
2
+ # not eagerly import submodules, or any submodule that imports back into
3
+ # the package creates a circular import. The CLI lives in __main__.py.
@@ -0,0 +1,42 @@
1
+ import argparse
2
+ import logging
3
+
4
+ from shakun_kernel.daemon.server import run_daemon
5
+
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(
10
+ prog="python -m shakun_kernel.daemon",
11
+ description="Shakun development daemon (local-only, no authentication).",
12
+ )
13
+ parser.add_argument("--key-path", required=True,
14
+ help="path to the daemon's encrypted identity key")
15
+ parser.add_argument("--passphrase", required=True,
16
+ help="passphrase for the identity key")
17
+ parser.add_argument("--db", required=True, dest="sqlite_path",
18
+ help="path to the kernel's sqlite event store")
19
+ parser.add_argument("--host", default="127.0.0.1")
20
+ parser.add_argument("--port", type=int, default=8000)
21
+ parser.add_argument("--allow-insecure-bind", action="store_true",
22
+ help="permit binding an unauthenticated daemon to a "
23
+ "non-loopback interface")
24
+ parser.add_argument("--dev-no-auth", action="store_true",
25
+ help="run WITHOUT request authentication (local dev "
26
+ "only; forces loopback unless --allow-insecure-bind)")
27
+ args = parser.parse_args()
28
+
29
+ logging.basicConfig(level=logging.INFO)
30
+
31
+ run_daemon(
32
+ key_path=args.key_path,
33
+ passphrase=args.passphrase,
34
+ sqlite_path=args.sqlite_path,
35
+ host=args.host,
36
+ port=args.port,
37
+ authenticated=not args.dev_no_auth,
38
+ allow_insecure_bind=args.allow_insecure_bind,
39
+ )
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1,67 @@
1
+ from shakun_kernel.kernel.caller import CallerContext
2
+
3
+
4
+ class CallerResolver:
5
+ """
6
+ Turns an incoming transport request into a CallerContext, or
7
+ refuses. This is the single place authentication happens — the
8
+ concrete component behind "the transport authenticates, the kernel
9
+ authorizes."
10
+
11
+ Transport → CallerResolver → CallerContext → Kernel
12
+
13
+ Every resolver must honour the CallerContext Trust Boundary: it may
14
+ only construct a CallerContext for an identity it has actually
15
+ verified. Constructing one from unverified request data defeats the
16
+ kernel's authorization model, and the kernel cannot detect that it
17
+ happened.
18
+
19
+ Subclasses implement resolve(). The `request` argument is the
20
+ transport's own native request object, passed through opaquely: the
21
+ resolver is free to interpret it however it needs (read headers, a
22
+ signature, the raw body), and is the ONLY component permitted to.
23
+ Below the transport boundary the request does not exist — the
24
+ dispatcher and kernel see only the CallerContext that comes out,
25
+ never the request that produced it. That is what lets the same
26
+ dispatcher and kernel sit behind HTTP, a signature-verifying
27
+ resolver, or a future network resolver without changing.
28
+ """
29
+ def resolve(self, request) -> CallerContext:
30
+ raise NotImplementedError
31
+
32
+
33
+ class LocalCallerResolver(CallerResolver):
34
+ """
35
+ Development only. Resolves EVERY request to one configured local
36
+ identity, performing no authentication whatsoever.
37
+
38
+ This exists to exercise the request pipeline before the real trust
39
+ boundary (SignatureCallerResolver, CRITICAL 1) is built. It
40
+ performs no authentication, so a deployment using it is responsible
41
+ for ensuring it is never exposed to untrusted clients. That is an
42
+ operational guarantee the deployment must make — this class cannot
43
+ enforce it, since a resolver has no visibility into how or where
44
+ the server binds. It is never a production component.
45
+
46
+ The configured identity is normally the kernel's own genesis
47
+ identity — the one identity guaranteed to exist and to hold
48
+ registration authority — so a local developer can drive every
49
+ syscall without first standing up a second identity.
50
+ """
51
+
52
+ def __init__(self, identity_id):
53
+ if not isinstance(identity_id, str) or not identity_id.strip():
54
+ raise ValueError("LocalCallerResolver requires a non-blank identity_id")
55
+ self._identity_id = identity_id
56
+
57
+ def resolve(self, request):
58
+ # No authentication. The request is ignored for identity — every
59
+ # caller is the one configured local identity. The operation is
60
+ # read from the adapter unauthenticated, which is the entire
61
+ # nature of this dev resolver: it asserts nothing it verified.
62
+ from shakun_kernel.daemon.signature_resolver import AuthenticatedInvocation
63
+ fields = request.auth_fields()
64
+ return AuthenticatedInvocation(
65
+ caller=CallerContext(identity_id=self._identity_id),
66
+ operation=fields.get("operation"),
67
+ )
@@ -0,0 +1,61 @@
1
+ from shakun_kernel.kernel.schemas import DelegationRecord
2
+
3
+
4
+ # The wire form of a DelegationRecord: a flat JSON object with exactly
5
+ # these keys. This mapping IS the protocol — a client in any language
6
+ # produces this shape — so it is declared explicitly here rather than
7
+ # derived from the dataclass by introspection, and every field is
8
+ # covered by a round-trip test. If DelegationRecord gains a field, this
9
+ # fails loudly (missing key) rather than silently dropping it.
10
+ _DELEGATION_FIELDS = (
11
+ "version",
12
+ "delegation_id",
13
+ "delegator_identity_id",
14
+ "delegate_identity_id",
15
+ "issuer_identity_id",
16
+ "scope_id",
17
+ "mission_id",
18
+ "issued_at",
19
+ "expires_at",
20
+ "revocation_reference",
21
+ "signature",
22
+ )
23
+
24
+ # Membership set for validation — the tuple gives deterministic
25
+ # reconstruction order, this gives O(1) presence/absence checks without
26
+ # rebuilding a list on every call.
27
+ _DELEGATION_FIELD_SET = frozenset(_DELEGATION_FIELDS)
28
+
29
+
30
+ def delegation_to_dict(record: DelegationRecord) -> dict:
31
+ """Serialize a DelegationRecord to its wire form. Explicit per-field
32
+ so the wire shape is a deliberate contract, not an accident of
33
+ dataclass layout."""
34
+ return {field: getattr(record, field) for field in _DELEGATION_FIELDS}
35
+
36
+
37
+ def delegation_from_dict(data: dict) -> DelegationRecord:
38
+ """
39
+ Reconstruct a DelegationRecord from its wire form.
40
+
41
+ Every field must be present. A missing field is a malformed
42
+ delegation and raises — we never silently default one, because a
43
+ defaulted field would change the bytes verify_delegation checks and
44
+ produce a confusing 'invalid signature' far from the real cause
45
+ (a truncated delegation). optional-VALUED fields (mission_id,
46
+ revocation_reference) may be null, but the KEY must exist.
47
+ """
48
+ if not isinstance(data, dict):
49
+ raise ValueError("delegation must be a JSON object")
50
+ keys = set(data)
51
+ missing = _DELEGATION_FIELD_SET - keys
52
+ if missing:
53
+ raise ValueError(f"delegation is missing required field(s): {sorted(missing)}")
54
+ extra = keys - _DELEGATION_FIELD_SET
55
+ if extra:
56
+ # Strict protocol boundary: unknown fields are rejected, not
57
+ # ignored. Catches client typos (e.g. delegate_identity vs
58
+ # delegate_identity_id) immediately, at the boundary, rather than
59
+ # as a downstream signature mismatch.
60
+ raise ValueError(f"delegation contains unknown field(s): {sorted(extra)}")
61
+ return DelegationRecord(**{field: data[field] for field in _DELEGATION_FIELDS})
@@ -0,0 +1,213 @@
1
+ from shakun_kernel.kernel.caller import CallerContext
2
+
3
+ # NOTE (future): as transport-level validation grows, these one-off
4
+ # dispatcher exceptions want to become a small hierarchy (a DispatcherError
5
+ # base with MalformedDelegation / DelegationUnavailable / ... subclasses),
6
+ # so dispatch() catches the base once and maps code/type off the exception
7
+ # instead of accumulating except-clauses. Deferred until a third such
8
+ # exception makes the shared shape clear — building it with two members
9
+ # would be structure ahead of the load that justifies it.
10
+ class _DelegationUnavailable(Exception):
11
+ """A delegated request reached a daemon with no revocation store.
12
+ Fails closed — see _auth_context."""
13
+
14
+ class SyscallDispatcher:
15
+ """
16
+ Turns a resolved caller and a parsed request body into exactly one
17
+ kernel syscall, and returns the kernel's structured response
18
+ unchanged.
19
+
20
+ Transport → CallerResolver → CallerContext ─┐
21
+ ├→ Dispatcher → Kernel
22
+ parsed request body ─────────────┘
23
+
24
+ This is the one place that knows the mapping from a named operation
25
+ to a kernel syscall and the shape of each operation's arguments.
26
+ It performs NO authentication (that already happened in the
27
+ resolver) and NO authorization (that happens inside the kernel).
28
+ Its whole job is: pick the syscall, pull named fields out of the
29
+ body, thread the caller through, hand back what the kernel returns.
30
+
31
+ It never calls a non-syscall_ kernel method. The syscall_ layer is
32
+ the boundary — see the Trust Boundary Law — and the dispatcher
33
+ stays on the outside of it.
34
+
35
+ A request that names an unknown operation, or omits a field a
36
+ syscall requires, is a client error and comes back as a structured
37
+ error dict, never an exception through the transport.
38
+ """
39
+
40
+ def __init__(self, kernel, revocation_store=None):
41
+ self._kernel = kernel
42
+ # The daemon's durable revocation store, used to build an
43
+ # AuthorizationContext for delegated requests. Optional: a daemon
44
+ # with no delegated operations (or the dev/local path) can run
45
+ # without one, in which case a request carrying a delegation is
46
+ # refused rather than silently unverified against revocation.
47
+ self._revocation_store = revocation_store
48
+ self._operations = {
49
+ "create_scope": self._create_scope,
50
+ "create_mission": self._create_mission,
51
+ "start_mission": self._start_mission,
52
+ "record_observation": self._record_observation,
53
+ "close_mission": self._close_mission,
54
+ "get_mission_state": self._get_mission_state,
55
+ "record_retrieval_event": self._record_retrieval_event,
56
+ "commit_memory": self._commit_memory,
57
+ }
58
+
59
+ def dispatch(self, operation, body, caller: CallerContext):
60
+ handler = self._operations.get(operation)
61
+ if handler is None:
62
+ return {
63
+ "status": "error",
64
+ "error": {
65
+ "type": "UnknownOperation",
66
+ "code": "dispatch.unknown_operation",
67
+ "reason": f"no such operation: {operation}",
68
+ },
69
+ }
70
+ try:
71
+ return handler(body, caller)
72
+ except _DelegationUnavailable as e:
73
+ return {
74
+ "status": "error",
75
+ "error": {
76
+ "type": "DelegationUnavailable",
77
+ "code": "dispatch.delegation_unavailable",
78
+ "reason": str(e),
79
+ },
80
+ }
81
+ except ValueError as e:
82
+ # Malformed delegation (bad wire shape, missing/unknown field).
83
+ # A client error, surfaced clearly rather than as a 500.
84
+ return {
85
+ "status": "error",
86
+ "error": {
87
+ "type": "MalformedDelegation",
88
+ "code": "dispatch.malformed_delegation",
89
+ "reason": str(e),
90
+ },
91
+ }
92
+ except KeyError as missing:
93
+ # ONLY transport-level errors are caught here. Kernel errors
94
+ # (authorization, invariants, domain rules) are returned by
95
+ # _run_syscall as structured dicts, not raised — they pass
96
+ # through untouched. Do not add a broad except: it would
97
+ # create a second error vocabulary competing with the
98
+ # kernel's own.
99
+ return {
100
+ "status": "error",
101
+ "error": {
102
+ "type": "MissingField",
103
+ "code": "dispatch.missing_field",
104
+ "reason": f"request is missing required field: {missing.args[0]}",
105
+ },
106
+ }
107
+
108
+ def _auth_context(self, body):
109
+ """
110
+ Assembles an AuthorizationContext from a request body, if the body
111
+ carries a delegation. Returns None for a direct-authority request
112
+ (no delegation field) — the kernel treats auth_context=None as
113
+ "direct authority only", exactly as for an in-process direct call.
114
+
115
+ This is the single place body-delegation becomes an
116
+ AuthorizationContext. delegation_wire only reconstructs the
117
+ DelegationRecord; this method is the one that knows about the
118
+ revocation store. The kernel still consumes only AuthorizationContext.
119
+ """
120
+ from shakun_kernel.daemon.delegation_wire import delegation_from_dict
121
+ from shakun_kernel.kernel.authorization import AuthorizationContext
122
+
123
+ raw = body.get("delegation")
124
+ if raw is None:
125
+ return None
126
+
127
+ if self._revocation_store is None:
128
+ # A delegation was presented but this daemon has no revocation
129
+ # store to check it against. Refuse rather than verify a
130
+ # delegation we couldn't confirm is unrevoked — failing closed
131
+ # is the only safe choice for an authority grant.
132
+ raise _DelegationUnavailable(
133
+ "this daemon is not configured to accept delegated requests"
134
+ )
135
+
136
+ delegation = delegation_from_dict(raw)
137
+ return AuthorizationContext(
138
+ delegation_record=delegation,
139
+ revocation_store=self._revocation_store,
140
+ )
141
+
142
+ # ── operation handlers ──────────────────────────────────────────────
143
+ # Each pulls the fields its syscall needs out of `body` and threads
144
+ # the caller through. A missing required field raises KeyError, which
145
+ # dispatch() turns into a clean dispatch.missing_field error.
146
+
147
+ def _create_scope(self, body, caller):
148
+ return self._kernel.syscall_create_scope(
149
+ body["scope_id"],
150
+ caller=caller,
151
+ parent_scope_id=body.get("parent_scope_id"),
152
+ )
153
+
154
+ def _create_mission(self, body, caller):
155
+ return self._kernel.syscall_create_mission(
156
+ body["goal_spec"],
157
+ scope_id=body["scope_id"],
158
+ caller=caller,
159
+ auth_context=self._auth_context(body),
160
+ )
161
+
162
+ def _start_mission(self, body, caller):
163
+ return self._kernel.syscall_start_mission(
164
+ body["mission_id"],
165
+ caller=caller,
166
+ auth_context=self._auth_context(body),
167
+ )
168
+
169
+ def _record_observation(self, body, caller):
170
+ return self._kernel.syscall_record_observation(
171
+ body["observation"],
172
+ scope_id=body["scope_id"],
173
+ mission_id=body.get("mission_id"),
174
+ caller=caller,
175
+ auth_context=self._auth_context(body),
176
+ )
177
+
178
+ def _close_mission(self, body, caller):
179
+ return self._kernel.syscall_close_mission(
180
+ body["mission_id"],
181
+ evidence_event_ids=body.get("evidence_event_ids"),
182
+ caller=caller,
183
+ auth_context=self._auth_context(body),
184
+ )
185
+
186
+ def _get_mission_state(self, body, caller):
187
+ return self._kernel.syscall_get_mission_state(
188
+ body["mission_id"],
189
+ caller=caller,
190
+ )
191
+
192
+ def _record_retrieval_event(self, body, caller):
193
+ return self._kernel.syscall_record_retrieval_event(
194
+ source_id=body["source_id"],
195
+ source_type=body["source_type"],
196
+ query=body["query"],
197
+ scope_id=body["scope_id"],
198
+ mission_id=body.get("mission_id"),
199
+ caller=caller,
200
+ auth_context=self._auth_context(body),
201
+ )
202
+
203
+ def _commit_memory(self, body, caller):
204
+ from shakun_kernel.kernel.kernel import MemoryType
205
+ return self._kernel.syscall_commit_memory(
206
+ memory_type=MemoryType(body["memory_type"]),
207
+ scope_id=body["scope_id"],
208
+ provenance=body["provenance"],
209
+ content=body["content"],
210
+ schema_version=body.get("schema_version", 1),
211
+ caller=caller,
212
+ auth_context=self._auth_context(body),
213
+ )
@@ -0,0 +1,133 @@
1
+ import sqlite3
2
+ from datetime import timedelta
3
+
4
+ from shakun_kernel.kernel.clock import parse_iso_timestamp
5
+
6
+
7
+ class NonceStore:
8
+ """
9
+ Replay-prevention state: has this (identity, nonce) been seen before?
10
+
11
+ Same abstraction family as EventStore, RevocationStore, and the
12
+ keystores — the resolver depends on this interface, and durability is
13
+ a property of the chosen backend, not of the authentication protocol.
14
+
15
+ Nonces are scoped per identity: two different identities may
16
+ legitimately use the same nonce string, and one identity's nonces are
17
+ never another's concern.
18
+ """
19
+
20
+ def seen(self, identity_id, nonce) -> bool:
21
+ raise NotImplementedError
22
+
23
+ def record(self, identity_id, nonce, timestamp) -> None:
24
+ raise NotImplementedError
25
+
26
+
27
+ class InMemoryNonceStore(NonceStore):
28
+ """
29
+ Development / single-process store. Holds seen (identity, nonce)
30
+ pairs in memory.
31
+
32
+ LIMITATION, by design: this forgets every nonce on restart. Within
33
+ the SignedInvocation timestamp-skew window, a captured request could
34
+ therefore be replayed once across a restart. That is a property of
35
+ THIS backend, not of the protocol — a durable NonceStore
36
+ (SQLite/Redis/etc.) closes it without any change to the resolver.
37
+ Same category of deferred-durability as revocation persistence.
38
+
39
+ `timestamp` is recorded so a future implementation can prune entries
40
+ older than the skew window; this in-memory version keeps them for the
41
+ process lifetime, which is correct (never under-rejects) if wasteful.
42
+ """
43
+
44
+ def __init__(self):
45
+ self._seen = set()
46
+
47
+ def seen(self, identity_id, nonce) -> bool:
48
+ return (identity_id, nonce) in self._seen
49
+
50
+ def record(self, identity_id, nonce, timestamp) -> None:
51
+ self._seen.add((identity_id, nonce))
52
+
53
+
54
+ class SQLiteNonceStore(NonceStore):
55
+ """
56
+ A durable NonceStore backed by SQLite. Same contract as
57
+ InMemoryNonceStore — has this (identity, nonce) been seen — but
58
+ survives restart, closing the cross-restart replay window that the
59
+ in-memory store leaves open (audit follow-up to HIGH 7).
60
+
61
+ Self-pruning: a nonce only needs to be remembered as long as a
62
+ request bearing it could still be ACCEPTED, which is the resolver's
63
+ timestamp-skew window. Past that, the resolver rejects the request as
64
+ stale regardless of the nonce check, so keeping the nonce is
65
+ pointless. Each record() therefore deletes entries older than
66
+ retention_seconds. This keeps an otherwise unbounded table bounded,
67
+ with no background worker or startup scan — cleanup happens naturally
68
+ as the daemon serves requests.
69
+
70
+ CRITICAL COUPLING: retention_seconds MUST be >= the resolver's
71
+ max_skew_seconds. If retention were shorter, a nonce could be pruned
72
+ while a request bearing it is still within the acceptance window —
73
+ silently reopening the exact replay hole this store exists to close.
74
+ The daemon must derive both values from one shared constant; see
75
+ build_daemon. This store validates only that retention is positive;
76
+ it cannot see the resolver's skew, so the >= relationship is the
77
+ daemon's responsibility to guarantee.
78
+ """
79
+
80
+ def __init__(self, db_path, *, retention_seconds=60):
81
+ if not isinstance(retention_seconds, (int, float)) or retention_seconds <= 0:
82
+ raise ValueError("retention_seconds must be a positive number")
83
+ self._db_path = db_path
84
+ self._retention = timedelta(seconds=retention_seconds)
85
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
86
+ self._conn.execute("PRAGMA journal_mode=WAL")
87
+ self._conn.execute("""
88
+ CREATE TABLE IF NOT EXISTS nonces (
89
+ identity_id TEXT NOT NULL,
90
+ nonce TEXT NOT NULL,
91
+ timestamp TEXT NOT NULL,
92
+ PRIMARY KEY (identity_id, nonce)
93
+ )
94
+ """)
95
+ # Index for the prune range query — without it, pruning becomes a
96
+ # full table scan that worsens exactly as the table grows.
97
+ self._conn.execute(
98
+ "CREATE INDEX IF NOT EXISTS idx_nonce_timestamp ON nonces(timestamp)"
99
+ )
100
+ self._conn.commit()
101
+
102
+ def seen(self, identity_id, nonce) -> bool:
103
+ row = self._conn.execute(
104
+ "SELECT 1 FROM nonces WHERE identity_id = ? AND nonce = ?",
105
+ (identity_id, nonce),
106
+ ).fetchone()
107
+ return row is not None
108
+
109
+ def record(self, identity_id, nonce, timestamp) -> None:
110
+ # Prune first: drop everything older than the retention window,
111
+ # computed from the newly-recorded request's own timestamp so the
112
+ # store needs no external clock. The cutoff is produced as an ISO
113
+ # string the same way stored timestamps are, so the string
114
+ # comparison below matches chronological order.
115
+ cutoff = (parse_iso_timestamp(timestamp) - self._retention).isoformat()
116
+ self._conn.execute("DELETE FROM nonces WHERE timestamp < ?", (cutoff,))
117
+
118
+ # INSERT OR IGNORE: recording the same (identity, nonce) twice is a
119
+ # no-op. The resolver only ever records a nonce it has already
120
+ # confirmed unseen, so a collision here would mean a genuine replay
121
+ # that the resolver's seen() check should have caught — ignoring is
122
+ # safe and the seen() check is the real guard.
123
+ self._conn.execute(
124
+ "INSERT OR IGNORE INTO nonces (identity_id, nonce, timestamp) "
125
+ "VALUES (?, ?, ?)",
126
+ (identity_id, nonce, timestamp),
127
+ )
128
+ self._conn.commit()
129
+
130
+ def close(self):
131
+ """Closes the connection explicitly — deterministic lifecycle for
132
+ the daemon at shutdown and for tests before reopen/delete."""
133
+ self._conn.close()