specunode 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 (53) hide show
  1. specunode/__init__.py +94 -0
  2. specunode/api.py +170 -0
  3. specunode/buffer/__init__.py +0 -0
  4. specunode/buffer/dispatcher.py +171 -0
  5. specunode/buffer/idempotency.py +160 -0
  6. specunode/buffer/store_buffer.py +1031 -0
  7. specunode/canonical.py +184 -0
  8. specunode/cli.py +712 -0
  9. specunode/config.py +279 -0
  10. specunode/core/__init__.py +0 -0
  11. specunode/core/branch.py +439 -0
  12. specunode/core/decision.py +171 -0
  13. specunode/core/effects.py +271 -0
  14. specunode/core/graph.py +248 -0
  15. specunode/core/hazards.py +254 -0
  16. specunode/core/loop.py +107 -0
  17. specunode/core/model.py +1964 -0
  18. specunode/core/policy.py +242 -0
  19. specunode/core/scheduler.py +2873 -0
  20. specunode/core/state.py +842 -0
  21. specunode/drafters/__init__.py +0 -0
  22. specunode/drafters/base.py +89 -0
  23. specunode/drafters/t0_stream.py +87 -0
  24. specunode/drafters/t1_pattern.py +255 -0
  25. specunode/drafters/t2_model.py +164 -0
  26. specunode/ids.py +97 -0
  27. specunode/integrations/__init__.py +0 -0
  28. specunode/integrations/anthropic.py +325 -0
  29. specunode/integrations/langgraph.py +328 -0
  30. specunode/integrations/mcp_proxy.py +713 -0
  31. specunode/integrations/plain.py +225 -0
  32. specunode/journal/__init__.py +0 -0
  33. specunode/journal/entries.py +214 -0
  34. specunode/journal/journal.py +1260 -0
  35. specunode/journal/ledger.py +1560 -0
  36. specunode/journal/replay.py +1070 -0
  37. specunode/journal/schema.sql +74 -0
  38. specunode/py.typed +0 -0
  39. specunode/runner.py +121 -0
  40. specunode/specunode.yaml.example +123 -0
  41. specunode/testing/__init__.py +0 -0
  42. specunode/testing/faults.py +124 -0
  43. specunode/testing/models.py +177 -0
  44. specunode/testing/world.py +707 -0
  45. specunode/verify/__init__.py +0 -0
  46. specunode/verify/equivalence.py +507 -0
  47. specunode/verify/gate.py +49 -0
  48. specunode/verify/witness.py +216 -0
  49. specunode-0.1.0.dist-info/METADATA +534 -0
  50. specunode-0.1.0.dist-info/RECORD +53 -0
  51. specunode-0.1.0.dist-info/WHEEL +4 -0
  52. specunode-0.1.0.dist-info/entry_points.txt +2 -0
  53. specunode-0.1.0.dist-info/licenses/LICENSE +202 -0
specunode/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """SpecuNode -- agents that take real actions, and never take one twice.
2
+
3
+ A runtime for AI agents whose tools change the world. Every write is held until the model's
4
+ decision behind it is durable, claimed in a journal under a deterministic key before it is sent,
5
+ and never sent twice -- a crash is resumed onto the same keys, and a reply lost in a crash is
6
+ either asked about (``reconcile``) or handed to a human, never guessed at. Every run replays.
7
+
8
+ The public API is exposed lazily, so ``import specunode`` stays cheap and does not pull in the
9
+ optional integrations (LangGraph, MCP, Anthropic) until they are used::
10
+
11
+ import specunode
12
+
13
+ @specunode.tool(effect="write")
14
+ async def charge_card(customer_id: str, amount: float) -> dict: ...
15
+
16
+ runtime = specunode.Runtime(specunode.graph(nodes, route), tools=[charge_card])
17
+ result = await runtime.run(inputs)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ #: Public name -> (module, attribute). Imported on first use.
28
+ _EXPORTS: dict[str, tuple[str, str]] = {
29
+ "tool": ("specunode.integrations.plain", "tool"),
30
+ "node": ("specunode.integrations.plain", "node"),
31
+ "graph": ("specunode.api", "graph"),
32
+ "Runtime": ("specunode.api", "Runtime"),
33
+ "current_idempotency_key": ("specunode.api", "current_idempotency_key"),
34
+ "agent_loop": ("specunode.core.loop", "agent_loop"),
35
+ "RunSession": ("specunode.core.graph", "RunSession"),
36
+ "Decision": ("specunode.core.decision", "Decision"),
37
+ "FreeText": ("specunode.core.decision", "FreeText"),
38
+ "ToolCall": ("specunode.core.decision", "ToolCall"),
39
+ "EffectClass": ("specunode.core.effects", "EffectClass"),
40
+ "Policy": ("specunode.core.policy", "Policy"),
41
+ "RunResult": ("specunode.core.scheduler", "RunResult"),
42
+ "ToolDispatchError": ("specunode.buffer.dispatcher", "ToolDispatchError"),
43
+ "ModelError": ("specunode.core.model", "ModelError"),
44
+ "TurnAbandoned": ("specunode.core.model", "TurnAbandoned"),
45
+ "AnthropicModel": ("specunode.integrations.anthropic", "AnthropicModel"),
46
+ }
47
+
48
+ __all__ = [
49
+ "AnthropicModel",
50
+ "Decision",
51
+ "EffectClass",
52
+ "FreeText",
53
+ "ModelError",
54
+ "Policy",
55
+ "RunResult",
56
+ "RunSession",
57
+ "Runtime",
58
+ "ToolCall",
59
+ "ToolDispatchError",
60
+ "TurnAbandoned",
61
+ "__version__",
62
+ "agent_loop",
63
+ "current_idempotency_key",
64
+ "graph",
65
+ "node",
66
+ "tool",
67
+ ]
68
+
69
+ if TYPE_CHECKING: # the same names, for type checkers and editors
70
+ from specunode.api import Runtime, current_idempotency_key, graph
71
+ from specunode.buffer.dispatcher import ToolDispatchError
72
+ from specunode.core.decision import Decision, FreeText, ToolCall
73
+ from specunode.core.effects import EffectClass
74
+ from specunode.core.graph import RunSession
75
+ from specunode.core.loop import agent_loop
76
+ from specunode.core.model import ModelError, TurnAbandoned
77
+ from specunode.core.policy import Policy
78
+ from specunode.core.scheduler import RunResult
79
+ from specunode.integrations.anthropic import AnthropicModel
80
+ from specunode.integrations.plain import node, tool
81
+
82
+
83
+ def __getattr__(name: str) -> Any:
84
+ try:
85
+ module, attribute = _EXPORTS[name]
86
+ except KeyError:
87
+ raise AttributeError(f"module 'specunode' has no attribute {name!r}") from None
88
+ value = getattr(importlib.import_module(module), attribute)
89
+ globals()[name] = value
90
+ return value
91
+
92
+
93
+ def __dir__() -> list[str]:
94
+ return sorted({*globals(), *_EXPORTS})
specunode/api.py ADDED
@@ -0,0 +1,170 @@
1
+ """The front door: declare tools and nodes, build a graph, run it, resume it.
2
+
3
+ Every piece here exists elsewhere in the package. What this module adds is the wiring every
4
+ first run needs and nobody should have to write -- a journal, a store buffer, a dispatcher and a
5
+ scheduler, assembled with every safety check left on -- so that a run is::
6
+
7
+ import specunode
8
+
9
+ @specunode.tool(effect="write")
10
+ async def charge_card(customer_id: str, amount: float) -> dict: ...
11
+
12
+ @specunode.node()
13
+ async def bill(session: specunode.RunSession) -> specunode.Decision: ...
14
+
15
+ runtime = specunode.Runtime(specunode.graph([bill], route), tools=[charge_card])
16
+ result = await runtime.run({"customer_id": "cus-1"})
17
+ # killed half way? await runtime.resume(result.run_id)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import AsyncIterator, Callable, Iterable, Mapping, Sequence
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from specunode.buffer.dispatcher import Dispatcher
28
+ from specunode.buffer.store_buffer import StoreBuffer
29
+ from specunode.canonical import JsonValue
30
+ from specunode.core.effects import ToolRegistry
31
+ from specunode.core.graph import GraphAdapter
32
+ from specunode.core.model import (
33
+ JournaledModel,
34
+ ModelClient,
35
+ ModelError,
36
+ ModelResponse,
37
+ RequestEnvelope,
38
+ StreamEvent,
39
+ call_scope,
40
+ )
41
+ from specunode.core.policy import Policy
42
+ from specunode.core.scheduler import RunResult, Scheduler
43
+ from specunode.ids import new_ulid
44
+ from specunode.integrations.plain import PlainAdapter, registry_of
45
+ from specunode.journal.journal import Journal, is_postgres_dsn
46
+
47
+ __all__ = ["Runtime", "current_idempotency_key", "graph"]
48
+
49
+ #: Where a run's journal lives unless told otherwise: beside the code that runs it.
50
+ DEFAULT_JOURNAL = Path(".specunode") / "journal.db"
51
+
52
+
53
+ def graph(
54
+ nodes: Iterable[Callable[..., Any]],
55
+ route: Callable[[Mapping[str, JsonValue]], str | Sequence[str] | None],
56
+ ) -> PlainAdapter:
57
+ """A graph from decorated node functions and a router.
58
+
59
+ The router is handed the committed state and returns the next node's name, a list of names
60
+ to run side by side and retire in that order, or ``None`` to end the run.
61
+ """
62
+ return PlainAdapter.of(nodes, route)
63
+
64
+
65
+ def current_idempotency_key() -> str:
66
+ """The key the runtime is dispatching this call under. Pass it to the upstream.
67
+
68
+ Deterministic: the same call at the same point of the same run derives the same key on a
69
+ resume or a replay, which is what lets an upstream that honours idempotency keys -- and a
70
+ tool's ``reconcile`` -- recognise a request it has already seen. Only defined inside a tool
71
+ while the runtime is dispatching it.
72
+ """
73
+ scope = call_scope.get()
74
+ key = scope.effect_key if scope is not None else ""
75
+ if not key:
76
+ raise RuntimeError(
77
+ "current_idempotency_key() is only defined inside a tool the runtime is "
78
+ "dispatching; a read, or a call made outside a run, has no key"
79
+ )
80
+ return key
81
+
82
+
83
+ @dataclass
84
+ class _NoModel:
85
+ """The target of a runtime built without a model: fine until something asks it."""
86
+
87
+ def _refuse(self) -> ModelError:
88
+ return ModelError(
89
+ "this Runtime was built without a model, and a node asked one; pass model=..."
90
+ )
91
+
92
+ async def complete(self, envelope: RequestEnvelope) -> ModelResponse:
93
+ raise self._refuse()
94
+
95
+ async def stream(self, envelope: RequestEnvelope) -> AsyncIterator[StreamEvent]:
96
+ raise self._refuse()
97
+ yield # type: ignore[unreachable] # pragma: no cover - makes this an async generator
98
+
99
+
100
+ @dataclass
101
+ class Runtime:
102
+ """A graph, its tools and a journal: everything a run and its resume need.
103
+
104
+ ``tools`` is a list of functions decorated with ``@specunode.tool``, or a registry.
105
+ ``model`` is the model the graph's nodes ask; a graph whose nodes never ask one needs none.
106
+ ``journal`` is a path to a SQLite file (created if absent) or a ``Journal``.
107
+ """
108
+
109
+ graph: GraphAdapter
110
+ tools: ToolRegistry | Sequence[Callable[..., Any]] = ()
111
+ model: ModelClient | None = None
112
+ journal: Journal | str | Path = DEFAULT_JOURNAL
113
+ #: Guessing is off: it needs a drafter, and without one it only adds bookkeeping to every
114
+ #: receipt. Issuing a read the moment its block parses is not guessing, and stays on.
115
+ policy: Policy = field(default_factory=lambda: Policy(speculation=False))
116
+ reducers: Mapping[str, str] = field(default_factory=dict)
117
+ #: Attempts per effect before it is dead-lettered, and the first backoff between them.
118
+ max_attempts: int = 3
119
+ base_delay_ms: float = 50.0
120
+
121
+ def _journal(self) -> Journal:
122
+ if isinstance(self.journal, Journal):
123
+ return self.journal
124
+ if is_postgres_dsn(self.journal):
125
+ # Not through ``Path``, which folds the DSN's ``//`` into ``/`` and made a SQLite
126
+ # file of it -- in a folder named ``postgresql:``.
127
+ return Journal(str(self.journal))
128
+ path = Path(self.journal)
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ return Journal(path)
131
+
132
+ def _registry(self) -> ToolRegistry:
133
+ if isinstance(self.tools, ToolRegistry):
134
+ return self.tools
135
+ return registry_of(self.tools)
136
+
137
+ def scheduler(self) -> Scheduler:
138
+ """A scheduler wired to this runtime's journal -- one per run or resume."""
139
+ journal = self._journal()
140
+ registry = self._registry()
141
+ return Scheduler(
142
+ graph=self.graph,
143
+ registry=registry,
144
+ journal=journal,
145
+ buffer=StoreBuffer(journal=journal, run_id=""),
146
+ dispatcher=Dispatcher(
147
+ registry=registry,
148
+ max_attempts=self.max_attempts,
149
+ base_delay_ms=self.base_delay_ms,
150
+ ),
151
+ target=JournaledModel(self.model or _NoModel(), journal),
152
+ policy=self.policy,
153
+ reducers=dict(self.reducers),
154
+ )
155
+
156
+ async def run(
157
+ self, inputs: Mapping[str, JsonValue] | None = None, *, run_id: str | None = None
158
+ ) -> RunResult:
159
+ """Run the graph to the end. ``result.run_id`` is what :meth:`resume` takes."""
160
+ return await self.scheduler().run(run_id or new_ulid(), dict(inputs or {}))
161
+
162
+ async def resume(self, run_id: str, *, ask_abandoned: bool = False) -> RunResult:
163
+ """Continue a run a crash interrupted, without sending again what already went out.
164
+
165
+ ``ask_abandoned``: where a node keeps waiting on a turn the crashed run had stopped
166
+ waiting for, the model is asked again, live, at the point the node would be stopped
167
+ with ``TurnAbandoned`` -- that turn only, and not one the node stops waiting for again
168
+ as the crashed run did. Its answer may differ from what was acted on.
169
+ """
170
+ return await self.scheduler().resume(run_id, ask_abandoned=ask_abandoned)
File without changes
@@ -0,0 +1,171 @@
1
+ """Dispatch: at-least-once, with deterministic idempotency keys.
2
+
3
+ The docs say "at-least-once dispatch with deterministic idempotency keys" and never claim
4
+ "exactly-once", because the only thing that makes a repeated delivery harmless is the *tool*
5
+ honouring its key -- and whether it does is the developer's word, measured in attack 7.4
6
+ rather than assumed. The vocabulary test fails the build on an unqualified claim to the
7
+ contrary, including one made here.
8
+
9
+ Backoff is exponential and **unjittered by default**. Jitter is the usual advice and it is
10
+ wrong here: the chaos matrix has to reproduce a failure it found, and a random delay makes a
11
+ leak that appeared once unreproducible.
12
+
13
+ Every failure reports whether the request *left the process*. That single bit is what keeps
14
+ the ambiguous crash window narrow: a connection refused before any bytes went out is safe to
15
+ retry, while a timeout after the request was sent is the two-generals boundary and is treated
16
+ as such.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from collections.abc import Awaitable, Callable
23
+ from dataclasses import dataclass
24
+ from typing import Literal
25
+
26
+ from specunode.canonical import JsonValue
27
+ from specunode.core.effects import ToolRegistry, UnknownTool
28
+ from specunode.core.model import CallScope, call_scope
29
+
30
+ __all__ = ["DispatchOutcome", "Dispatcher", "ToolDispatchError"]
31
+
32
+ Sent = Literal["no", "maybe"]
33
+
34
+
35
+ class ToolDispatchError(RuntimeError):
36
+ """A dispatch attempt failed.
37
+
38
+ ``sent`` is the load-bearing field. ``"no"`` means nothing left this process, so a retry
39
+ cannot duplicate anything. ``"maybe"`` -- the default, because it is the safe assumption --
40
+ means the upstream may already have acted.
41
+ """
42
+
43
+ def __init__(self, message: str, *, sent: Sent = "maybe", retriable: bool = True) -> None:
44
+ super().__init__(message)
45
+ self.sent: Sent = sent
46
+ self.retriable = retriable
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class DispatchOutcome:
51
+ ok: bool
52
+ ack: JsonValue = None
53
+ attempts: int = 0
54
+ error: str | None = None
55
+ sent: Sent = "no"
56
+ #: True when the dispatcher was asked not to call the tool. Carried through to the journal
57
+ #: and the ledger, because a row that reads DISPATCHED for an effect that never left is
58
+ #: exactly the kind of untrue record this project exists to make impossible.
59
+ dry_run: bool = False
60
+
61
+
62
+ @dataclass
63
+ class Dispatcher:
64
+ """Sends a staged effect to the world, with bounded retries."""
65
+
66
+ registry: ToolRegistry
67
+ max_attempts: int = 5
68
+ base_delay_ms: float = 50.0
69
+ cap_delay_ms: float = 5000.0
70
+ #: Off by default so a chaos run can reproduce what it found. Turn it on in production,
71
+ #: where a thundering herd matters more than reproducibility.
72
+ jitter: bool = False
73
+ #: Never call the tool; report the effect as it would have been sent.
74
+ #:
75
+ #: This exists for ``specunode replay``. Replaying a run against its journal re-drives the
76
+ #: graph, and a graph that re-drives dispatches -- so a replay of a run that charged a card
77
+ #: would charge it again, from a command whose whole purpose is to answer a question about
78
+ #: the past. The default there is therefore to dispatch nothing, and sending for real is
79
+ #: behind a flag that names what it is for.
80
+ dry_run: bool = False
81
+
82
+ def _delay_ms(self, attempt: int) -> float:
83
+ return float(min(self.base_delay_ms * (2.0 ** (attempt - 1)), self.cap_delay_ms))
84
+
85
+ async def dispatch(
86
+ self,
87
+ tool: str,
88
+ args: dict[str, JsonValue],
89
+ *,
90
+ idempotency_key: str,
91
+ branch_id: str,
92
+ before_attempt: Callable[[], Awaitable[None]] | None = None,
93
+ ) -> DispatchOutcome:
94
+ """Call ``tool``, retrying until it acks or the attempts run out.
95
+
96
+ A retry is only ever safe for one of two reasons: the attempt that failed demonstrably
97
+ sent nothing (``sent="no"``), or the tool declared a repeat harmless (``idempotent``).
98
+ After a failure that may have taken effect, a tool that did not declare it is not
99
+ called again -- the outcome is reported as ``sent="maybe"``, and the store buffer asks
100
+ the upstream (the tool's ``reconcile``) or dead-letters it. Retrying anyway charged a
101
+ card twice whenever a gateway took the charge and timed out on the reply, with the
102
+ default settings and no crash at all.
103
+
104
+ ``sent`` on a failed outcome is ``"maybe"`` if *any* attempt may have taken effect, not
105
+ whatever the last one said: an attempt that landed followed by one that was refused is
106
+ still an effect that may be out.
107
+
108
+ The call runs under a :class:`CallScope` carrying the branch and the key, which is how
109
+ the fake world attributes every mutation -- and therefore how the leak test can state
110
+ its invariant as a set comparison.
111
+
112
+ ``before_attempt`` runs before every attempt, and stops the dispatch by raising: the
113
+ store buffer checks there that this process still holds the run.
114
+ """
115
+ spec = self.registry.get(tool)
116
+ if self.dry_run:
117
+ # Resolved above, so an unknown tool is still an error here rather than something
118
+ # a dry run silently reports as fine and a real one then fails on.
119
+ return DispatchOutcome(
120
+ ok=True, ack={"dry_run": True}, attempts=0, sent="no", dry_run=True
121
+ )
122
+ last_error: str | None = None
123
+ maybe_sent = False
124
+
125
+ for attempt in range(1, self.max_attempts + 1):
126
+ if before_attempt is not None:
127
+ await before_attempt()
128
+ scope = CallScope(branch_id=branch_id, effect_key=idempotency_key, speculative=False)
129
+ token = call_scope.set(scope)
130
+ retriable = True
131
+ try:
132
+ ack = await spec.fn(**args)
133
+ return DispatchOutcome(ok=True, ack=ack, attempts=attempt, sent="maybe")
134
+ except UnknownTool as exc:
135
+ # Not retriable and not ambiguous: there was nothing to call.
136
+ return DispatchOutcome(
137
+ ok=False,
138
+ attempts=attempt,
139
+ error=str(exc),
140
+ sent="maybe" if maybe_sent else "no",
141
+ )
142
+ except ToolDispatchError as exc:
143
+ last_error, retriable = str(exc), exc.retriable
144
+ # Anything but a plain "no" may have landed -- fail safe on a value the tool
145
+ # got wrong, rather than retrying a write because it did not say "maybe".
146
+ maybe_sent = maybe_sent or exc.sent != "no"
147
+ except asyncio.CancelledError:
148
+ # A drain is never speculative, so a cancellation here is the process going
149
+ # away rather than a squash. Do not swallow it.
150
+ raise
151
+ except Exception as exc: # an adapter that raised something of its own
152
+ last_error, maybe_sent = f"{type(exc).__name__}: {exc}", True
153
+ finally:
154
+ call_scope.reset(token)
155
+
156
+ if not retriable or (maybe_sent and not spec.idempotent):
157
+ return DispatchOutcome(
158
+ ok=False,
159
+ attempts=attempt,
160
+ error=last_error,
161
+ sent="maybe" if maybe_sent else "no",
162
+ )
163
+ if attempt < self.max_attempts:
164
+ await asyncio.sleep(self._delay_ms(attempt) / 1000.0)
165
+
166
+ return DispatchOutcome(
167
+ ok=False,
168
+ attempts=self.max_attempts,
169
+ error=last_error,
170
+ sent="maybe" if maybe_sent else "no",
171
+ )
@@ -0,0 +1,160 @@
1
+ """Idempotency keys: one preimage, three derivations (Hard Rule 8).
2
+
3
+ key = blake2b(run_id | branch_lineage | node_id | step_index | tool_name | canonical(args))
4
+
5
+ The spec's formula, with the framing made explicit: the preimage is a canonical JSON *object*,
6
+ never a concatenation, because ``("ab", "c")`` and ``("a", "bc")`` concatenate to the same
7
+ bytes and two different effects must never share a key.
8
+
9
+ Three values come out of that one preimage, and confusing them is the subtle failure this
10
+ module exists to prevent:
11
+
12
+ ``key`` -- lineage-bearing, internal
13
+ Indexes the store buffer, names a :class:`StagedEffect`, and appears in the journal and the
14
+ ledger. The lineage is what keeps two siblings' staged rows apart *before* resolution:
15
+ they occupy the same ``step_index`` and the same ``node_id`` by construction, so without it
16
+ their entries alias and discarding the loser would drop the winner's row.
17
+
18
+ ``nkey`` -- lineage-free, run-scoped
19
+ The dedupe table's primary key, and the idempotency token handed to the tool adapter. It
20
+ must be lineage-free because the same logical effect legitimately arrives down different
21
+ paths: a stalled branch's effect is discarded and re-staged by the sequential re-execution
22
+ under a new branch id, and a resume re-mints branch ids entirely. Keyed on ``key``, every
23
+ one of those would re-dispatch -- a second charge on the same card.
24
+
25
+ ``ekey`` -- lineage-free *and* run-free, comparison only
26
+ Used by :func:`~specunode.verify.equivalence.normalise_for_equivalence` and nowhere else.
27
+ It carries a different domain prefix so that it cannot be mistaken for a dispatch key even
28
+ if it were passed somewhere one was expected.
29
+
30
+ Lineage contributes no uniqueness among *dispatched* effects, because at most one branch per
31
+ program position ever retires. That is exactly why stripping it is sound for dedupe and for
32
+ equivalence, and exactly why it must stay in ``key``.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from collections.abc import Mapping, Sequence
38
+ from hashlib import blake2b
39
+ from typing import NewType
40
+
41
+ from specunode.canonical import JsonValue, canonical
42
+
43
+ __all__ = [
44
+ "EQUIV_DOMAIN",
45
+ "KEY_DOMAIN",
46
+ "DedupeKey",
47
+ "EquivalenceKey",
48
+ "IdempotencyKey",
49
+ "dedupe_key",
50
+ "equivalence_key",
51
+ "idempotency_key",
52
+ "key_preimage",
53
+ ]
54
+
55
+ #: Domain separators, so a key can never collide with a journal entry hash or a ledger
56
+ #: signature preimage even when the same bytes are fed to all three.
57
+ KEY_DOMAIN = b"specunode/idempotency/v1\x00"
58
+ EQUIV_DOMAIN = b"specunode/equiv-key/v1\x00"
59
+
60
+ IdempotencyKey = NewType("IdempotencyKey", str)
61
+ DedupeKey = NewType("DedupeKey", str)
62
+ EquivalenceKey = NewType("EquivalenceKey", str)
63
+
64
+
65
+ def key_preimage(
66
+ *,
67
+ run_id: str,
68
+ lineage: Sequence[str],
69
+ node_id: str,
70
+ step_index: int,
71
+ tool_name: str,
72
+ args: Mapping[str, JsonValue],
73
+ ) -> bytes:
74
+ """The canonical bytes every key derivation hashes.
75
+
76
+ Public, because the property tests and ``docs/replay.md`` assert on these exact bytes: a
77
+ key nobody can reproduce by hand is a key nobody can audit.
78
+ """
79
+ return canonical(
80
+ {
81
+ "run_id": run_id,
82
+ "lineage": list(lineage),
83
+ "node_id": node_id,
84
+ "step_index": step_index,
85
+ "tool": tool_name,
86
+ "args": args,
87
+ }
88
+ )
89
+
90
+
91
+ def _digest(domain: bytes, preimage: bytes) -> str:
92
+ return blake2b(domain + preimage, digest_size=32).hexdigest()
93
+
94
+
95
+ def idempotency_key(
96
+ *,
97
+ run_id: str,
98
+ lineage: Sequence[str],
99
+ node_id: str,
100
+ step_index: int,
101
+ tool_name: str,
102
+ args: Mapping[str, JsonValue],
103
+ ) -> IdempotencyKey:
104
+ """Hard Rule 8's key, verbatim. Internal: indexes the store buffer, never leaves."""
105
+ return IdempotencyKey(
106
+ _digest(
107
+ KEY_DOMAIN,
108
+ key_preimage(
109
+ run_id=run_id,
110
+ lineage=lineage,
111
+ node_id=node_id,
112
+ step_index=step_index,
113
+ tool_name=tool_name,
114
+ args=args,
115
+ ),
116
+ )
117
+ )
118
+
119
+
120
+ def dedupe_key(
121
+ *,
122
+ run_id: str,
123
+ node_id: str,
124
+ step_index: int,
125
+ tool_name: str,
126
+ args: Mapping[str, JsonValue],
127
+ ) -> DedupeKey:
128
+ """The value the dedupe table and the tool adapter see. Lineage-free by construction."""
129
+ return DedupeKey(
130
+ _digest(
131
+ KEY_DOMAIN,
132
+ key_preimage(
133
+ run_id=run_id,
134
+ lineage=(),
135
+ node_id=node_id,
136
+ step_index=step_index,
137
+ tool_name=tool_name,
138
+ args=args,
139
+ ),
140
+ )
141
+ )
142
+
143
+
144
+ def equivalence_key(
145
+ *, node_id: str, step_index: int, tool_name: str, args: Mapping[str, JsonValue]
146
+ ) -> EquivalenceKey:
147
+ """Comparison only (Hard Rule 9). Run-free, lineage-free, and never dispatchable."""
148
+ return EquivalenceKey(
149
+ _digest(
150
+ EQUIV_DOMAIN,
151
+ key_preimage(
152
+ run_id="",
153
+ lineage=(),
154
+ node_id=node_id,
155
+ step_index=step_index,
156
+ tool_name=tool_name,
157
+ args=args,
158
+ ),
159
+ )
160
+ )