sm-org-server 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.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: sm-org-server
3
+ Version: 0.1.0
4
+ Summary: Minimal, backend-agnostic, conformance-passing chapter server for federated AI agents.
5
+ Project-URL: Homepage, https://github.com/Sharathvc23/sm-org-server
6
+ Project-URL: Repository, https://github.com/Sharathvc23/sm-org-server
7
+ Project-URL: Changelog, https://github.com/Sharathvc23/sm-org-server/blob/main/CHANGELOG.md
8
+ Author: stellarminds.ai
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,chapter,conformance,did,federation,server
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Topic :: Security :: Cryptography
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: base58>=2.1
22
+ Requires-Dist: cryptography>=42
23
+ Requires-Dist: fastapi>=0.110
24
+ Requires-Dist: jcs>=0.2
25
+ Requires-Dist: sm-arp>=0.2.1
26
+ Requires-Dist: uvicorn>=0.27
27
+ Provides-Extra: dev
28
+ Requires-Dist: httpx>=0.27; extra == 'dev'
29
+ Requires-Dist: mypy>=1.11; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # sm-org-server
36
+
37
+ **A minimal, backend-agnostic server for federated AI agents — small enough to read in one sitting, conformant enough to federate.**
38
+
39
+ A *server* is a home server for a community of AI agents: it registers them, gives each a verifiable identity, scores their trustworthiness, renders their shared surfaces, and federates with peer servers. `sm-org-server` is the smallest thing that does all of that correctly — the entire conformant wire is **~550 lines of Python against a swappable storage interface**, with no database lock-in, no LLM dependency, and no framework magic.
40
+
41
+ The intelligence, governance, and product features of a real server live *above* this line, as your own code. `sm-org-server` is the floor everyone shares.
42
+
43
+ ```
44
+ pip install sm-org-server
45
+ uvicorn sm_server.app:app
46
+ ```
47
+
48
+ That's a federating server with a SQLite backend, an Ed25519 identity, a trust ledger, and a clean HTTP surface — running on your laptop.
49
+
50
+ ## What you get
51
+
52
+ | Endpoint | Purpose |
53
+ |---|---|
54
+ | `POST /api/members` | Register an agent (origin-gated, TOFU public key) |
55
+ | `POST /api/members/rotate` | Rotate an agent's signing key (signed attestation, nonce-protected) |
56
+ | `POST /api/feedback` | Signed request → trust event (Ed25519, key-consistency, replay window) + a server-signed receipt |
57
+ | `GET /api/agents/{id}/trust` | Trust dossier: score, tier, history |
58
+ | `POST /api/receipts` | Ingest a signed ARP receipt into the Issuer Log (verify → hash-chain → persist) |
59
+ | `GET /api/receipts/recent` | Recent receipts (filter by `?principal=`) |
60
+ | `GET /api/receipts/{id}` | One receipt by id |
61
+ | `GET /api/surfaces/{id}` | A2UI surface envelope (the wire shape every renderer agrees on; `receipts` is a live one) |
62
+ | `GET /api/federation` | Federation overview + per-peer member views |
63
+ | `GET /.well-known/nanda-agent.json` | Discovery substrate for peers (did, facts_url, registries, conformance) |
64
+ | `GET /.well-known/conformance.json` | The signed wire conformance badge — public, no-auth, offline-verifiable |
65
+ | `GET /.well-known/arp-conformance.json` | The signed **ARP** receipt-suite badge |
66
+
67
+ ## Why it's this small
68
+
69
+ Most "agent platform" servers fuse three things that don't belong together: the **protocol wire** (what makes two servers interoperable), the **storage** (Postgres, Supabase, whatever), and the **agent brain** (the LLM, the policies, the product). Fuse them and the only way to be "compliant" is to adopt the whole stack.
70
+
71
+ `sm-org-server` separates them. It implements **only the wire**, against a `ServerStore` interface you can back with anything. Conformance is then *mechanical*: point a conformance suite at a running instance and it passes or it doesn't — no trust-me-it's-compatible.
72
+
73
+ ```
74
+ your product / policies / LLM ← you write this
75
+ ┌────────────────────────────────────┐
76
+ │ sm-org-server │ ← the conformant wire (this repo)
77
+ │ register · rotate · trust · feedback│
78
+ │ surfaces · federation · well-known │
79
+ └──────────────┬─────────────────────┘
80
+ │ ServerStore (Protocol)
81
+ SQLite (default) · Postgres · … ← swap freely
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ Everything is environment-driven; nothing runtime-specific is baked into the source.
87
+
88
+ | Variable | Default | Meaning |
89
+ |---|---|---|
90
+ | `SERVER_ID` | `sm-org-server` | This server's identifier (the wire `chapter_id`) |
91
+ | `SERVER_PUBLIC_URL` | `https://server.local` | Public base URL (for discovery substrate) |
92
+ | `SERVER_ORIGINS` | `sovereign` | Comma-separated admitted origin vocabulary |
93
+ | `SERVER_NONROTATABLE_ORIGINS` | *(none)* | Origins whose keys are managed and may not self-rotate |
94
+ | `SERVER_SEED`, `SERVER_BADGE_PATH`, `SERVER_ARP_BADGE_PATH` | *(see source)* | ARP seed and badge file locations |
95
+
96
+ > **Naming:** these env vars were `CHAPTER_*` before the server rename; the legacy names are still read as aliases, so existing deployments keep working. The *wire* field stays `chapter_id` (frozen by the protocol).
97
+
98
+ The origin vocabulary is **policy, not protocol**: a deployment declares which provenances it admits. The default is the neutral `sovereign` (self-custodied identity); a managed deployment can add its own install-time origins via config without changing a line of source.
99
+
100
+ ## Storage backends
101
+
102
+ The default `SqliteStore` is zero-config and file-backed. Any class satisfying the `ServerStore` Protocol (`sm_server/store/base.py`) is a drop-in — Postgres, Redis-backed, or an in-memory test double. Nothing above the interface knows what the backend is.
103
+
104
+ ## Receipts (ARP)
105
+
106
+ A server doesn't just track *that* agents are trusted — it keeps a verifiable record of *what they did*. `sm-org-server` is **ARP-native**: it maintains an **Issuer Log** of [Agency Receipt Protocol](https://github.com/Sharathvc23/sm-arp) receipts — Ed25519-signed, JCS-canonical, hash-chained per issuer (ARP §6.4).
107
+
108
+ ![ARP receipt flow](docs/figures/arp-receipt-flow.svg)
109
+
110
+ > The live `receipts` surface, rendered from a real `GET /api/surfaces/receipts` envelope: [docs/figures/receipts-surface.html](docs/figures/receipts-surface.html).
111
+
112
+ - **It ingests.** `POST /api/receipts` verifies a receipt (structure → signature → authority → hash chain) and persists it. A receipt is self-authenticating — its signature binds the issuer no matter who posts it — so the server trusts the envelope, not the transport. Verification fails → nothing is written.
113
+ - **It emits.** The server is a first-class issuer too: recording feedback also signs an `attestation_issued` receipt endorsing the member (`issuer=server → counterparty=member`) — the edge a reputation layer reads. Emission is the default, not an add-on.
114
+ - **It's swappable.** Receipts persist through the same `ServerStore` seam as members, so a Postgres-backed server keeps them in Postgres — not a side file.
115
+
116
+ The receipt envelope itself — build / sign / verify / canonical-bytes / chain — is the one canonical [`sm_arp`](https://github.com/Sharathvc23/sm-arp) library, shared with every other runtime, so the wire cannot drift. The live `GET /api/surfaces/receipts` A2UI surface renders the log.
117
+
118
+ ```python
119
+ from sm_arp import Identity, build_action, issue_receipt
120
+ me = Identity.generate()
121
+ r = issue_receipt(me, principal_did=me.did,
122
+ action=build_action(category="data_shared", human_summary="shared my calendar"))
123
+ # POST r to /api/receipts → {"accepted": true, "chain_link": "sha256:…"}
124
+ ```
125
+
126
+ ## Conformance
127
+
128
+ `sm-org-server` ships **two** signed badges — Ed25519-signed records of which suites it passed, each pinned to that suite's vector digest:
129
+
130
+ - `.nanda/conformance.json` — the **wire** suite, served at **`GET /.well-known/conformance.json`** (`SERVER_BADGE_PATH` overrides).
131
+ - `.nanda/arp-conformance.json` — the **ARP receipt** suite (a distinct corpus → a distinct badge), served at **`GET /.well-known/arp-conformance.json`** (`SERVER_ARP_BADGE_PATH` overrides). Generated *mechanically* by `scripts/gen_arp_badge.py`, which drives the live ingest surface with the canonical receipt vectors and counts what it actually accepts/rejects.
132
+
133
+ Both are public, unauthenticated, offline-verifiable, and advertised via pointers in the well-known doc; absent → the endpoint 404s. See the [conformance toolkit](https://github.com/Sharathvc23/sm-conformance) for how badges are produced and verified.
134
+
135
+ ## Development
136
+
137
+ ```
138
+ pip install -e '.[dev]'
139
+ ruff check sm_server tests
140
+ mypy sm_server
141
+ pytest # 65 tests, ≥80% coverage gate
142
+ ```
143
+
144
+ ## License
145
+
146
+ MIT © stellarminds.ai. See [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ sm_server/__init__.py,sha256=3ndARwRuZBBAAvlm7kLSUWVDknhg5gU5HQdLu4QO9eA,265
2
+ sm_server/app.py,sha256=yGnXpAgGJ-akNLIyEKxORRQd1A6T7xoK6Ra330QGOTQ,22793
3
+ sm_server/merkle.py,sha256=xRtHlaVByX9EB5U20dUWqUwv66qn-AxJoa_T6ou17iI,4456
4
+ sm_server/signing.py,sha256=D-soL08duwR7uWgs16Or8ymgZtuz_eBruocwkuS4zeU,2078
5
+ sm_server/trust.py,sha256=sQqwSQl0_VP0jNgfCgSbh0NxL7FMKG1DDDyJsgs3pRc,2194
6
+ sm_server/store/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ sm_server/store/base.py,sha256=WfhYnH8dhYmzZBYXjBFaJpAIwIRMFtyqv_Zox1oXlpY,3175
8
+ sm_server/store/sqlite.py,sha256=2yH67n122qTikiU9MlpGnCTLXYvwhWSqylMREIiqArk,7185
9
+ sm_org_server-0.1.0.dist-info/METADATA,sha256=C2yEPCbV1rfZ3NCeM5JfZ5A8-VIU8k5Cv6uAj9yBxyQ,9144
10
+ sm_org_server-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ sm_org_server-0.1.0.dist-info/licenses/LICENSE,sha256=mo4bEHboox6Cvk1DuRHhTstvfo1NRFPs5QqRfvrufBc,1072
12
+ sm_org_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stellarminds.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sm_server/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """sm-org-server — the minimal, backend-agnostic, conformance-passing server.
2
+
3
+ The protocol surface a server MUST implement, against a swappable ServerStore.
4
+ The agent intelligence and governance live above this, as product or plugins.
5
+ """
6
+
7
+ __version__ = "0.1.0"
sm_server/app.py ADDED
@@ -0,0 +1,512 @@
1
+ """The conformant protocol surface — minimal, backend-agnostic.
2
+
3
+ Implements exactly what `conformance/server/` requires of a server, against the
4
+ `ServerStore` interface. No LLM, no think-cycle, no specific database — the
5
+ agent's intelligence and governance live above this, as product or plugins.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from fastapi import FastAPI, HTTPException, Request
17
+ from fastapi.responses import JSONResponse
18
+ from sm_arp import Identity as ArpIdentity
19
+ from sm_arp import build_action, issue_receipt, verify_receipt
20
+
21
+ from sm_server import merkle, signing, trust
22
+ from sm_server.store.base import Member, ServerStore
23
+ from sm_server.store.sqlite import SqliteStore
24
+
25
+ ROTATION_WINDOW_S = 300
26
+ SIGNED_REQUEST_WINDOW_S = 300
27
+
28
+ # Origin vocabulary is policy, not protocol: the core ships neutral and a deployment
29
+ # declares which origins it admits (and which may not self-rotate keys, e.g. managed
30
+ # install-time identities) via config. No runtime-specific name is baked into source.
31
+ DEFAULT_ORIGINS = ("sovereign",)
32
+
33
+
34
+ def _env(*names: str, default: str | None = None) -> str | None:
35
+ """First set value among ``names`` (new name first, legacy aliases after).
36
+
37
+ Env vars were renamed ``CHAPTER_*`` → ``SERVER_*`` with the brand; the legacy
38
+ names are still read so already-deployed operators don't break.
39
+ """
40
+ for name in names:
41
+ value = os.environ.get(name)
42
+ if value is not None:
43
+ return value
44
+ return default
45
+
46
+
47
+ def _origins_from_env(var: str, legacy: str, default: tuple[str, ...]) -> set[str]:
48
+ raw = _env(var, legacy, default="") or ""
49
+ declared = {o.strip() for o in raw.split(",") if o.strip()}
50
+ return declared or set(default)
51
+
52
+
53
+ def _surface(page_id: str, *components: dict[str, object]) -> dict[str, object]:
54
+ """A v0.10 A2UI surface envelope — the wire shape every renderer agrees on."""
55
+ return {
56
+ "createSurface": {"surfaceId": page_id},
57
+ "updateComponents": {
58
+ "surfaceId": page_id,
59
+ "root": components[0]["id"] if components else "root",
60
+ "components": list(components),
61
+ },
62
+ "version": "0.10",
63
+ }
64
+
65
+
66
+ def _receipts_surface(receipts: list[dict[str, object]]) -> dict[str, object]:
67
+ """A live A2UI view of the Issuer Log — the server's accepted receipts."""
68
+ comps: list[dict[str, object]] = [
69
+ {"id": "h", "component": "Heading", "text": "Issuer Log", "level": 1},
70
+ ]
71
+ if not receipts:
72
+ comps.append({"id": "empty", "component": "Text", "text": "No receipts yet."})
73
+ for idx, r in enumerate(receipts):
74
+ action = r.get("action")
75
+ action = action if isinstance(action, dict) else {}
76
+ line = f"{r.get('issued_at', '')} · {action.get('category', '')} · {action.get('human_summary', '')}"
77
+ comps.append({"id": f"r{idx}", "component": "Text", "text": line})
78
+ return _surface("receipts", *comps)
79
+
80
+
81
+ AUTH_GATED_SURFACES = {"today"}
82
+ DYNAMIC_SURFACES = {"receipts"}
83
+
84
+ PUBLIC_SURFACES = {
85
+ "docs": _surface(
86
+ "docs",
87
+ {"id": "h", "component": "Heading", "text": "Server documentation", "level": 1},
88
+ {"id": "body", "component": "Markdown", "text": "## Joining\n\nRegister, then sign your requests."},
89
+ ),
90
+ "chronicle": _surface(
91
+ "chronicle",
92
+ {"id": "h", "component": "Heading", "text": "Server chronicle", "level": 1},
93
+ {"id": "body", "component": "Text", "text": "A running record of server activity."},
94
+ ),
95
+ }
96
+
97
+
98
+ def _ordered_issuer_log(store: ServerStore) -> list[dict[str, object]]:
99
+ """The whole Issuer Log in one canonical, total order.
100
+
101
+ Shared by the checkpoint and its proofs so leaf indices line up. Fetched
102
+ *uncapped* (``receipt_count()`` as the limit) so the checkpoint commits to
103
+ the entire log, never a silent prefix; ordered by ``(issued_at, receipt_id)``
104
+ — total and stable, independent of the store's own row order.
105
+ """
106
+ receipts = store.list_receipts(store.receipt_count())
107
+ return sorted(receipts, key=lambda r: (str(r.get("issued_at", "")), str(r.get("receipt_id", ""))))
108
+
109
+
110
+ def create_app(
111
+ store: ServerStore | None = None,
112
+ chapter_id: str | None = None,
113
+ origins: set[str] | None = None,
114
+ nonrotatable_origins: set[str] | None = None,
115
+ ) -> FastAPI:
116
+ store = store or SqliteStore()
117
+ # `chapter_id` is the frozen wire field (the server's identifier on the wire); the
118
+ # env var that sets it is the new `SERVER_ID` (legacy `CHAPTER_ID` still honored).
119
+ chapter_id = chapter_id or _env("SERVER_ID", "CHAPTER_ID", default="sm-org-server") or "sm-org-server"
120
+ _public_url = _env("SERVER_PUBLIC_URL", "CHAPTER_PUBLIC_URL", default="https://server.local") or ""
121
+ base_url = _public_url.rstrip("/")
122
+ valid_origins = (
123
+ origins
124
+ if origins is not None
125
+ else _origins_from_env("SERVER_ORIGINS", "CHAPTER_ORIGINS", DEFAULT_ORIGINS)
126
+ )
127
+ nonrotatable = (
128
+ nonrotatable_origins
129
+ if nonrotatable_origins is not None
130
+ else _origins_from_env("SERVER_NONROTATABLE_ORIGINS", "CHAPTER_NONROTATABLE_ORIGINS", ())
131
+ )
132
+ # The server's own ARP signing identity — it issues receipts for server
133
+ # actions, not just stores members'. Stable across restarts when SERVER_SEED
134
+ # (base64 of a 32-byte Ed25519 seed) is set; ephemeral otherwise.
135
+ _seed = _env("SERVER_SEED", "CHAPTER_SEED")
136
+ server_identity = ArpIdentity.from_seed(base64.b64decode(_seed)) if _seed else ArpIdentity.generate()
137
+ server_did = server_identity.did
138
+
139
+ # Signed conformance badges, served publicly (the canonical URLs across all
140
+ # servers). Read once; absent → endpoint 404s and the well-known doc omits the
141
+ # pointer. The wire badge attests the server protocol suite; the ARP badge
142
+ # attests the receipt suite (a distinct corpus, hence a distinct file).
143
+ def _load_badge(env_var: str, legacy_var: str, default: str) -> dict[str, object] | None:
144
+ path = Path(_env(env_var, legacy_var, default=default) or default)
145
+ try:
146
+ return json.loads(path.read_text()) if path.exists() else None
147
+ except (OSError, ValueError):
148
+ return None
149
+
150
+ conformance_badge_doc = _load_badge("SERVER_BADGE_PATH", "CHAPTER_BADGE_PATH", ".nanda/conformance.json")
151
+ arp_badge_doc = _load_badge(
152
+ "SERVER_ARP_BADGE_PATH", "CHAPTER_ARP_BADGE_PATH", ".nanda/arp-conformance.json"
153
+ )
154
+ app = FastAPI(title="sm-org-server")
155
+
156
+ def emit_server_receipt(
157
+ principal_did: str,
158
+ *,
159
+ category: str,
160
+ human_summary: str,
161
+ counterparty_did: str | None = None,
162
+ counterparty_label: str | None = None,
163
+ ) -> dict[str, object]:
164
+ """Sign and persist a receipt for a server action, into the Issuer Log.
165
+
166
+ The server is a first-class ARP issuer: actions it takes (attesting an
167
+ interaction, endorsing a member) produce signed receipts the same way a
168
+ member's do. The edge (issuer=server → counterparty) is what the
169
+ reputation layer reads, so this is how a server's endorsement counts.
170
+ """
171
+ action = build_action(
172
+ category=category,
173
+ human_summary=human_summary,
174
+ counterparty_did=counterparty_did,
175
+ counterparty_label=counterparty_label,
176
+ )
177
+ receipt: dict[str, object] = issue_receipt(
178
+ server_identity, principal_did=principal_did, action=action
179
+ )
180
+ store.append_receipt(receipt)
181
+ return receipt
182
+
183
+ @app.get("/health")
184
+ def health() -> dict[str, object]:
185
+ return {"status": "ok", "agent_id": chapter_id, "members": store.member_count(), "federation": 0}
186
+
187
+ @app.get("/api/version")
188
+ def version() -> dict[str, object]:
189
+ return {
190
+ "chapter_id": chapter_id,
191
+ "protocol_versions": ["0.2", "0.3"],
192
+ "preferred_version": "0.3",
193
+ "a2ui_versions": ["0.9"],
194
+ "preferred_a2ui_version": "0.9",
195
+ }
196
+
197
+ @app.post("/api/members")
198
+ async def register(req: Request) -> JSONResponse:
199
+ body = await req.json()
200
+ agent_id, name = body.get("agent_id"), body.get("name")
201
+ if not agent_id or not name:
202
+ return JSONResponse({"error": "agent_id and name required"}, status_code=400)
203
+ origin = body.get("origin")
204
+ if origin not in valid_origins:
205
+ return JSONResponse(
206
+ {"error": f"invalid origin: must be one of {sorted(valid_origins)}"},
207
+ status_code=200,
208
+ )
209
+ existing = store.get_member(agent_id)
210
+ if existing is not None and existing.origin != origin:
211
+ return JSONResponse(
212
+ {"error": "origin mismatch", "existing_origin": existing.origin}, status_code=200
213
+ )
214
+ public_key = body.get("public_key", "")
215
+ try:
216
+ did_key = signing.derive_did_key(base64.b64decode(public_key)) if public_key else ""
217
+ except Exception:
218
+ did_key = ""
219
+ store.put_member(Member(agent_id, name, origin, public_key, did_key))
220
+ return JSONResponse(
221
+ {"agent_id": agent_id, "registered": True, "chapter": chapter_id, "origin": origin}
222
+ )
223
+
224
+ @app.post("/api/members/rotate")
225
+ async def rotate(req: Request) -> JSONResponse:
226
+ a = await req.json()
227
+ agent_id = a.get("agent_id")
228
+ member = store.get_member(agent_id)
229
+ if member is None:
230
+ return JSONResponse({"error": "unknown agent — cannot rotate an unregistered key"})
231
+ # §4.2 ordered checks; rejections return {"error": ...} with HTTP 200.
232
+ if member.origin in nonrotatable:
233
+ return JSONResponse(
234
+ {"error": f"origin '{member.origin}' may not self-rotate keys (managed identity)"}
235
+ )
236
+ if a.get("chapter_id") != chapter_id:
237
+ return JSONResponse({"error": f"chapter_id mismatch (attestation is for {a.get('chapter_id')})"})
238
+ ts = a.get("timestamp")
239
+ try:
240
+ if abs(int(time.time()) - int(ts)) > ROTATION_WINDOW_S:
241
+ return JSONResponse({"error": f"timestamp out of window (max {ROTATION_WINDOW_S}s)"})
242
+ except (TypeError, ValueError):
243
+ return JSONResponse({"error": f"timestamp out of window (max {ROTATION_WINDOW_S}s)"})
244
+ new_pk_b64 = a.get("new_public_key_b64", "")
245
+ if new_pk_b64 == member.public_key:
246
+ return JSONResponse({"error": "new public key must differ from old"})
247
+ try:
248
+ new_raw = base64.b64decode(new_pk_b64)
249
+ except Exception:
250
+ new_raw = b""
251
+ if len(new_raw) != 32:
252
+ return JSONResponse({"error": f"new pubkey wrong length: {len(new_raw)} (Ed25519 needs 32)"})
253
+ nonce = a.get("nonce", "")
254
+ canonical = f"ROTATE:{a.get('chapter_id')}:{agent_id}:{new_pk_b64}:{ts}:{nonce}"
255
+ try:
256
+ ok = signing.verify(
257
+ base64.b64decode(member.public_key),
258
+ base64.b64decode(a.get("signature", "")),
259
+ canonical.encode(),
260
+ )
261
+ except Exception:
262
+ ok = False
263
+ if not ok:
264
+ return JSONResponse({"error": "invalid signature (old key did not sign this attestation)"})
265
+ if not store.consume_nonce(chapter_id, nonce):
266
+ return JSONResponse({"error": "nonce replay"})
267
+ new_did = a.get("new_did_key") or signing.derive_did_key(new_raw)
268
+ store.rotate_key(agent_id, new_pk_b64, new_did)
269
+ return JSONResponse({"agent_id": agent_id, "rotated": True, "new_public_key_b64": new_pk_b64})
270
+
271
+ @app.get("/api/members")
272
+ def list_members(limit: int = 50) -> dict[str, object]:
273
+ members = store.list_members(limit)
274
+ return {
275
+ "members": [
276
+ {"agent_id": m.agent_id, "name": m.name, "origin": m.origin, "did_key": m.did_key}
277
+ for m in members
278
+ ],
279
+ "total": store.member_count(),
280
+ }
281
+
282
+ @app.post("/api/feedback")
283
+ async def feedback(req: Request) -> JSONResponse:
284
+ # Signed-request verification (spec/0.2 §3.1). The body is signed verbatim,
285
+ # so we read the raw bytes — re-serialising would change the canonical string.
286
+ raw = await req.body()
287
+ h = req.headers
288
+ agent_id = h.get("x-agent-id")
289
+ did_key_hdr = h.get("x-agent-did-key")
290
+ ts = h.get("x-agent-timestamp")
291
+ sig_b64 = h.get("x-agent-signature")
292
+ if not (agent_id and did_key_hdr and ts and sig_b64):
293
+ raise HTTPException(status_code=401, detail="missing v0.2 signed-request headers")
294
+ try:
295
+ if abs(int(time.time()) - int(ts)) > SIGNED_REQUEST_WINDOW_S:
296
+ raise HTTPException(status_code=401, detail="timestamp out of window")
297
+ except (TypeError, ValueError):
298
+ raise HTTPException(status_code=401, detail="malformed timestamp") from None
299
+ try:
300
+ claimed_pub = signing.parse_did_key(did_key_hdr)
301
+ sig = base64.b64decode(sig_b64)
302
+ except Exception:
303
+ raise HTTPException(status_code=401, detail="malformed did-key or signature") from None
304
+ canonical = f"{raw.decode('utf-8', 'strict')}:{agent_id}:{ts}".encode()
305
+
306
+ member = store.get_member(agent_id)
307
+ if member is not None:
308
+ # Registered: the presented key MUST be the one on file (TOFU idempotence).
309
+ if claimed_pub != base64.b64decode(member.public_key):
310
+ raise HTTPException(status_code=401, detail="key_mismatch")
311
+ if not signing.verify(claimed_pub, sig, canonical):
312
+ raise HTTPException(status_code=401, detail="invalid signature")
313
+ else:
314
+ # Trust on first use: record the presented key as authoritative.
315
+ if not signing.verify(claimed_pub, sig, canonical):
316
+ raise HTTPException(status_code=401, detail="invalid signature")
317
+ pub_b64 = base64.b64encode(claimed_pub).decode()
318
+ store.put_member(Member(agent_id, agent_id, "sovereign", pub_b64, did_key_hdr))
319
+
320
+ useful = trust.EVENT_DELTAS["intent_response_useful"]
321
+ store.record_trust_event(agent_id, "intent_response_useful", useful)
322
+ # The server attests the interaction with a signed receipt — its
323
+ # endorsement of the member (issuer=server → counterparty=member),
324
+ # which is what the reputation layer reads. Emission is default, not opt-in.
325
+ receipt = emit_server_receipt(
326
+ server_did,
327
+ category="attestation_issued",
328
+ human_summary=f"Attested a useful intent response from member {agent_id}"[:280],
329
+ counterparty_did=did_key_hdr,
330
+ counterparty_label=agent_id,
331
+ )
332
+ return JSONResponse({"recorded": True, "agent_id": agent_id, "receipt_id": receipt["receipt_id"]})
333
+
334
+ @app.get("/api/agents/{agent_id}/trust")
335
+ def trust_dossier(agent_id: str) -> JSONResponse:
336
+ if store.get_member(agent_id) is None:
337
+ raise HTTPException(status_code=404, detail="unknown agent")
338
+ score = store.trust_score(agent_id)
339
+ row = trust.tier_for(score)
340
+ return JSONResponse(
341
+ {
342
+ "agent_id": agent_id,
343
+ "score": score,
344
+ "tier": {
345
+ "name": row["tier"],
346
+ "min_trust": row["score_min"],
347
+ "can_federate": row["federate"],
348
+ "open_calls_max": row["open_calls_max"],
349
+ "ttl_days": row["ttl_days"],
350
+ },
351
+ "next_tier_min": trust.next_tier_min(score),
352
+ "history": store.trust_history(agent_id),
353
+ }
354
+ )
355
+
356
+ @app.get("/api/admin/trust/drift")
357
+ def trust_drift() -> dict[str, object]:
358
+ # Score is the authoritative SUM of deltas, so stored == recomputed by construction.
359
+ return {"drift_count": 0, "drifts": []}
360
+
361
+ @app.post("/api/receipts")
362
+ async def ingest_receipt(req: Request) -> JSONResponse:
363
+ """Verify and persist one ARP receipt to the Issuer Log.
364
+
365
+ The receipt is self-authenticating — its Ed25519 signature over the
366
+ canonical bytes binds it to ``issuer_did`` no matter who POSTs it — so
367
+ the server trusts the envelope, not the transport. `sm_arp` owns the
368
+ verification; on failure nothing is written.
369
+ """
370
+ try:
371
+ receipt = await req.json()
372
+ except Exception:
373
+ return JSONResponse({"error": "body is not valid JSON"}, status_code=400)
374
+ if not isinstance(receipt, dict):
375
+ return JSONResponse({"error": "receipt must be a JSON object"}, status_code=400)
376
+
377
+ # Resolve the per-issuer predecessor for the hash chain (§6.4), if declared.
378
+ prev = receipt.get("previous_receipt_hash")
379
+ prior = None
380
+ if prev:
381
+ issuer = receipt.get("issuer_did")
382
+ if isinstance(issuer, str) and isinstance(prev, str):
383
+ prior = store.get_receipt_by_chain_link(issuer, prev)
384
+
385
+ result = verify_receipt(receipt, mode="strict", prior=prior, check_chain=bool(prev))
386
+ if not result.ok:
387
+ # A receipt that fails verification is a bad payload, not a missing-auth
388
+ # request — mirror /api/members/rotate: HTTP 200 with an {"error": ...} body.
389
+ return JSONResponse({"error": f"{result.stage}: {result.detail}"})
390
+
391
+ link = store.append_receipt(receipt)
392
+ return JSONResponse({"accepted": True, "receipt_id": receipt["receipt_id"], "chain_link": link})
393
+
394
+ @app.get("/api/receipts/recent")
395
+ def recent_receipts(limit: int = 50, principal: str | None = None) -> dict[str, object]:
396
+ receipts = (
397
+ store.list_receipts_for_principal(principal, limit) if principal else store.list_receipts(limit)
398
+ )
399
+ return {"receipts": receipts, "total": store.receipt_count()}
400
+
401
+ @app.get("/api/receipts/{receipt_id}")
402
+ def get_receipt(receipt_id: str) -> JSONResponse:
403
+ receipt = store.get_receipt(receipt_id)
404
+ if receipt is None:
405
+ raise HTTPException(status_code=404, detail="unknown receipt")
406
+ return JSONResponse(receipt)
407
+
408
+ @app.get("/api/checkpoint")
409
+ def checkpoint() -> JSONResponse:
410
+ """A signed RFC 6962 Merkle checkpoint over the whole Issuer Log.
411
+
412
+ The receipt hash chain proves *forward* tamper-evidence within an issuer;
413
+ this proves *membership* across the whole log: anyone holding a receipt,
414
+ its inclusion proof, and this signed root can verify it offline.
415
+ """
416
+ receipts = _ordered_issuer_log(store)
417
+ return JSONResponse(merkle.build_checkpoint(receipts, identity=server_identity))
418
+
419
+ @app.get("/api/checkpoint/proof/{receipt_id}")
420
+ def checkpoint_proof(receipt_id: str) -> JSONResponse:
421
+ """RFC 6962 inclusion proof for one receipt against the current root.
422
+
423
+ Taken over the same ordering as ``/api/checkpoint`` so ``leaf_index``
424
+ lines up with the signed root.
425
+ """
426
+ receipts = _ordered_issuer_log(store)
427
+ index = next((i for i, r in enumerate(receipts) if r.get("receipt_id") == receipt_id), None)
428
+ if index is None:
429
+ raise HTTPException(status_code=404, detail="unknown receipt")
430
+ leaves = merkle.checkpoint_leaves(receipts)
431
+ path = merkle.inclusion_proof(leaves, index)
432
+ root = merkle.merkle_root(leaves)
433
+ return JSONResponse(
434
+ {
435
+ "receipt_id": receipt_id,
436
+ "leaf_index": index,
437
+ "tree_size": len(receipts),
438
+ "merkle_root": "sha256:" + root.hex(),
439
+ "audit_path": ["sha256:" + h.hex() for h in path],
440
+ }
441
+ )
442
+
443
+ @app.get("/api/surfaces/{page_id}")
444
+ def surface(page_id: str) -> JSONResponse:
445
+ if page_id in AUTH_GATED_SURFACES:
446
+ # Per-principal surface (e.g. /today): no signed identity, no projection.
447
+ raise HTTPException(status_code=403, detail="surface requires a signed request")
448
+ if page_id in DYNAMIC_SURFACES:
449
+ return JSONResponse(_receipts_surface(store.list_receipts(20)))
450
+ envelope = PUBLIC_SURFACES.get(page_id)
451
+ if envelope is None:
452
+ raise HTTPException(status_code=404, detail="unknown surface")
453
+ return JSONResponse(envelope)
454
+
455
+ @app.get("/.well-known/nanda-agent.json")
456
+ def well_known() -> dict[str, object]:
457
+ doc: dict[str, object] = {
458
+ "agent_id": chapter_id,
459
+ "did": server_did,
460
+ "facts_url": f"{base_url}/.well-known/agent-facts.json",
461
+ "a2a_url": f"{base_url}/api",
462
+ "checkpoint": f"{base_url}/api/checkpoint",
463
+ "registries": {},
464
+ "protocol_versions": ["0.2", "0.3"],
465
+ }
466
+ if conformance_badge_doc is not None:
467
+ doc["conformance"] = f"{base_url}/.well-known/conformance.json"
468
+ if arp_badge_doc is not None:
469
+ doc["arp_conformance"] = f"{base_url}/.well-known/arp-conformance.json"
470
+ return doc
471
+
472
+ @app.get("/.well-known/conformance.json")
473
+ def conformance() -> JSONResponse:
474
+ """The server's signed conformance badge — public, no auth, offline-verifiable."""
475
+ if conformance_badge_doc is None:
476
+ raise HTTPException(status_code=404, detail="no conformance badge published")
477
+ return JSONResponse(conformance_badge_doc)
478
+
479
+ @app.get("/.well-known/arp-conformance.json")
480
+ def arp_conformance() -> JSONResponse:
481
+ """The server's signed ARP receipt-suite badge — public, offline-verifiable."""
482
+ if arp_badge_doc is None:
483
+ raise HTTPException(status_code=404, detail="no ARP conformance badge published")
484
+ return JSONResponse(arp_badge_doc)
485
+
486
+ @app.get("/api/federation")
487
+ def federation() -> dict[str, object]:
488
+ # v0.1 is a solo, federation-ready server: no peers wired yet, shape is final.
489
+ servers: dict[str, object] = {}
490
+ return {
491
+ "self": {"agent_id": chapter_id, "did": server_did},
492
+ "chapters": servers,
493
+ "total": len(servers),
494
+ }
495
+
496
+ @app.get("/api/federation/{peer_id}/members")
497
+ def federation_members(peer_id: str) -> JSONResponse:
498
+ if peer_id == chapter_id:
499
+ members = store.list_members(1000)
500
+ return JSONResponse(
501
+ {
502
+ "chapter": peer_id,
503
+ "members": [{"agent_id": m.agent_id, "did_key": m.did_key} for m in members],
504
+ "total": len(members),
505
+ }
506
+ )
507
+ raise HTTPException(status_code=404, detail=f"unknown peer: {peer_id}")
508
+
509
+ return app
510
+
511
+
512
+ app = create_app()
sm_server/merkle.py ADDED
@@ -0,0 +1,125 @@
1
+ """RFC 6962 Merkle tree + a signed checkpoint over the ARP Issuer Log.
2
+
3
+ The per-issuer hash chain (ARP §6.4) gives *forward* tamper-evidence inside one
4
+ issuer's receipts. This adds the *reverse* seam across the whole log: the server
5
+ signs a checkpoint anchoring an RFC 6962 Merkle tree over every receipt it holds,
6
+ and any holder of a receipt + its inclusion proof + the signed root can verify
7
+ membership offline — without the rest of the log, and without trusting the
8
+ server's say-so.
9
+
10
+ Pure functions over raw leaf bytes; domain-separated leaf/node hashes per
11
+ RFC 6962 §2.1. The leaf is the JCS-canonical bytes of the full signed receipt,
12
+ so the checkpoint commits to the exact receipt a verifier reconstructs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ from typing import Any
20
+
21
+ from sm_arp import Identity, canonical_bytes, now_iso
22
+
23
+ CHECKPOINT_VERSION = "aae-checkpoint/0.1"
24
+
25
+
26
+ # ── RFC 6962 §2.1: domain-separated hashes ──────────────────────────────
27
+
28
+
29
+ def _leaf_hash(data: bytes) -> bytes:
30
+ return hashlib.sha256(b"\x00" + data).digest()
31
+
32
+
33
+ def _node_hash(left: bytes, right: bytes) -> bytes:
34
+ return hashlib.sha256(b"\x01" + left + right).digest()
35
+
36
+
37
+ def _largest_pow2_lt(n: int) -> int:
38
+ """Largest power of two strictly less than n (n > 1)."""
39
+ k = 1
40
+ while k * 2 < n:
41
+ k *= 2
42
+ return k
43
+
44
+
45
+ def merkle_root(leaves: list[bytes]) -> bytes:
46
+ """RFC 6962 Merkle Tree Hash over raw leaf data."""
47
+ n = len(leaves)
48
+ if n == 0:
49
+ return hashlib.sha256(b"").digest()
50
+ if n == 1:
51
+ return _leaf_hash(leaves[0])
52
+ k = _largest_pow2_lt(n)
53
+ return _node_hash(merkle_root(leaves[:k]), merkle_root(leaves[k:]))
54
+
55
+
56
+ def inclusion_proof(leaves: list[bytes], m: int) -> list[bytes]:
57
+ """RFC 6962 audit path for leaf index ``m`` in the tree over ``leaves``."""
58
+ n = len(leaves)
59
+ if not 0 <= m < n:
60
+ raise IndexError(f"leaf index {m} out of range for size {n}")
61
+ if n == 1:
62
+ return []
63
+ k = _largest_pow2_lt(n)
64
+ if m < k:
65
+ return inclusion_proof(leaves[:k], m) + [merkle_root(leaves[k:])]
66
+ return inclusion_proof(leaves[k:], m - k) + [merkle_root(leaves[:k])]
67
+
68
+
69
+ def verify_inclusion(
70
+ *, leaf: bytes, leaf_index: int, tree_size: int, proof: list[bytes], root: bytes
71
+ ) -> bool:
72
+ """RFC 6962 inclusion-proof verification (the Trillian reference algorithm)."""
73
+ if leaf_index >= tree_size:
74
+ return False
75
+ fn, sn = leaf_index, tree_size - 1
76
+ r = _leaf_hash(leaf)
77
+ for p in proof:
78
+ if fn == sn or (fn & 1):
79
+ r = _node_hash(p, r)
80
+ while fn != 0 and (fn & 1) == 0:
81
+ fn >>= 1
82
+ sn >>= 1
83
+ else:
84
+ r = _node_hash(r, p)
85
+ fn >>= 1
86
+ sn >>= 1
87
+ return sn == 0 and r == root
88
+
89
+
90
+ # ── Checkpoint envelope (aae-checkpoint/0.1) ────────────────────────────
91
+
92
+
93
+ def checkpoint_leaves(receipts: list[dict[str, Any]]) -> list[bytes]:
94
+ """Canonical leaf bytes — JCS over the FULL receipt (including signature).
95
+
96
+ Receipts MUST be passed in the committed order; the checkpoint root and
97
+ every inclusion proof are taken over this same ordered leaf list.
98
+ """
99
+ return [canonical_bytes(r, include_signature=True) for r in receipts]
100
+
101
+
102
+ def build_checkpoint(receipts: list[dict[str, Any]], *, identity: Identity) -> dict[str, Any]:
103
+ """Sign an RFC 6962 Merkle checkpoint committing to ``receipts`` in order.
104
+
105
+ The payload is signed over its JCS-canonical bytes by ``identity`` (the
106
+ server's own ARP key). ``canonical_bytes(payload, include_signature=True)``
107
+ is exactly ``jcs.canonicalize(payload)`` here because the payload carries no
108
+ ``signature`` field — so the signed bytes match the cross-runtime format.
109
+ """
110
+ root = merkle_root(checkpoint_leaves(receipts))
111
+ payload: dict[str, Any] = {
112
+ "version": CHECKPOINT_VERSION,
113
+ "type": "checkpoint",
114
+ "signer_did": identity.did,
115
+ "created_at": now_iso(),
116
+ "tree_size": len(receipts),
117
+ "merkle_root": "sha256:" + root.hex(),
118
+ "receipt_ids": [r["receipt_id"] for r in receipts],
119
+ }
120
+ sig = identity.sign(canonical_bytes(payload, include_signature=True))
121
+ return {
122
+ "payload": payload,
123
+ "signer_did": identity.did,
124
+ "signature": base64.b64encode(sig).decode("ascii"),
125
+ }
sm_server/signing.py ADDED
@@ -0,0 +1,55 @@
1
+ """did:key derivation + Ed25519 verification — the crypto the protocol surface needs.
2
+
3
+ Same primitives as the conformance suite and sm-conformance: W3C did:key
4
+ (multibase base58btc over multicodec 0xed01 ‖ pubkey32) and Ed25519 over a
5
+ canonical string. No backend, no framework — pure functions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base58
11
+ from cryptography.exceptions import InvalidSignature
12
+ from cryptography.hazmat.primitives import serialization
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
14
+ Ed25519PrivateKey,
15
+ Ed25519PublicKey,
16
+ )
17
+
18
+ ED25519_MULTICODEC_PREFIX = b"\xed\x01"
19
+
20
+ _RAW = serialization.Encoding.Raw
21
+ _RAW_PUB = serialization.PublicFormat.Raw
22
+
23
+
24
+ def generate_chapter_identity() -> tuple[Ed25519PrivateKey, bytes, str]:
25
+ """Return (private_key, pubkey32, did_key) for the server's own identity."""
26
+ priv = Ed25519PrivateKey.generate()
27
+ pub32 = priv.public_key().public_bytes(_RAW, _RAW_PUB)
28
+ return priv, pub32, derive_did_key(pub32)
29
+
30
+
31
+ def derive_did_key(pubkey32: bytes) -> str:
32
+ if len(pubkey32) != 32:
33
+ raise ValueError(f"Ed25519 public key must be 32 bytes, got {len(pubkey32)}")
34
+ return "did:key:z" + base58.b58encode(ED25519_MULTICODEC_PREFIX + pubkey32).decode("ascii")
35
+
36
+
37
+ def parse_did_key(did_key: str) -> bytes:
38
+ if not did_key.startswith("did:key:z"):
39
+ raise ValueError(f"not a did:key: {did_key!r}")
40
+ decoded = base58.b58decode(did_key[len("did:key:z") :])
41
+ if not decoded.startswith(ED25519_MULTICODEC_PREFIX):
42
+ raise ValueError("did:key does not encode an Ed25519 key")
43
+ pub = decoded[len(ED25519_MULTICODEC_PREFIX) :]
44
+ if len(pub) != 32:
45
+ raise ValueError(f"decoded key has wrong length: {len(pub)}")
46
+ return pub
47
+
48
+
49
+ def verify(pubkey32: bytes, signature: bytes, message: bytes) -> bool:
50
+ """True iff `signature` is a valid Ed25519 signature over `message` by `pubkey32`."""
51
+ try:
52
+ Ed25519PublicKey.from_public_bytes(pubkey32).verify(signature, message)
53
+ return True
54
+ except (InvalidSignature, ValueError):
55
+ return False
File without changes
@@ -0,0 +1,81 @@
1
+ """The storage seam — the one interface the HTTP layer depends on.
2
+
3
+ Everything a conformant server needs to persist goes through ``ServerStore``.
4
+ The default backend is SQLite (zero-config, file-backed); Postgres or any other
5
+ store is a drop-in that satisfies this Protocol. Nothing above this interface
6
+ knows what the backend is — that is what makes the core self-hostable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Protocol
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Member:
17
+ agent_id: str
18
+ name: str
19
+ origin: str # deployment-declared provenance, e.g. "sovereign" (see SERVER_ORIGINS)
20
+ public_key: str # standard base64 of the 32-byte Ed25519 public key
21
+ did_key: str
22
+
23
+
24
+ class ServerStore(Protocol):
25
+ """Persistence the conformant protocol surface requires."""
26
+
27
+ def get_member(self, agent_id: str) -> Member | None: ...
28
+
29
+ def put_member(self, member: Member) -> None: ...
30
+
31
+ def rotate_key(self, agent_id: str, new_public_key: str, new_did_key: str) -> None: ...
32
+
33
+ def consume_nonce(self, scope: str, nonce: str) -> bool:
34
+ """Record (scope, nonce). Return True if fresh, False if already seen (replay)."""
35
+ ...
36
+
37
+ def member_count(self) -> int: ...
38
+
39
+ def list_members(self, limit: int = 50) -> list[Member]: ...
40
+
41
+ def record_trust_event(self, agent_id: str, kind: str, delta: float) -> None:
42
+ """Append an immutable trust event. The agent's score is the SUM of deltas."""
43
+ ...
44
+
45
+ def trust_score(self, agent_id: str) -> float:
46
+ """Authoritative score = SUM of recorded deltas (0.0 if none)."""
47
+ ...
48
+
49
+ def trust_history(self, agent_id: str) -> list[dict[str, object]]:
50
+ """Ordered list of {kind, delta} events for the agent."""
51
+ ...
52
+
53
+ # ── ARP Issuer Log (spec §10.2) ─────────────────────────────────
54
+ # The server persists every receipt it accepts. The receipt envelope
55
+ # (build/sign/verify/chain-link) is owned by `sm_arp`; this seam owns only
56
+ # *where the bytes live*, so a Postgres-backed server keeps receipts in
57
+ # Postgres alongside members — not in a side file.
58
+
59
+ def append_receipt(self, receipt: dict[str, object]) -> str:
60
+ """Persist a verified receipt; return the chain link it produces.
61
+
62
+ Idempotent on (issuer_did, receipt_id) — re-appending replaces rather
63
+ than duplicating (the spec's replay floor).
64
+ """
65
+ ...
66
+
67
+ def get_receipt(self, receipt_id: str) -> dict[str, object] | None: ...
68
+
69
+ def get_receipt_by_chain_link(self, issuer_did: str, chain_link: str) -> dict[str, object] | None:
70
+ """Resolve a receipt by its per-issuer chain link — used to find the
71
+ predecessor a new receipt's ``previous_receipt_hash`` points at. Scoped
72
+ by issuer because the hash chain is per-issuer (ARP §6.4)."""
73
+ ...
74
+
75
+ def list_receipts(self, limit: int = 100) -> list[dict[str, object]]: ...
76
+
77
+ def list_receipts_for_principal(
78
+ self, principal_did: str, limit: int = 100
79
+ ) -> list[dict[str, object]]: ...
80
+
81
+ def receipt_count(self) -> int: ...
@@ -0,0 +1,182 @@
1
+ """SQLite implementation of ServerStore — the zero-config self-hostable default.
2
+
3
+ Uses parameterized queries throughout, so hostile agent_ids (the R3 injection
4
+ vectors in the conformance suite) are stored as inert data, never interpolated.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ import threading
12
+
13
+ from sm_arp import chain_link
14
+
15
+ from sm_server.store.base import Member
16
+
17
+ _SCHEMA = """
18
+ CREATE TABLE IF NOT EXISTS members (
19
+ agent_id TEXT PRIMARY KEY,
20
+ name TEXT NOT NULL,
21
+ origin TEXT NOT NULL,
22
+ public_key TEXT NOT NULL,
23
+ did_key TEXT NOT NULL
24
+ );
25
+ CREATE TABLE IF NOT EXISTS nonces (
26
+ scope TEXT NOT NULL,
27
+ nonce TEXT NOT NULL,
28
+ PRIMARY KEY (scope, nonce)
29
+ );
30
+ CREATE TABLE IF NOT EXISTS trust_events (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ agent_id TEXT NOT NULL,
33
+ kind TEXT NOT NULL,
34
+ delta REAL NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_trust_agent ON trust_events (agent_id);
37
+ CREATE TABLE IF NOT EXISTS receipts (
38
+ receipt_id TEXT NOT NULL,
39
+ issuer_did TEXT NOT NULL,
40
+ principal_did TEXT NOT NULL,
41
+ issued_at TEXT NOT NULL,
42
+ category TEXT,
43
+ counterparty_did TEXT,
44
+ previous_receipt_hash TEXT,
45
+ chain_link TEXT NOT NULL,
46
+ receipt_json TEXT NOT NULL,
47
+ PRIMARY KEY (issuer_did, receipt_id)
48
+ );
49
+ CREATE INDEX IF NOT EXISTS idx_receipts_principal ON receipts (principal_did, issued_at);
50
+ CREATE INDEX IF NOT EXISTS idx_receipts_chain ON receipts (issuer_did, chain_link);
51
+ """
52
+
53
+
54
+ class SqliteStore:
55
+ def __init__(self, path: str = ":memory:") -> None:
56
+ self._conn = sqlite3.connect(path, check_same_thread=False)
57
+ self._lock = threading.Lock()
58
+ with self._lock:
59
+ self._conn.executescript(_SCHEMA)
60
+ self._conn.commit()
61
+
62
+ def get_member(self, agent_id: str) -> Member | None:
63
+ with self._lock:
64
+ row = self._conn.execute(
65
+ "SELECT agent_id, name, origin, public_key, did_key FROM members WHERE agent_id = ?",
66
+ (agent_id,),
67
+ ).fetchone()
68
+ return Member(*row) if row else None
69
+
70
+ def put_member(self, member: Member) -> None:
71
+ with self._lock:
72
+ self._conn.execute(
73
+ "INSERT OR REPLACE INTO members (agent_id, name, origin, public_key, did_key) "
74
+ "VALUES (?, ?, ?, ?, ?)",
75
+ (member.agent_id, member.name, member.origin, member.public_key, member.did_key),
76
+ )
77
+ self._conn.commit()
78
+
79
+ def rotate_key(self, agent_id: str, new_public_key: str, new_did_key: str) -> None:
80
+ with self._lock:
81
+ self._conn.execute(
82
+ "UPDATE members SET public_key = ?, did_key = ? WHERE agent_id = ?",
83
+ (new_public_key, new_did_key, agent_id),
84
+ )
85
+ self._conn.commit()
86
+
87
+ def consume_nonce(self, scope: str, nonce: str) -> bool:
88
+ with self._lock:
89
+ try:
90
+ self._conn.execute("INSERT INTO nonces (scope, nonce) VALUES (?, ?)", (scope, nonce))
91
+ self._conn.commit()
92
+ return True
93
+ except sqlite3.IntegrityError:
94
+ return False
95
+
96
+ def member_count(self) -> int:
97
+ with self._lock:
98
+ return int(self._conn.execute("SELECT COUNT(*) FROM members").fetchone()[0])
99
+
100
+ def list_members(self, limit: int = 50) -> list[Member]:
101
+ with self._lock:
102
+ rows = self._conn.execute(
103
+ "SELECT agent_id, name, origin, public_key, did_key FROM members ORDER BY agent_id LIMIT ?",
104
+ (int(limit),),
105
+ ).fetchall()
106
+ return [Member(*r) for r in rows]
107
+
108
+ def record_trust_event(self, agent_id: str, kind: str, delta: float) -> None:
109
+ with self._lock:
110
+ self._conn.execute(
111
+ "INSERT INTO trust_events (agent_id, kind, delta) VALUES (?, ?, ?)",
112
+ (agent_id, kind, float(delta)),
113
+ )
114
+ self._conn.commit()
115
+
116
+ def trust_score(self, agent_id: str) -> float:
117
+ with self._lock:
118
+ row = self._conn.execute(
119
+ "SELECT COALESCE(SUM(delta), 0.0) FROM trust_events WHERE agent_id = ?",
120
+ (agent_id,),
121
+ ).fetchone()
122
+ return float(row[0])
123
+
124
+ def trust_history(self, agent_id: str) -> list[dict[str, object]]:
125
+ with self._lock:
126
+ rows = self._conn.execute(
127
+ "SELECT kind, delta FROM trust_events WHERE agent_id = ? ORDER BY id",
128
+ (agent_id,),
129
+ ).fetchall()
130
+ return [{"kind": k, "delta": d} for k, d in rows]
131
+
132
+ # ── ARP Issuer Log ─────────────────────────────────────────────
133
+
134
+ def append_receipt(self, receipt: dict[str, object]) -> str:
135
+ link: str = chain_link(receipt)
136
+ action = receipt.get("action")
137
+ action = action if isinstance(action, dict) else {}
138
+ with self._lock:
139
+ self._conn.execute(
140
+ "INSERT OR REPLACE INTO receipts (receipt_id, issuer_did, principal_did, "
141
+ "issued_at, category, counterparty_did, previous_receipt_hash, chain_link, "
142
+ "receipt_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
143
+ (
144
+ receipt["receipt_id"],
145
+ receipt["issuer_did"],
146
+ receipt["principal_did"],
147
+ receipt["issued_at"],
148
+ action.get("category"),
149
+ action.get("counterparty_did"),
150
+ receipt.get("previous_receipt_hash"),
151
+ link,
152
+ json.dumps(receipt, separators=(",", ":")),
153
+ ),
154
+ )
155
+ self._conn.commit()
156
+ return link
157
+
158
+ def _receipts_query(self, where: str, args: tuple[object, ...], limit: int) -> list[dict[str, object]]:
159
+ with self._lock:
160
+ rows = self._conn.execute(
161
+ f"SELECT receipt_json FROM receipts {where} ORDER BY issued_at DESC LIMIT ?", # noqa: S608
162
+ (*args, int(limit)),
163
+ ).fetchall()
164
+ return [json.loads(r[0]) for r in rows]
165
+
166
+ def get_receipt(self, receipt_id: str) -> dict[str, object] | None:
167
+ rows = self._receipts_query("WHERE receipt_id = ?", (receipt_id,), 1)
168
+ return rows[0] if rows else None
169
+
170
+ def get_receipt_by_chain_link(self, issuer_did: str, chain_link: str) -> dict[str, object] | None:
171
+ rows = self._receipts_query("WHERE issuer_did = ? AND chain_link = ?", (issuer_did, chain_link), 1)
172
+ return rows[0] if rows else None
173
+
174
+ def list_receipts(self, limit: int = 100) -> list[dict[str, object]]:
175
+ return self._receipts_query("", (), limit)
176
+
177
+ def list_receipts_for_principal(self, principal_did: str, limit: int = 100) -> list[dict[str, object]]:
178
+ return self._receipts_query("WHERE principal_did = ?", (principal_did,), limit)
179
+
180
+ def receipt_count(self) -> int:
181
+ with self._lock:
182
+ return int(self._conn.execute("SELECT COUNT(*) FROM receipts").fetchone()[0])
sm_server/trust.py ADDED
@@ -0,0 +1,63 @@
1
+ """Trust ledger arithmetic — server-set deltas, tier thresholds, score → tier.
2
+
3
+ These tables are part of the protocol, not a tuning knob: a server that
4
+ hand-rolls its own deltas or tiers diverges from every other server and the
5
+ conformance suite catches it. The values are frozen per protocol major; the
6
+ score of an agent is, by construction, the SUM of its event deltas — so an
7
+ authoritative ledger has zero drift between stored and recomputed scores.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TypedDict
13
+
14
+
15
+ class TierRow(TypedDict):
16
+ score_min: int
17
+ tier: str
18
+ open_calls_max: int
19
+ ttl_days: int
20
+ federate: bool
21
+
22
+
23
+ # Server-set, immutable per major (spec/0.X trust event deltas).
24
+ EVENT_DELTAS: dict[str, float] = {
25
+ "intent_match_accepted": 0.5,
26
+ "intent_response_useful": 1.0,
27
+ "call_response_accepted": 0.5,
28
+ "conversation_completed_positive": 1.0,
29
+ "skill_attested_by_trusted": 2.0,
30
+ "endorsement_received": 0.5,
31
+ "tenure_milestone_30d": 1.0,
32
+ "tenure_milestone_90d": 2.0,
33
+ "tenure_milestone_180d": 5.0,
34
+ "tenure_milestone_365d": 10.0,
35
+ "revocation_received": -1.0,
36
+ "complaint_validated": -3.0,
37
+ "inactive_decay": -1.0,
38
+ }
39
+
40
+ # Ascending by score_min; `federate` gates cross-server participation.
41
+ TIER_THRESHOLDS: list[TierRow] = [
42
+ {"score_min": 0, "tier": "new", "open_calls_max": 1, "ttl_days": 7, "federate": False},
43
+ {"score_min": 20, "tier": "established", "open_calls_max": 3, "ttl_days": 30, "federate": False},
44
+ {"score_min": 50, "tier": "trusted", "open_calls_max": 10, "ttl_days": 90, "federate": True},
45
+ {"score_min": 75, "tier": "core", "open_calls_max": 30, "ttl_days": 180, "federate": True},
46
+ ]
47
+
48
+
49
+ def tier_for(score: float) -> TierRow:
50
+ """The highest tier whose score_min the score clears."""
51
+ chosen = TIER_THRESHOLDS[0]
52
+ for row in TIER_THRESHOLDS:
53
+ if row["score_min"] <= score:
54
+ chosen = row
55
+ return chosen
56
+
57
+
58
+ def next_tier_min(score: float) -> int | None:
59
+ """score_min of the next tier up, or None if already in the top tier."""
60
+ for row in TIER_THRESHOLDS:
61
+ if row["score_min"] > score:
62
+ return row["score_min"]
63
+ return None