sm-divergence 0.7.0__tar.gz
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.
- sm_divergence-0.7.0/.github/workflows/ci.yml +30 -0
- sm_divergence-0.7.0/.github/workflows/release.yml +49 -0
- sm_divergence-0.7.0/.gitignore +14 -0
- sm_divergence-0.7.0/CHANGELOG.md +172 -0
- sm_divergence-0.7.0/CONTRIBUTING.md +31 -0
- sm_divergence-0.7.0/GOVERNANCE.md +31 -0
- sm_divergence-0.7.0/LICENSE +21 -0
- sm_divergence-0.7.0/Makefile +11 -0
- sm_divergence-0.7.0/PKG-INFO +273 -0
- sm_divergence-0.7.0/PUBLISHING.md +41 -0
- sm_divergence-0.7.0/README.md +245 -0
- sm_divergence-0.7.0/SPEC.md +259 -0
- sm_divergence-0.7.0/WHITEPAPER.md +218 -0
- sm_divergence-0.7.0/examples/__init__.py +0 -0
- sm_divergence-0.7.0/examples/identity_key_divergence.py +39 -0
- sm_divergence-0.7.0/examples/registry_divergence.py +47 -0
- sm_divergence-0.7.0/examples/signed_self_description.py +51 -0
- sm_divergence-0.7.0/pyproject.toml +59 -0
- sm_divergence-0.7.0/sm_divergence/__init__.py +119 -0
- sm_divergence-0.7.0/sm_divergence/__main__.py +3 -0
- sm_divergence-0.7.0/sm_divergence/_signing.py +88 -0
- sm_divergence-0.7.0/sm_divergence/cli.py +109 -0
- sm_divergence-0.7.0/sm_divergence/detector.py +61 -0
- sm_divergence-0.7.0/sm_divergence/discovery/__init__.py +32 -0
- sm_divergence-0.7.0/sm_divergence/discovery/attestation.py +81 -0
- sm_divergence-0.7.0/sm_divergence/discovery/client.py +73 -0
- sm_divergence-0.7.0/sm_divergence/discovery/closure.py +15 -0
- sm_divergence-0.7.0/sm_divergence/discovery/http_by_id.py +54 -0
- sm_divergence-0.7.0/sm_divergence/discovery/index_v2.py +132 -0
- sm_divergence-0.7.0/sm_divergence/discovery/views.py +32 -0
- sm_divergence-0.7.0/sm_divergence/identity/__init__.py +54 -0
- sm_divergence-0.7.0/sm_divergence/identity/closure.py +64 -0
- sm_divergence-0.7.0/sm_divergence/identity/description_layer.py +164 -0
- sm_divergence-0.7.0/sm_divergence/identity/didkey.py +46 -0
- sm_divergence-0.7.0/sm_divergence/identity/didweb.py +98 -0
- sm_divergence-0.7.0/sm_divergence/identity/selfdesc.py +202 -0
- sm_divergence-0.7.0/sm_divergence/identity/universal.py +76 -0
- sm_divergence-0.7.0/sm_divergence/identity/views.py +41 -0
- sm_divergence-0.7.0/tests/test_attestation.py +77 -0
- sm_divergence-0.7.0/tests/test_cli.py +109 -0
- sm_divergence-0.7.0/tests/test_client.py +70 -0
- sm_divergence-0.7.0/tests/test_closure.py +108 -0
- sm_divergence-0.7.0/tests/test_core.py +82 -0
- sm_divergence-0.7.0/tests/test_description_layer.py +139 -0
- sm_divergence-0.7.0/tests/test_detector.py +121 -0
- sm_divergence-0.7.0/tests/test_diff.py +101 -0
- sm_divergence-0.7.0/tests/test_identity.py +142 -0
- sm_divergence-0.7.0/tests/test_index_v2.py +123 -0
- sm_divergence-0.7.0/tests/test_resolver.py +79 -0
- sm_divergence-0.7.0/tests/test_selfdesc.py +148 -0
- sm_divergence-0.7.0/uv.lock +572 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
ci-local:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
- name: Pin Python
|
|
18
|
+
run: uv python pin ${{ matrix.python-version }}
|
|
19
|
+
# The same gate as `make ci-local` — green locally == green here.
|
|
20
|
+
# sm-resolver (the kernel) resolves from PyPI.
|
|
21
|
+
- run: uv sync --extra dev
|
|
22
|
+
- run: uv run ruff check .
|
|
23
|
+
- run: uv run ruff format --check .
|
|
24
|
+
- run: uv run mypy sm_divergence
|
|
25
|
+
- run: uv run pytest -v
|
|
26
|
+
- name: Examples run
|
|
27
|
+
run: |
|
|
28
|
+
uv run python examples/registry_divergence.py
|
|
29
|
+
uv run python examples/identity_key_divergence.py
|
|
30
|
+
uv run python examples/signed_self_description.py
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Publish sm-divergence to PyPI on a version tag, via PyPI Trusted Publishing (OIDC).
|
|
2
|
+
#
|
|
3
|
+
# No API token anywhere: PyPI is told (once, in its web UI) to trust THIS repo +
|
|
4
|
+
# THIS workflow file, and the `id-token: write` permission below lets the job
|
|
5
|
+
# mint a short-lived OIDC token PyPI accepts. See PUBLISHING.md for the one-time
|
|
6
|
+
# setup — including the prerequisite of publishing sm-resolver first and swapping
|
|
7
|
+
# the git-URL dependency for a version spec (PyPI rejects direct references).
|
|
8
|
+
#
|
|
9
|
+
# Fires only when a tag like `v0.7.0` is pushed — never on normal commits.
|
|
10
|
+
name: release
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
tags:
|
|
15
|
+
- "v*"
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
publish:
|
|
22
|
+
name: Build + publish to PyPI
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
permissions:
|
|
25
|
+
# A job-level permissions block REPLACES the workflow-level one (it does not
|
|
26
|
+
# merge), so contents:read must be restated here or actions/checkout 404s on
|
|
27
|
+
# its own repo. Both lines are required.
|
|
28
|
+
contents: read # for actions/checkout
|
|
29
|
+
id-token: write # REQUIRED for Trusted Publishing — this is what replaces the token.
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
|
|
33
|
+
- uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: "3.12"
|
|
36
|
+
|
|
37
|
+
- name: Build sdist + wheel
|
|
38
|
+
run: |
|
|
39
|
+
python -m pip install --upgrade build
|
|
40
|
+
python -m build
|
|
41
|
+
|
|
42
|
+
- name: Check the built distributions
|
|
43
|
+
run: |
|
|
44
|
+
python -m pip install --upgrade twine
|
|
45
|
+
python -m twine check dist/*
|
|
46
|
+
|
|
47
|
+
- name: Publish to PyPI
|
|
48
|
+
# No `with: password:` — the action uses the OIDC token automatically.
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.venv/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
uv.lock.tmp
|
|
11
|
+
|
|
12
|
+
# Development & reconnaissance notes — design scoping, competitive research,
|
|
13
|
+
# decision records. Kept local, never published.
|
|
14
|
+
dev/
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/) and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.7.0] — 2026-07-04
|
|
8
|
+
|
|
9
|
+
Self-description hardening — replay resistance, dogfooded distribution, and alias
|
|
10
|
+
closure. Also the system's first **agent-equivocation** detection.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`seq` + supersession** — the self-description carries a monotonic `seq`
|
|
14
|
+
(signed); highest supersedes. `build_self_description(..., seq=N)` is now
|
|
15
|
+
required. Closes the replay hole where an unexpired but superseded description
|
|
16
|
+
could be served as "the agent's own signature."
|
|
17
|
+
- **`reconcile_descriptions` / `DescriptionReconciliation`** — reconcile
|
|
18
|
+
descriptions gathered from multiple sources (`seq`-aware): the surviving
|
|
19
|
+
highest-`seq` `canonical`, plus findings `agent_equivocation` (two valid
|
|
20
|
+
descriptions at the same `seq` with different content → the agent's key signed
|
|
21
|
+
a conflict; correspondence fails closed), `omission`, and `stale_description`.
|
|
22
|
+
- **`HttpDescriptionResolver` + `corroborate_descriptions`** — fetch + verify the
|
|
23
|
+
description from every source and reconcile, so the description's own
|
|
24
|
+
distribution (omission / replay) is corroborated, not trusted from one source.
|
|
25
|
+
- **`AliasClosureResolver` + `binds_by_did` (discovery) / `binds_by_key`
|
|
26
|
+
(identity)** — a record reached via a self-asserted alias that does not bind
|
|
27
|
+
back to the agent's `did:key` contributes no claim, never a divergence. Prevents
|
|
28
|
+
a malicious agent from manufacturing a divergence by naming a victim's alias.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `build_self_description` gains a required `seq`; `SelfDescription` gains `seq`
|
|
32
|
+
and `content_key()`. `verify_self_description` parses and requires `seq`.
|
|
33
|
+
|
|
34
|
+
## [0.6.0] — 2026-07-04
|
|
35
|
+
|
|
36
|
+
The kernel is extracted. The source-agnostic core (`Resolver[T]`, `View`,
|
|
37
|
+
`diff_views`, `Corroborator`, `Finding`) now lives in its own package,
|
|
38
|
+
[`sm-resolver`](https://github.com/Sharathvc23/sm-resolver), and `sm-divergence`
|
|
39
|
+
depends on it — the extraction the layered design was built toward, triggered
|
|
40
|
+
once the identity layer joined discovery as a second consumer.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
- **`sm_divergence.core` removed**; the kernel is the `sm-resolver` dependency.
|
|
44
|
+
The top-level public API is unchanged (`from sm_divergence import Corroborator,
|
|
45
|
+
Finding, Resolver, Status, View, ViewT, diff_views, OMISSION, …` still work — now
|
|
46
|
+
re-exported from `sm-resolver`). Deep imports of `sm_divergence.core.*` no longer
|
|
47
|
+
exist; import from `sm_resolver` or the top level.
|
|
48
|
+
- CI installs the private `sm-resolver` git dependency (needs the
|
|
49
|
+
`SM_RESOLVER_PAT` secret, or a public `sm-resolver`).
|
|
50
|
+
- Version realigned (pyproject had drifted at 0.3.0 while the package was at 0.5.0).
|
|
51
|
+
|
|
52
|
+
## [0.5.0] — 2026-07-04
|
|
53
|
+
|
|
54
|
+
Signed portable-identity self-description (Phase 3) — identity correspondence
|
|
55
|
+
becomes an agent-signed fact instead of caller-configured trust.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- **`sm_divergence.identity` self-description** — the agent signs a bundle
|
|
59
|
+
asserting its own aliases (its name per namespace) and service endpoints with
|
|
60
|
+
its `did:key`; a consumer verifies it offline and drives resolvers from the
|
|
61
|
+
*verified* names, so a source contradicting a signed alias/endpoint diverges
|
|
62
|
+
from the agent's own signature:
|
|
63
|
+
- `build_self_description(aliases, services, private_key)` → signed
|
|
64
|
+
`{descriptor, sig}` (did derived from the key; 24h default TTL).
|
|
65
|
+
- `verify_self_description(bundle)` → `SelfDescription | None` (offline sig +
|
|
66
|
+
freshness; string→string alias/service maps; never raises).
|
|
67
|
+
- `verified_descriptions(bundles)` → verify a map, keep only what verifies.
|
|
68
|
+
- `signed_alias_for(descriptions, namespace)` → a `did_for` / `locator_for`
|
|
69
|
+
backed by verified aliases; **falls back to the id when unverified**, so a
|
|
70
|
+
forged description can't redirect a resolver.
|
|
71
|
+
- **`sm_divergence._signing`** — the shared Ed25519 / `did:key` / JCS primitives,
|
|
72
|
+
now used by both the discovery attestation adapter and the self-description
|
|
73
|
+
(behind the `attestation` extra). No behaviour change to `verified_did`.
|
|
74
|
+
|
|
75
|
+
## [0.4.0] — 2026-07-04
|
|
76
|
+
|
|
77
|
+
The identity layer (Phase 2 of the resolver-stack design) — the core's second
|
|
78
|
+
consumer, proving the kernel generalizes past discovery. Corroborates the one
|
|
79
|
+
field that must agree across an agent's DID methods: its verification key.
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
- **`sm_divergence.identity`** — key-consistency across DID methods, built on
|
|
83
|
+
the same core (`DidView` implements `View`; the resolvers produce it,
|
|
84
|
+
`Corroborator` + `diff_views` do the rest):
|
|
85
|
+
- `DidKeyResolver` — resolves a `did:key` offline (the self-certifying root);
|
|
86
|
+
- `DidWebResolver` — fetches a `did:web` document and reads its
|
|
87
|
+
`publicKeyMultibase`;
|
|
88
|
+
- `UniversalResolverResolver` — resolves any method via a DIF Universal
|
|
89
|
+
Resolver endpoint (`GET {base}/1.0/identifiers/{did}`).
|
|
90
|
+
A method that serves a different key than the root `did:key` is a `key`
|
|
91
|
+
divergence — the identity-layer analogue of endpoint tampering (a hijacked
|
|
92
|
+
domain, a stale mirror, or a lying resolver). Keys compare as `did:key`
|
|
93
|
+
multibase strings, so no crypto dependency is added.
|
|
94
|
+
- Public: `DidView`, `DidKeyResolver`, `DidWebResolver`, `UniversalResolverResolver`,
|
|
95
|
+
`key_from_did_key`, `key_from_did_document`, `did_web_url`, `identity_did`, `KEY`.
|
|
96
|
+
|
|
97
|
+
## [0.3.0] — 2026-07-04
|
|
98
|
+
|
|
99
|
+
The core/discovery seam (Phase 1 of the resolver-stack design). Splits the
|
|
100
|
+
package into a source-agnostic **kernel** and the discovery **layer** on top, so
|
|
101
|
+
the kernel is extractable and a thin per-format adapter (e.g. a NANDA Index
|
|
102
|
+
adapter) needs no discovery internals.
|
|
103
|
+
|
|
104
|
+
### Added
|
|
105
|
+
- **`sm_divergence.core`** — the layer-agnostic kernel: the `View` contract
|
|
106
|
+
(a claim reduced to named comparable fields via `comparable()`), a generic
|
|
107
|
+
`Resolver[T]` protocol, a generic `diff_views` (findings named after whatever
|
|
108
|
+
fields the view exposes), the `Corroborator[T]` orchestration, and `Finding`.
|
|
109
|
+
Nothing here knows any registry format. Intended to extract as a standalone
|
|
110
|
+
kernel once a second layer consumes it.
|
|
111
|
+
- **`sm_divergence.discovery`** — the registry-integrity layer: `RecordView`
|
|
112
|
+
plus the `HttpByIdResolver` (NEST) and `NandaIndexV2Resolver` adapters, all
|
|
113
|
+
thin on the core.
|
|
114
|
+
- `Corroborator`, `View`, `ViewT` are now public (for adapter/layer authors).
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
- **Finding detail is uniform across layers.** A field divergence now carries
|
|
118
|
+
`{"field": <name>, "values": {source: value}}` (was `{"endpoints": {...}}` /
|
|
119
|
+
`{"dids": {...}}`), so any consumer parses findings the same way regardless of
|
|
120
|
+
layer or field. `omission` detail is unchanged. `kind` is unchanged
|
|
121
|
+
(`omission`, or the field name — `endpoint`/`did`).
|
|
122
|
+
- Module layout moved under `core/` and `discovery/`. The top-level public API
|
|
123
|
+
(`from sm_divergence import …`) is unchanged; only deep imports
|
|
124
|
+
(`sm_divergence.client`, `.resolver`, `.index_v2`, `.models`, `.attestation`)
|
|
125
|
+
moved (to `sm_divergence.discovery.*`).
|
|
126
|
+
|
|
127
|
+
## [0.2.0] — 2026-07-04
|
|
128
|
+
|
|
129
|
+
Heterogeneous registries: corroborate across registries that speak *different*
|
|
130
|
+
formats, not just different JSON at the same by-id endpoint.
|
|
131
|
+
|
|
132
|
+
### Added
|
|
133
|
+
- **The `Resolver` protocol** (`sm_divergence.resolver`, SPEC §6) — a per-registry
|
|
134
|
+
component mapping a canonical id to a normalized `(Status, RecordView)`. Each
|
|
135
|
+
resolver owns one registry's transport, record extraction, and native
|
|
136
|
+
verification; the diff is untouched because every resolver returns the same view.
|
|
137
|
+
- **`HttpByIdResolver`** — the `GET /api/agents/{id}` shape (e.g. NEST), the v0.1
|
|
138
|
+
behavior repackaged as the first resolver.
|
|
139
|
+
- **`NandaIndexV2Resolver`** (`sm_divergence.index_v2`) — the NANDA Index v2 two-hop
|
|
140
|
+
resolve (`/api/v1/resolve?locator=…` → `{registry_url, identifier}` → fetch the
|
|
141
|
+
record), normalized to a `RecordView`. `locator_for` maps a canonical id to the
|
|
142
|
+
Index's URN locator (identity correspondence is the caller's to define).
|
|
143
|
+
- **`DivergenceDetector` accepts `str | Resolver`** — bare URLs are wrapped in
|
|
144
|
+
`HttpByIdResolver` (back-compatible), and resolver instances of different formats
|
|
145
|
+
can be mixed in one detector. Views are keyed by each resolver's `label`.
|
|
146
|
+
- **CLI `--nanda-index URL`** — corroborate a NANDA Index v2 alongside `--registry`.
|
|
147
|
+
|
|
148
|
+
### Changed
|
|
149
|
+
- `looks_absent` (was `_looks_absent`) is now a shared public helper so every HTTP
|
|
150
|
+
resolver classifies soft-404 identically.
|
|
151
|
+
|
|
152
|
+
## [0.1.0] — 2026-07-04
|
|
153
|
+
|
|
154
|
+
Initial public release of sm-divergence — cross-registry divergence detection for
|
|
155
|
+
accountable agent discovery.
|
|
156
|
+
|
|
157
|
+
### Added
|
|
158
|
+
- **The diff** (`diff_views`, SPEC §4) — the pure, deterministic core: per-registry
|
|
159
|
+
claims → `omission` / `endpoint` / `did` findings. No I/O; an unreachable
|
|
160
|
+
registry (an `error` claim) is excluded rather than counted as absence.
|
|
161
|
+
- **Claim classification** (`fetch_view`, SPEC §3) — one registry's answer about one
|
|
162
|
+
id as `present` / `absent` / `error`, with hard-404 and soft-404 (`200` + not-found
|
|
163
|
+
body) both mapping to `absent`. Injectable by-id path and record adapter.
|
|
164
|
+
- **`DivergenceDetector`** — fetch + diff + dedupe orchestration with an
|
|
165
|
+
`on_finding` callback that fires once per distinct finding; `check_once` one-shot.
|
|
166
|
+
- **Verified-DID extraction** (`sm_divergence.attestation`, SPEC §5) — optional
|
|
167
|
+
`attestation` extra: Ed25519 / `did:key` / JCS verification of a self-certifying
|
|
168
|
+
record, plus a subject-match guard, feeding the `did` diff. Kept out of the core
|
|
169
|
+
so omission/endpoint checks carry no cryptographic dependency.
|
|
170
|
+
- **CLI** — `python -m sm_divergence check --registry … --watch …`; exit `2` on any
|
|
171
|
+
divergence (cron/CI-friendly), `--json`, `--attestation`.
|
|
172
|
+
- Whitepaper, spec, and governance documents.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Contributing to sm-divergence
|
|
2
|
+
|
|
3
|
+
Contributions are accepted under the Developer Certificate of Origin (DCO)
|
|
4
|
+
sign-off model. Add `Signed-off-by: Your Name <you@example.com>` to every commit
|
|
5
|
+
(`git commit -s`).
|
|
6
|
+
|
|
7
|
+
## Change process
|
|
8
|
+
|
|
9
|
+
1. Spec-affecting changes open a PR that updates the **spec (`SPEC.md`), the tests,
|
|
10
|
+
and the code together**. Drift between these is a defect — the `tests/` suite is
|
|
11
|
+
the authoritative behavioural specification of the diff.
|
|
12
|
+
2. The gate is `make ci-local` (uv: `ruff` → `ruff format` → `mypy --strict` →
|
|
13
|
+
`pytest`). CI runs the same on Python 3.11 and 3.12. Push only when it is green.
|
|
14
|
+
3. Every new guarantee needs a happy-path test; every classification and every
|
|
15
|
+
divergence kind needs its own case. The negative cases — unreachable ≠ absent,
|
|
16
|
+
one-verified-DID ≠ divergence, soft-404 = absent — are the point.
|
|
17
|
+
|
|
18
|
+
## House rules
|
|
19
|
+
|
|
20
|
+
- **A timeout is not a claim.** An unreachable or erroring source MUST be
|
|
21
|
+
excluded from the diff, never counted as an omission. This is the axiom the whole
|
|
22
|
+
library rests on; a change here needs an RFC-style PR to `SPEC.md` first.
|
|
23
|
+
- **Only proven values disagree.** An unverified value (e.g. an unverified DID)
|
|
24
|
+
never enters the diff. Do not weaken the verification path.
|
|
25
|
+
- **The diff stays pure and generic.** `diff_views` is deterministic, does no
|
|
26
|
+
I/O, never raises, and never learns its layer. All I/O lives in the resolvers;
|
|
27
|
+
a layer supplies a view (`comparable()`) and thin resolvers, nothing more.
|
|
28
|
+
- **No new crypto.** The optional DID check reuses the family convention
|
|
29
|
+
(Ed25519 / `did:key` / JCS). Do not re-implement it elsewhere.
|
|
30
|
+
- **No expansion of the classification (SPEC §3), the view contract (SPEC §4), or
|
|
31
|
+
the diff (SPEC §5) without an RFC-style PR to `SPEC.md` first.**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Governance
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
| In scope | Out of scope |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| the claim classification (`present`/`absent`/`error`), the view contract (`comparable()`), the divergence diff (`omission` + per-field: `endpoint`/`did`/`key`/…), and the optional verified-DID extraction | the source itself; the record/document format and its signing (AgentFacts / self-certifying records / DID documents — the *tampering* half); a transparency log or witness network (single-source consistent lying); remediation policy on a finding; and each source's transport/schema (injected by a resolver) |
|
|
8
|
+
|
|
9
|
+
The primitive owns one thing — answering *"do these sources disagree about this
|
|
10
|
+
agent, and how?"* — across any layer (discovery, identity, and future
|
|
11
|
+
capability/evidence). Anything outside the table belongs to a companion package
|
|
12
|
+
or the caller.
|
|
13
|
+
|
|
14
|
+
## Versioning
|
|
15
|
+
|
|
16
|
+
Semantic Versioning 2.0.0. The claim classification (SPEC §3), the view contract
|
|
17
|
+
(SPEC §4), and the diff (SPEC §5) are frozen within a major; a change requires an
|
|
18
|
+
RFC-style PR to `SPEC.md` before code. The library API (`diff_views`,
|
|
19
|
+
`Corroborator`, `DivergenceDetector`, `check_once`) follows the same major.
|
|
20
|
+
|
|
21
|
+
## Conformance
|
|
22
|
+
|
|
23
|
+
An implementation is conforming iff it reproduces the finding set in SPEC §10 for
|
|
24
|
+
the specified claim inputs. The diff is pure and deterministic by construction, so
|
|
25
|
+
conformance is mechanical.
|
|
26
|
+
|
|
27
|
+
## Changes
|
|
28
|
+
|
|
29
|
+
Spec-affecting changes: open a PR editing `SPEC.md` (and `WHITEPAPER.md` if the
|
|
30
|
+
rationale moves), then the implementation and tests. Non-spec changes (adapters,
|
|
31
|
+
CLI, performance) are ordinary PRs against a green `make ci-local`.
|
|
@@ -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.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.PHONY: ci-local
|
|
2
|
+
# One-command pre-push gate — the same steps CI runs, hard-failing on first red.
|
|
3
|
+
ci-local:
|
|
4
|
+
uv sync --extra dev
|
|
5
|
+
uv run ruff check .
|
|
6
|
+
uv run ruff format --check .
|
|
7
|
+
uv run mypy sm_divergence
|
|
8
|
+
uv run pytest -v
|
|
9
|
+
uv run python examples/registry_divergence.py
|
|
10
|
+
uv run python examples/identity_key_divergence.py
|
|
11
|
+
uv run python examples/signed_self_description.py
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sm-divergence
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Catch a cheating registry by corroboration — omission/endpoint/DID + cross-DID-method key divergence for federated agent discovery, on the sm-resolver kernel.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Sharathvc23/sm-divergence
|
|
6
|
+
Project-URL: Spec, https://github.com/Sharathvc23/sm-divergence/blob/main/SPEC.md
|
|
7
|
+
Project-URL: Whitepaper, https://github.com/Sharathvc23/sm-divergence/blob/main/WHITEPAPER.md
|
|
8
|
+
Author: Sharath (Stellarminds)
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: accountability,ai-agents,discovery,federation,nanda,registry
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: httpx>=0.24
|
|
14
|
+
Requires-Dist: sm-resolver>=0.1
|
|
15
|
+
Provides-Extra: attestation
|
|
16
|
+
Requires-Dist: base58>=2.1; extra == 'attestation'
|
|
17
|
+
Requires-Dist: cryptography>=42; extra == 'attestation'
|
|
18
|
+
Requires-Dist: jcs>=0.2.1; extra == 'attestation'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: base58>=2.1; extra == 'dev'
|
|
21
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
22
|
+
Requires-Dist: jcs>=0.2.1; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# sm-divergence — catch a cheating registry by corroboration
|
|
30
|
+
|
|
31
|
+
**Integrity for the sources agents resolve through. A registry (or a name
|
|
32
|
+
service, or a DID method) can lie by omission, tampering, or equivocation;
|
|
33
|
+
signing an artifact closes only tampering. This asks several sources the same
|
|
34
|
+
questions and makes any disagreement loud.**
|
|
35
|
+
|
|
36
|
+
Agents reach each other through middlemen — registries, indexes, DID methods —
|
|
37
|
+
and a middleman can lie:
|
|
38
|
+
|
|
39
|
+
- **tamper** — serve a false endpoint (or key) for an agent,
|
|
40
|
+
- **omit** — hide an agent that is actually registered,
|
|
41
|
+
- **equivocate** — tell different clients different things.
|
|
42
|
+
|
|
43
|
+
A self-certifying artifact (a signed AgentFacts-style record, a `did:key`)
|
|
44
|
+
defeats tampering — it can't be altered without breaking its signature. But *no
|
|
45
|
+
signature can prove what a source chose **not** to serve.* Omission and
|
|
46
|
+
equivocation are invisible to a single source.
|
|
47
|
+
|
|
48
|
+
The cheap, robust defense is one decentralized discovery already hands you:
|
|
49
|
+
**corroboration.** If an agent is reachable through two or more sources, ask all
|
|
50
|
+
of them the same question and treat any disagreement as a signal. That is all
|
|
51
|
+
this library does — enough to turn a silently cheating source into a loudly
|
|
52
|
+
diverging one. See the [whitepaper](WHITEPAPER.md) for the argument and the
|
|
53
|
+
[spec](SPEC.md) for the normative procedure.
|
|
54
|
+
|
|
55
|
+
> **Not a transparency log.** Corroboration catches a source that disagrees with
|
|
56
|
+
> its peers; it does not stop a *single* source lying *consistently* to a
|
|
57
|
+
> first-time caller. That last mile is transparency-log territory (WHITEPAPER §7).
|
|
58
|
+
|
|
59
|
+
## Where it fits
|
|
60
|
+
|
|
61
|
+
A **DNS/CA-pillar** primitive in the [Project NANDA](https://projectnanda.org)
|
|
62
|
+
four-pillar model (DNS / CA / Orchestration / Attestation): it protects the
|
|
63
|
+
integrity of *discovery and identity resolution themselves*. NANDA's discovery is
|
|
64
|
+
multi-source **by design** — a lean Index delegating to a quilt of registries —
|
|
65
|
+
and corroboration is only *possible* because of it: the redundancy built for
|
|
66
|
+
resilience doubles as an integrity check. This library turns that latent property
|
|
67
|
+
into an active one, the concrete mechanism behind **accountable discovery** — a
|
|
68
|
+
federated registry that is not merely decentralized but *auditable*.
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install sm-divergence # omission + endpoint/key checks (httpx only)
|
|
74
|
+
pip install "sm-divergence[attestation]" # + verified-DID checks (Ed25519/did:key)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import asyncio
|
|
81
|
+
from sm_divergence import check_once
|
|
82
|
+
|
|
83
|
+
findings = asyncio.run(check_once(
|
|
84
|
+
["https://registry-a.example", "https://registry-b.example"],
|
|
85
|
+
["agent-1", "agent-2"],
|
|
86
|
+
))
|
|
87
|
+
for f in findings:
|
|
88
|
+
print(f.kind, f.agent_id, f.detail)
|
|
89
|
+
# endpoint agent-1 {'field': 'endpoint',
|
|
90
|
+
# 'values': {'https://registry-a.example': 'https://real.example',
|
|
91
|
+
# 'https://registry-b.example': 'https://attacker.example'}}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Long-running? Keep a detector and route new findings to your alert sink — it
|
|
95
|
+
deduplicates, so a persisting divergence fires your callback once:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from sm_divergence import DivergenceDetector
|
|
99
|
+
|
|
100
|
+
detector = DivergenceDetector(
|
|
101
|
+
["https://registry-a.example", "https://registry-b.example"],
|
|
102
|
+
on_finding=lambda f: alert(f.kind, f.agent_id, f.detail),
|
|
103
|
+
)
|
|
104
|
+
await detector.check(watch_ids=my_agent_ids) # call each poll cycle
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## CLI
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python -m sm_divergence check \
|
|
111
|
+
--registry https://registry-a.example \
|
|
112
|
+
--registry https://registry-b.example \
|
|
113
|
+
--watch agent-1 --watch agent-2
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Exit **0** when the sources agree, **2** when any divergence is found (so a cron
|
|
117
|
+
or CI step fails loudly), **1** on a usage error. Add `--json` for machine
|
|
118
|
+
output, `--attestation` to also compare verified DIDs, `--nanda-index URL`
|
|
119
|
+
beside `--registry URL` to corroborate a NANDA Index.
|
|
120
|
+
|
|
121
|
+
## What it compares
|
|
122
|
+
|
|
123
|
+
Every finding is `omission` (present on one source, positively absent on another)
|
|
124
|
+
or the name of a view field whose values disagree. The detail is uniform:
|
|
125
|
+
`{"field": <name>, "values": {source: value}}` (or `{"present_on", "missing_from"}`
|
|
126
|
+
for omission), so a consumer parses findings the same way at every layer.
|
|
127
|
+
|
|
128
|
+
| Kind | Meaning |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `omission` | One source serves the id; another **positively** reports it absent (404 / soft-404). An *unreachable* source is excluded — a timeout is not a claim. |
|
|
131
|
+
| `endpoint` | Discovery sources disagree on an agent's endpoint. |
|
|
132
|
+
| `did` | Discovery sources with a **verified** attestation disagree on the key. One valid record + one forged one is *not* a disagreement — only proven DIDs count. |
|
|
133
|
+
| `key` | Identity sources (DID methods) disagree on the agent's verification key. |
|
|
134
|
+
| `agent_equivocation` | The agent's *own key* signed two different self-descriptions at the same `seq` — key compromise or a lying agent (the one finding about the agent, not a source). |
|
|
135
|
+
| `stale_description` | A source serves a self-description behind the highest `seq` (replay / lag). |
|
|
136
|
+
|
|
137
|
+
## Architecture — a kernel and its layers
|
|
138
|
+
|
|
139
|
+
The primitive factors into a source-agnostic **kernel** and a **layer** per thing
|
|
140
|
+
a middleman can be asked. Each layer supplies its own view and thin resolvers;
|
|
141
|
+
the kernel is untouched.
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
sm-resolver (dependency) the source-agnostic kernel — Resolver[T], the View
|
|
145
|
+
contract, diff_views, Corroborator, Finding
|
|
146
|
+
sm_divergence/
|
|
147
|
+
discovery/ registry-integrity layer — RecordView + HttpByIdResolver (NEST)
|
|
148
|
+
+ NandaIndexV2Resolver
|
|
149
|
+
identity/ key-consistency layer — DidView + DidKeyResolver / DidWebResolver
|
|
150
|
+
/ UniversalResolverResolver + signed self-description
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The kernel now lives in its own package,
|
|
154
|
+
[`sm-resolver`](https://github.com/Sharathvc23/sm-resolver) — `sm-divergence` is
|
|
155
|
+
the reference set of layers built on it. Everything below is re-exported from the
|
|
156
|
+
top level, so `from sm_divergence import Corroborator, Finding, …` is unchanged.
|
|
157
|
+
|
|
158
|
+
**Sources of different formats mix in one run.** A `Resolver` hides one source's
|
|
159
|
+
wire format and normalizes its answer to the same view the diff understands:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from sm_divergence import DivergenceDetector, HttpByIdResolver, NandaIndexV2Resolver
|
|
163
|
+
|
|
164
|
+
await DivergenceDetector([
|
|
165
|
+
HttpByIdResolver("https://nest.example"), # GET /api/agents/{id}
|
|
166
|
+
NandaIndexV2Resolver("https://index.example"), # two-hop resolve → record
|
|
167
|
+
]).check(["agent-1"])
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Identity layer** — catches a `did:web` (or a Universal Resolver) serving a
|
|
171
|
+
different verification key than the agent's self-certifying `did:key` root, a
|
|
172
|
+
`key` divergence (the identity analogue of a tampered endpoint):
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from sm_divergence import Corroborator, DidKeyResolver, DidWebResolver
|
|
176
|
+
|
|
177
|
+
await Corroborator([
|
|
178
|
+
DidKeyResolver(did_for=lambda a: agent_did_key[a]), # offline root of truth
|
|
179
|
+
DidWebResolver(did_for=lambda a: agent_did_web[a]), # fetched, may be hijacked
|
|
180
|
+
]).check(agent_ids)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**A new format or layer is a thin adapter** — a view with a `comparable()` and a
|
|
184
|
+
`Resolver` that returns it; the generic `Corroborator` + `diff_views` do the rest:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from collections.abc import Mapping
|
|
188
|
+
from dataclasses import dataclass
|
|
189
|
+
from sm_divergence import Corroborator
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class MyView:
|
|
193
|
+
field: str | None = None
|
|
194
|
+
def comparable(self) -> Mapping[str, str | None]:
|
|
195
|
+
return {"field": self.field}
|
|
196
|
+
|
|
197
|
+
class MyResolver:
|
|
198
|
+
label = "src-a"
|
|
199
|
+
async def resolve(self, agent_id): ... # -> (Status, MyView | None)
|
|
200
|
+
|
|
201
|
+
await Corroborator([MyResolver(), ...]).check(agent_ids)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Signed correspondence** — the same agent is a bare id on one registry, a URN
|
|
205
|
+
locator on an Index, a `did:web` on a domain, and corroboration can't infer that
|
|
206
|
+
these are the same agent. Instead of trusting a caller-supplied mapping, let the
|
|
207
|
+
agent *sign* its own aliases + endpoints; verify offline and drive the resolvers
|
|
208
|
+
from the verified names, so a source that disagrees is contradicting the agent's
|
|
209
|
+
own signature:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from sm_divergence import (
|
|
213
|
+
build_self_description, HttpDescriptionResolver, corroborate_descriptions,
|
|
214
|
+
signed_alias_for, DidWebResolver, AliasClosureResolver, binds_by_key,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# the agent signs (bumping seq on every re-issue), with its did:key's private key:
|
|
218
|
+
bundle = build_self_description(
|
|
219
|
+
aliases={"web": "did:web:acme.org", "nanda": "urn:ai:…:acme"},
|
|
220
|
+
services={"discovery": "https://acme.example/agents/acme"},
|
|
221
|
+
private_key=agent_ed25519_private_bytes, seq=5,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# a consumer fetches the description from EVERY source and reconciles it —
|
|
225
|
+
# omission / replay / agent-equivocation on the description itself become findings:
|
|
226
|
+
recs = await corroborate_descriptions(
|
|
227
|
+
[HttpDescriptionResolver(s) for s in source_urls], ["acme"],
|
|
228
|
+
)
|
|
229
|
+
canonical = recs["acme"].canonical # None if the agent equivocates
|
|
230
|
+
sds = {"acme": canonical} if canonical else {}
|
|
231
|
+
|
|
232
|
+
# drive resolvers from the PROVEN aliases, with closure so a claimed alias that
|
|
233
|
+
# doesn't bind back to the did:key contributes no claim (never a false divergence):
|
|
234
|
+
web = DidWebResolver(did_for=signed_alias_for(sds, "web"))
|
|
235
|
+
web = AliasClosureResolver(web, lambda a: sds[a].did if a in sds else None, binds_by_key)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
> The kernel lives in [`sm-resolver`](https://github.com/Sharathvc23/sm-resolver);
|
|
239
|
+
> `sm-divergence` is the reference layers built on it. The top-level public API is
|
|
240
|
+
> unchanged.
|
|
241
|
+
|
|
242
|
+
For verified-DID comparison over signed records shaped
|
|
243
|
+
`{"record": {..., "did": "did:key:z…", "endpoint": …}, "sig": "<b64 ed25519 over JCS(record)>"}`,
|
|
244
|
+
use the built-in adapter (needs the `attestation` extra):
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from sm_divergence import DivergenceDetector, signed_record_adapter
|
|
248
|
+
DivergenceDetector(registries, adapter=signed_record_adapter())
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Related packages
|
|
252
|
+
|
|
253
|
+
| Package | Role |
|
|
254
|
+
| --- | --- |
|
|
255
|
+
| [`sm-bridge`](https://github.com/Sharathvc23/sm-bridge) | NANDA-compatible registry endpoints + Quilt-style deltas — *produces* the multi-source surface this corroborates across. |
|
|
256
|
+
| [`sm-chapter`](https://github.com/Sharathvc23/sm-chapter) | Minimal registry/discovery server — a natural *consumer* that runs a divergence check each cycle. |
|
|
257
|
+
| [`sm-conformance`](https://github.com/Sharathvc23/sm-conformance) | The shared Ed25519 / JCS signing convention this library's optional DID check verifies. |
|
|
258
|
+
|
|
259
|
+
## Develop
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
make ci-local # uv: sync → ruff → format → mypy --strict → pytest
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
[MIT](LICENSE)
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
*First published: 2026-07-04 | Last modified: 2026-07-04*
|
|
272
|
+
|
|
273
|
+
*Personal research contributions aligned with [Project NANDA](https://projectnanda.org) standards. [Stellarminds.ai](https://stellarminds.ai)*
|