sanctum-engine 0.2.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 (47) hide show
  1. sanctum/__init__.py +66 -0
  2. sanctum/aether/__init__.py +23 -0
  3. sanctum/aether/core.py +166 -0
  4. sanctum/aether/errors.py +13 -0
  5. sanctum/aether/reducers.py +43 -0
  6. sanctum/codex/__init__.py +21 -0
  7. sanctum/codex/core.py +60 -0
  8. sanctum/codex/errors.py +14 -0
  9. sanctum/codex/memory.py +33 -0
  10. sanctum/codex/postgres.py +164 -0
  11. sanctum/codex/sqlite.py +126 -0
  12. sanctum/grimoire/__init__.py +25 -0
  13. sanctum/grimoire/core.py +290 -0
  14. sanctum/grimoire/errors.py +35 -0
  15. sanctum/grimoire/repair.py +117 -0
  16. sanctum/grimoire/summon.py +211 -0
  17. sanctum/omens/__init__.py +50 -0
  18. sanctum/omens/events.py +218 -0
  19. sanctum/omens/tracing.py +455 -0
  20. sanctum/oracle/__init__.py +44 -0
  21. sanctum/oracle/_shared.py +105 -0
  22. sanctum/oracle/core.py +87 -0
  23. sanctum/oracle/errors.py +39 -0
  24. sanctum/oracle/llamacpp.py +154 -0
  25. sanctum/oracle/ollama.py +215 -0
  26. sanctum/oracle/openai_compat.py +227 -0
  27. sanctum/oracle/robust.py +239 -0
  28. sanctum/oracle/scripted.py +68 -0
  29. sanctum/oracle/transformers.py +89 -0
  30. sanctum/ritual/__init__.py +42 -0
  31. sanctum/ritual/constants.py +14 -0
  32. sanctum/ritual/core.py +624 -0
  33. sanctum/ritual/errors.py +58 -0
  34. sanctum/ritual/interrupt.py +42 -0
  35. sanctum/ritual/policies.py +80 -0
  36. sanctum/ritual/scheduler.py +573 -0
  37. sanctum/trace.py +41 -0
  38. sanctum/wards/__init__.py +24 -0
  39. sanctum/wards/audit.py +39 -0
  40. sanctum/wards/core.py +62 -0
  41. sanctum/wards/errors.py +14 -0
  42. sanctum/wards/redact.py +53 -0
  43. sanctum/wards/usage.py +63 -0
  44. sanctum_engine-0.2.0.dist-info/METADATA +175 -0
  45. sanctum_engine-0.2.0.dist-info/RECORD +47 -0
  46. sanctum_engine-0.2.0.dist-info/WHEEL +4 -0
  47. sanctum_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
sanctum/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """Sanctum — the chamber where rituals of invocation are prepared and performed.
2
+
3
+ A minimal, local-first orchestration engine for AI agents. Sanctum executes
4
+ cyclic state graphs by supersteps (Pregel/BSP model): Sigils (nodes) run in
5
+ parallel over a shared Aether (state), their partial deltas are merged through
6
+ each Conduit's reducer, and conditional edges decide the next active set until
7
+ END or the recursion limit is reached.
8
+ """
9
+
10
+ from sanctum.aether import AetherSchema, AetherValidationError, Conduit
11
+ from sanctum.codex import Codex, Seal, SealError
12
+ from sanctum.grimoire import (
13
+ Spell,
14
+ SpellCallParseError,
15
+ SpellExecutionError,
16
+ Tome,
17
+ spell,
18
+ summon,
19
+ )
20
+ from sanctum.oracle import Oracle
21
+ from sanctum.ritual import (
22
+ END,
23
+ START,
24
+ Interrupt,
25
+ RecursionLimitError,
26
+ Rite,
27
+ Ritual,
28
+ RitualValidationError,
29
+ SigilExecutionError,
30
+ SigilPolicy,
31
+ SigilTimeoutError,
32
+ interrupt,
33
+ )
34
+ from sanctum.wards import Ward, WardRejection
35
+
36
+ __version__ = "0.2.0"
37
+
38
+ __all__ = [
39
+ "END",
40
+ "START",
41
+ "AetherSchema",
42
+ "AetherValidationError",
43
+ "Codex",
44
+ "Conduit",
45
+ "Interrupt",
46
+ "Oracle",
47
+ "RecursionLimitError",
48
+ "Rite",
49
+ "Ritual",
50
+ "RitualValidationError",
51
+ "Seal",
52
+ "SealError",
53
+ "SigilExecutionError",
54
+ "SigilPolicy",
55
+ "SigilTimeoutError",
56
+ "Spell",
57
+ "SpellCallParseError",
58
+ "SpellExecutionError",
59
+ "Tome",
60
+ "Ward",
61
+ "WardRejection",
62
+ "__version__",
63
+ "interrupt",
64
+ "spell",
65
+ "summon",
66
+ ]
@@ -0,0 +1,23 @@
1
+ """Aether — the shared energy all invoked entities draw upon.
2
+
3
+ Shared state management. The Aether is a typed dict whose channels are
4
+ Conduits, each declaring a reducer (overwrite, append, add, merge_dict, or
5
+ custom) that merges the partial deltas returned by Sigils. An AetherSchema
6
+ defines the Conduits available to a Ritual.
7
+ """
8
+
9
+ from sanctum.aether.core import Aether, AetherSchema, Conduit
10
+ from sanctum.aether.errors import AetherValidationError
11
+ from sanctum.aether.reducers import Reducer, add, append, merge_dict, overwrite
12
+
13
+ __all__ = [
14
+ "Aether",
15
+ "AetherSchema",
16
+ "AetherValidationError",
17
+ "Conduit",
18
+ "Reducer",
19
+ "add",
20
+ "append",
21
+ "merge_dict",
22
+ "overwrite",
23
+ ]
sanctum/aether/core.py ADDED
@@ -0,0 +1,166 @@
1
+ """The Conduits are drawn: the shape the shared energy must take.
2
+
3
+ State schema. A Conduit declares how one key of the Aether merges deltas
4
+ (its reducer); an AetherSchema is the complete set of Conduits a Ritual
5
+ operates on. When a Ritual has a schema, every delta key must name a
6
+ declared Conduit and is merged through that Conduit's reducer instead of
7
+ plain overwrite.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Annotated, Any, get_args, get_origin, get_type_hints
15
+
16
+ from sanctum.aether.errors import AetherValidationError
17
+ from sanctum.aether.reducers import Reducer, overwrite
18
+
19
+ Aether = dict[str, Any]
20
+ """The shared state dict, keyed by Conduit name."""
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Conduit:
25
+ """One channel of the Aether and the way it merges new energy.
26
+
27
+ Declares the reducer ``(current, update) -> new`` applied when a
28
+ Sigil's delta writes to this key. Defaults to `overwrite`. The reducer
29
+ is skipped when the key is not yet present in the Aether: the delta
30
+ value is set directly, so custom reducers never receive a missing
31
+ current value.
32
+ """
33
+
34
+ reducer: Reducer = overwrite
35
+
36
+
37
+ def _as_conduit(value: Conduit | Any) -> Conduit:
38
+ """Coerce a schema declaration into a Conduit.
39
+
40
+ Accepts a Conduit instance, an ``Annotated[T, reducer]`` (the first
41
+ callable metadata item becomes the reducer), or any plain type
42
+ (overwrite). Types themselves are not enforced at runtime; the schema
43
+ governs keys and reducers.
44
+ """
45
+ if isinstance(value, Conduit):
46
+ return value
47
+ if get_origin(value) is Annotated:
48
+ for item in get_args(value)[1:]:
49
+ if callable(item):
50
+ return Conduit(reducer=item)
51
+ return Conduit()
52
+
53
+
54
+ class AetherSchema:
55
+ """The declared shape of the shared energy.
56
+
57
+ Maps each Aether key to its Conduit. Values in the constructor mapping
58
+ may be Conduit instances, ``Annotated[T, reducer]`` forms, or plain
59
+ types (which default to overwrite):
60
+
61
+ AetherSchema({
62
+ "messages": Conduit(reducer=append),
63
+ "score": Annotated[int, add],
64
+ "verdict": str,
65
+ })
66
+
67
+ Delta application is deterministic: within a superstep, deltas are
68
+ applied in Sigil insertion order (the order Sigils were bound to the
69
+ Ritual), and each key merges through its Conduit's reducer.
70
+ """
71
+
72
+ def __init__(self, conduits: Mapping[str, Conduit | Any]) -> None:
73
+ self._conduits: dict[str, Conduit] = {
74
+ name: _as_conduit(value) for name, value in conduits.items()
75
+ }
76
+
77
+ @classmethod
78
+ def from_class(cls, source: type) -> AetherSchema:
79
+ """Read the Conduits from a class's annotations.
80
+
81
+ Sugar for declaring the schema as an annotated class::
82
+
83
+ class ChantAether:
84
+ messages: Annotated[list, append]
85
+ score: int # plain annotation -> overwrite
86
+
87
+ ``Annotated[T, reducer]`` fields use the first callable metadata
88
+ item as the Conduit's reducer.
89
+ """
90
+ hints = get_type_hints(source, include_extras=True)
91
+ return cls(hints)
92
+
93
+ @property
94
+ def conduits(self) -> dict[str, Conduit]:
95
+ """A copy of the mapping from Aether key to its Conduit."""
96
+ return dict(self._conduits)
97
+
98
+ def __contains__(self, key: str) -> bool:
99
+ return key in self._conduits
100
+
101
+ def validate_input(self, input: Mapping[str, Any]) -> None:
102
+ """Check that the initial Aether only uses declared Conduits.
103
+
104
+ Raises:
105
+ AetherValidationError: If `input` contains keys not declared in
106
+ the schema.
107
+ """
108
+ unknown = sorted(set(input) - set(self._conduits))
109
+ if unknown:
110
+ raise AetherValidationError(
111
+ f"Invocation input contains unknown Conduit(s): "
112
+ f"{', '.join(unknown)}; declared Conduits: "
113
+ f"{', '.join(sorted(self._conduits)) or '(none)'}."
114
+ )
115
+
116
+ def apply_delta(
117
+ self, aether: Mapping[str, Any], delta: Mapping[str, Any], *, sigil: str
118
+ ) -> Aether:
119
+ """Merge one Sigil's delta into the Aether through the Conduits.
120
+
121
+ Each delta key must name a declared Conduit; its value merges via
122
+ the Conduit's reducer, or is set directly when the key is not yet
123
+ present. Returns a new dict; `aether` is not mutated.
124
+
125
+ Args:
126
+ aether: Current state.
127
+ delta: Partial update returned by the Sigil.
128
+ sigil: Name of the Sigil that produced the delta, for error
129
+ attribution.
130
+
131
+ Raises:
132
+ AetherValidationError: If the delta writes to a key not
133
+ declared in the schema; the message names the Sigil.
134
+ """
135
+ merged: Aether = dict(aether)
136
+ for key, update in delta.items():
137
+ if key not in self._conduits:
138
+ raise AetherValidationError(
139
+ f"Sigil '{sigil}' wrote to unknown Conduit '{key}'; "
140
+ f"declared Conduits: "
141
+ f"{', '.join(sorted(self._conduits)) or '(none)'}."
142
+ )
143
+ if key in merged:
144
+ merged[key] = self._conduits[key].reducer(merged[key], update)
145
+ else:
146
+ merged[key] = update
147
+ return merged
148
+
149
+ def apply_deltas(
150
+ self,
151
+ aether: Mapping[str, Any],
152
+ deltas: Sequence[tuple[str, Mapping[str, Any]]],
153
+ ) -> Aether:
154
+ """Merge one superstep's deltas into the Aether, deterministically.
155
+
156
+ Deltas are applied strictly in the order given as ``(sigil_name,
157
+ delta)`` pairs; the engine passes them in Sigil insertion order
158
+ (the order Sigils were bound to the Ritual). This makes concurrent
159
+ writes to the same Conduit within a superstep deterministic: the
160
+ Conduit's reducer folds each delta in, in that fixed order.
161
+ Returns a new dict; `aether` is not mutated.
162
+ """
163
+ merged: Aether = dict(aether)
164
+ for sigil, delta in deltas:
165
+ merged = self.apply_delta(merged, delta, sigil=sigil)
166
+ return merged
@@ -0,0 +1,13 @@
1
+ """The ways the Aether rejects what does not belong to it.
2
+
3
+ Exceptions raised by the Aether layer.
4
+ """
5
+
6
+
7
+ class AetherValidationError(Exception):
8
+ """Energy tried to flow outside the declared Conduits.
9
+
10
+ Raised at invocation time when a Sigil's delta — or the initial input —
11
+ writes to a key not declared in the AetherSchema. The message names the
12
+ offending Sigil (when one is involved) and the unknown key.
13
+ """
@@ -0,0 +1,43 @@
1
+ """How new energy folds into each Conduit of the Aether.
2
+
3
+ Built-in reducers. A reducer is any callable ``(current, update) -> new``
4
+ that merges a Sigil's delta value into a Conduit's current value:
5
+ `overwrite` (the default) replaces, `append` concatenates lists, `add` sums
6
+ numbers, and `merge_dict` shallow-merges dicts. Any custom callable with
7
+ the same signature is a valid reducer. Reducers are only called when the
8
+ Conduit already holds a value; a delta to a missing key sets it directly.
9
+ """
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ Reducer = Callable[[Any, Any], Any]
15
+ """A Conduit's merge function: ``(current, update) -> new value``."""
16
+
17
+
18
+ def overwrite(current: Any, update: Any) -> Any:
19
+ """Replace the current value with the update (the default reducer)."""
20
+ return update
21
+
22
+
23
+ def append(current: list[Any], update: list[Any]) -> list[Any]:
24
+ """Concatenate the update list after the current list.
25
+
26
+ The delta value must be a list of items to append (wrap single items).
27
+ Returns a new list; neither input is mutated.
28
+ """
29
+ return list(current) + list(update)
30
+
31
+
32
+ def add(current: Any, update: Any) -> Any:
33
+ """Sum the update into the current value (numeric accumulation)."""
34
+ return current + update
35
+
36
+
37
+ def merge_dict(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
38
+ """Shallow-merge the update dict over the current dict.
39
+
40
+ Keys present in both take the update's value. Returns a new dict;
41
+ neither input is mutated.
42
+ """
43
+ return {**current, **update}
@@ -0,0 +1,21 @@
1
+ """Codex — the ledger where every step of the ritual is inscribed.
2
+
3
+ Persistence and checkpointing. A Seal is the snapshot of the Aether taken at
4
+ the end of each superstep; a Codex stores and retrieves Seals per Invocation
5
+ (execution session, keyed by `invocation_id`). Implementations: MemoryCodex,
6
+ SqliteCodex, and PostgresCodex (optional extra, lazy imports; import it
7
+ explicitly from `sanctum.codex.postgres`).
8
+ """
9
+
10
+ from sanctum.codex.core import Codex, Seal
11
+ from sanctum.codex.errors import SealError
12
+ from sanctum.codex.memory import MemoryCodex
13
+ from sanctum.codex.sqlite import SqliteCodex
14
+
15
+ __all__ = [
16
+ "Codex",
17
+ "MemoryCodex",
18
+ "Seal",
19
+ "SealError",
20
+ "SqliteCodex",
21
+ ]
sanctum/codex/core.py ADDED
@@ -0,0 +1,60 @@
1
+ """Seals — the wax impressions each superstep leaves behind.
2
+
3
+ Checkpoint model and storage contract. A Seal is the snapshot written at
4
+ the end of a superstep: the full Aether, the frontier of the next
5
+ superstep, the superstep number, a timestamp, and free-form metadata. A
6
+ Codex stores Seals per Invocation (keyed by `invocation_id`) and yields
7
+ them back to enable resumption, human-in-the-loop pauses, and time-travel.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ import uuid
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Seal:
21
+ """The wax impression of one superstep.
22
+
23
+ Immutable checkpoint. `aether` is the full state after the superstep's
24
+ deltas were applied; `frontier` the Sigils active in the *next*
25
+ superstep — resumption continues from it; `superstep` the 1-based
26
+ count within the Invocation; `timestamp` epoch seconds; `metadata`
27
+ free-form context (e.g. interrupt details). `seal_id` uniquely
28
+ identifies the Seal for time-travel.
29
+ """
30
+
31
+ aether: dict[str, Any]
32
+ frontier: list[str]
33
+ superstep: int
34
+ timestamp: float = field(default_factory=time.time)
35
+ metadata: dict[str, Any] = field(default_factory=dict)
36
+ seal_id: str = field(default_factory=lambda: uuid.uuid4().hex)
37
+
38
+
39
+ class Codex(ABC):
40
+ """The ledger where Seals are inscribed and consulted.
41
+
42
+ Abstract async storage contract for Seals, keyed by `invocation_id`.
43
+ Histories are append-only: resuming an Invocation appends new Seals
44
+ after the old ones, and `get` returns the most recently written Seal
45
+ (not the highest superstep number). Implementations: MemoryCodex
46
+ (ephemeral, for tests), SqliteCodex (local file), PostgresCodex
47
+ (optional extra, `sanctum.codex.postgres`).
48
+ """
49
+
50
+ @abstractmethod
51
+ async def put(self, invocation_id: str, seal: Seal) -> None:
52
+ """Append a Seal to the Invocation's history."""
53
+
54
+ @abstractmethod
55
+ async def get(self, invocation_id: str) -> Seal | None:
56
+ """Return the Invocation's most recently written Seal, or None."""
57
+
58
+ @abstractmethod
59
+ async def list(self, invocation_id: str) -> list[Seal]:
60
+ """Return the Invocation's full Seal history, oldest first."""
@@ -0,0 +1,14 @@
1
+ """The ways the ledger refuses an inscription or a consultation.
2
+
3
+ Exceptions raised by the Codex layer.
4
+ """
5
+
6
+
7
+ class SealError(Exception):
8
+ """A Seal could not be written, found, or restored.
9
+
10
+ Raised when persistence fails (e.g. the Aether is not
11
+ JSON-serializable for a storage backend) or when resumption cannot
12
+ proceed: no Codex attached, no Seals recorded for the Invocation, or an
13
+ unknown `seal_id`.
14
+ """
@@ -0,0 +1,33 @@
1
+ """A Codex written in breath: it fades when the session ends.
2
+
3
+ In-memory Seal store, the reference implementation for tests and
4
+ ephemeral invocations. Not shared across processes and not persistent.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sanctum.codex.core import Codex, Seal
10
+
11
+
12
+ class MemoryCodex(Codex):
13
+ """Ephemeral in-memory ledger of Seals.
14
+
15
+ Stores histories in a plain dict keyed by `invocation_id`. Intended
16
+ for tests and short-lived local runs; contents vanish with the object.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ self._seals: dict[str, list[Seal]] = {}
21
+
22
+ async def put(self, invocation_id: str, seal: Seal) -> None:
23
+ """Append a Seal to the Invocation's history."""
24
+ self._seals.setdefault(invocation_id, []).append(seal)
25
+
26
+ async def get(self, invocation_id: str) -> Seal | None:
27
+ """Return the Invocation's most recently written Seal, or None."""
28
+ history = self._seals.get(invocation_id)
29
+ return history[-1] if history else None
30
+
31
+ async def list(self, invocation_id: str) -> list[Seal]:
32
+ """Return the Invocation's full Seal history, oldest first."""
33
+ return list(self._seals.get(invocation_id, ()))
@@ -0,0 +1,164 @@
1
+ """A Codex kept in a distant archive: PostgreSQL.
2
+
3
+ Durable Seal store for multi-process or remote deployments. This is an
4
+ optional module: it requires the `psycopg` (v3) driver, declared as the
5
+ ``postgres`` extra (``pip install sanctum-engine[postgres]``). The import
6
+ is lazy — importing this module never fails; instantiating PostgresCodex
7
+ without psycopg installed raises ImportError with install instructions.
8
+
9
+ Same contract and limitation as SqliteCodex: the Aether, frontier, and
10
+ metadata are stored as JSONB, so every value must be JSON-serializable.
11
+
12
+ Schema (created automatically on first use):
13
+
14
+ CREATE TABLE IF NOT EXISTS seals (
15
+ id BIGSERIAL PRIMARY KEY,
16
+ seal_id TEXT NOT NULL UNIQUE,
17
+ invocation_id TEXT NOT NULL,
18
+ superstep INTEGER NOT NULL,
19
+ aether JSONB NOT NULL,
20
+ frontier JSONB NOT NULL,
21
+ timestamp DOUBLE PRECISION NOT NULL,
22
+ metadata JSONB NOT NULL
23
+ );
24
+ CREATE INDEX IF NOT EXISTS seals_invocation_idx
25
+ ON seals (invocation_id, id);
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from typing import Any
32
+
33
+ from sanctum.codex.core import Codex, Seal
34
+ from sanctum.codex.errors import SealError
35
+
36
+ _SCHEMA = """
37
+ CREATE TABLE IF NOT EXISTS seals (
38
+ id BIGSERIAL PRIMARY KEY,
39
+ seal_id TEXT NOT NULL UNIQUE,
40
+ invocation_id TEXT NOT NULL,
41
+ superstep INTEGER NOT NULL,
42
+ aether JSONB NOT NULL,
43
+ frontier JSONB NOT NULL,
44
+ timestamp DOUBLE PRECISION NOT NULL,
45
+ metadata JSONB NOT NULL
46
+ );
47
+ CREATE INDEX IF NOT EXISTS seals_invocation_idx
48
+ ON seals (invocation_id, id);
49
+ """
50
+
51
+
52
+ def _psycopg() -> Any:
53
+ """Import psycopg lazily, with install guidance on failure."""
54
+ try:
55
+ import psycopg
56
+ except ImportError as exc:
57
+ raise ImportError(
58
+ "PostgresCodex requires the optional dependency 'psycopg'; "
59
+ "install it with: pip install sanctum-engine[postgres]"
60
+ ) from exc
61
+ return psycopg
62
+
63
+
64
+ class PostgresCodex(Codex):
65
+ """Durable PostgreSQL ledger of Seals.
66
+
67
+ Persists Seals via async psycopg connections opened per operation
68
+ against `dsn` (e.g. ``postgresql://user:pass@host/db``). The schema is
69
+ created on first use. Requires JSON-serializable Aether and metadata.
70
+ """
71
+
72
+ def __init__(self, dsn: str) -> None:
73
+ _psycopg() # fail fast, with guidance, if the driver is missing
74
+ self._dsn = dsn
75
+ self._schema_ready = False
76
+
77
+ async def _connect(self) -> Any:
78
+ """Open an async connection, creating the schema on first use."""
79
+ connection = await _psycopg().AsyncConnection.connect(self._dsn)
80
+ if not self._schema_ready:
81
+ await connection.execute(_SCHEMA)
82
+ await connection.commit()
83
+ self._schema_ready = True
84
+ return connection
85
+
86
+ async def put(self, invocation_id: str, seal: Seal) -> None:
87
+ """Append a Seal to the Invocation's history.
88
+
89
+ Raises:
90
+ SealError: If the Seal's Aether, frontier, or metadata cannot
91
+ be serialized to JSON.
92
+ """
93
+ try:
94
+ aether = json.dumps(seal.aether)
95
+ frontier = json.dumps(seal.frontier)
96
+ metadata = json.dumps(seal.metadata)
97
+ except (TypeError, ValueError) as exc:
98
+ raise SealError(
99
+ f"Seal '{seal.seal_id}' of Invocation '{invocation_id}' is "
100
+ f"not JSON-serializable: {exc}. PostgresCodex requires the "
101
+ "Aether and metadata to hold only JSON-serializable values."
102
+ ) from exc
103
+ connection = await self._connect()
104
+ try:
105
+ await connection.execute(
106
+ "INSERT INTO seals (seal_id, invocation_id, superstep, "
107
+ "aether, frontier, timestamp, metadata) "
108
+ "VALUES (%s, %s, %s, %s, %s, %s, %s)",
109
+ (
110
+ seal.seal_id,
111
+ invocation_id,
112
+ seal.superstep,
113
+ aether,
114
+ frontier,
115
+ seal.timestamp,
116
+ metadata,
117
+ ),
118
+ )
119
+ await connection.commit()
120
+ finally:
121
+ await connection.close()
122
+
123
+ async def get(self, invocation_id: str) -> Seal | None:
124
+ """Return the Invocation's most recently written Seal, or None."""
125
+ connection = await self._connect()
126
+ try:
127
+ cursor = await connection.execute(
128
+ "SELECT seal_id, superstep, aether, frontier, timestamp, "
129
+ "metadata FROM seals WHERE invocation_id = %s "
130
+ "ORDER BY id DESC LIMIT 1",
131
+ (invocation_id,),
132
+ )
133
+ row = await cursor.fetchone()
134
+ finally:
135
+ await connection.close()
136
+ return _row_to_seal(row) if row is not None else None
137
+
138
+ async def list(self, invocation_id: str) -> list[Seal]:
139
+ """Return the Invocation's full Seal history, oldest first."""
140
+ connection = await self._connect()
141
+ try:
142
+ cursor = await connection.execute(
143
+ "SELECT seal_id, superstep, aether, frontier, timestamp, "
144
+ "metadata FROM seals WHERE invocation_id = %s "
145
+ "ORDER BY id ASC",
146
+ (invocation_id,),
147
+ )
148
+ rows = await cursor.fetchall()
149
+ finally:
150
+ await connection.close()
151
+ return [_row_to_seal(row) for row in rows]
152
+
153
+
154
+ def _row_to_seal(row: tuple[Any, ...]) -> Seal:
155
+ """Rehydrate a Seal from a seals-table row."""
156
+ seal_id, superstep, aether, frontier, timestamp, metadata = row
157
+ return Seal(
158
+ aether=aether if isinstance(aether, dict) else json.loads(aether),
159
+ frontier=frontier if isinstance(frontier, list) else json.loads(frontier),
160
+ superstep=superstep,
161
+ timestamp=timestamp,
162
+ metadata=metadata if isinstance(metadata, dict) else json.loads(metadata),
163
+ seal_id=seal_id,
164
+ )