grundnorm (Python SDK)
Resolve a legal norm by its open identifier (ELI/ECLI) and a date, and get back its canonical, sealed meaning as an independently verifiable signed object. The SDK recomputes the content hash and verifies every Ed25519 signature locally — you never have to trust the server.
- Deterministic, zero-LLM read path. Honest
not_foundinstead of a guess. - Point-in-time:
[validFrom, validUntil). - Python 3.9+. One dependency:
cryptography.
Install
pip install grundnorm
Use
from grundnorm import GrundnormClient
# Defaults to the public GDPR demonstrator. For a pilot:
# GrundnormClient(endpoint="https://.../api/grundnorm/resolve", api_key="nlk_...")
client = GrundnormClient()
r = client.resolve(
id="http://data.europa.eu/eli/reg/2016/679/art_5",
jurisdiction="EU",
at="2026-07-07", # omit for "today"
)
if r.status == "found":
print(r.norm["atoms"]) # subject / modality / action / condition / exception / scope + evidence
print(r.verification["verdict"]) # one discriminated verdict, never a boolean
resolve() verifies the seal by default. Skip with verify=False, or verify a stored envelope later:
from grundnorm import verify
v = verify(norm, {"id": ..., "jurisdiction": ..., "at": ...}) # pass the question you asked
# {"verdict", "hash_ok", "view_consistent", "envelope_consistent", "at_within_sealed_window",
# "status_sealed", "provenance_consistent", "request_bound", "unsealed_fields",
# "signatures", "quorum", "attestation"}
verify() returns ONE discriminated verdict, and deliberately no boolean beside it. A boolean
next to a status is an invitation to read the boolean, and it can only ever answer a narrower question
than its name suggests. The closed set (VERDICTS), evaluated in fixed precedence:
| verdict | meaning |
|---|---|
verified_authoritative |
The trust gate. Everything re-derives AND the keys are known non-demo institutions with a pin-anchored quorum (>=2 pinned valid signatures incl. >=1 pinned sovereign). |
verified_not_authoritative |
Bytes, envelope and signatures all re-derive, but the keys are not. Every current demo record lands here: the demo keys are derived from a seed held in the source tree, so they carry no custody guarantee. And all three demo signers derive from the SAME seed, so a demo seal is cryptographically one party signing three times, not a three-institution consensus. |
content_tampered |
The sealed preimage does not produce seal.contentHash. |
view_inconsistent |
The served atoms/purpose are not what the sealed content projects to. |
envelope_contradicts_seal |
The envelope claims an identifier, jurisdiction, domain, label, legal force or validity window that the sealed content contradicts. |
out_of_sealed_window |
The date served falls outside the record's own sealed validity window. |
not_sealed |
The record is not served as sealed. |
signature_invalid |
A signature does not verify, or there are none. |
signature_duplicated |
A public key repeats — one key cannot satisfy a two-signer quorum. |
provenance_unsupported |
The served provenance is not what the signatures derive. |
status_rule_violated |
The D5 witness is present and an atom's status does not re-execute. |
answers_different_question |
The response does not answer the request that was passed in. |
malformed |
Not a resolvable body. |
A verdict this client does not know MUST be rejected, never defaulted into a success branch: adding
one is a version bump. is_verified(verdict) exists for the coarse split and is a function, never
a field, so a boolean cannot end up sitting beside the status again.
Neither verified state establishes that the meaning is jurist-correct. That is graded by an independent jurist against a blind labelled set, and that number does not exist yet.
Pass the question you asked. resolve() does it for you. Calling verify(norm) bare leaves
request_bound: None and a signed response for one identifier replays as the answer to another.
What the signature does not cover is listed in unsealed_fields, not left implicit: version,
supersedesHash, conflict, the ledger/anchor metadata, and set membership of the signatures — a
valid signature can be dropped and the record still verifies with a smaller quorum, because the sealed
preimage does not enumerate who was meant to sign. Closing that needs a signed commitment to the
record set, not a bigger per-record hash.
What verify() re-derives (no trust required)
- Hash:
sha256(canonicalize(norm["canonical"]["content"]))equalsnorm["seal"]["contentHash"], wherecanonicalize= JSON with keys sorted recursively (UTF-16 order), no whitespace, UTF-8. - View integrity: the English
atoms/purposeare exactly what the sealedcanonical.contentprojects to (view_consistent);canonical.contentis the source of truth. - Signatures: each
seal.signatures[].signatureHexis a valid Ed25519 signature by that signer'spublicKeyHexover the UTF-8 bytes of theseal.contentHashhex string. - Status (when a classification witness is present): each atom's status
(
fixed/needs_review/for_the_court) re-executes from the deterministic rule applied to the sealed per-atom signals (status_re_derivable). A mismatch failsok.None= no witness (older seals) → not re-derivable, no penalty. Bounded: proves the rule was applied to sealed inputs; it does not prove the deontic decomposition is faithful to the article (that is the jurist gate).
What it REPORTS but does not prove
quorum— a signature count (valid_signatures,has_sovereign,meets= >=2 incl. one sovereign). The sovereign role is pin-anchored where a pin exists, but an unpinned signer's claimed role is taken on trust. For a spoof-proof gate read theverdict.attestation— honest flags the SDK cannot prove:custody("unverified"for the demo),independence(as asserted),ledger("unanchored", or"claimed_unverified:<backend>"for a record claiming an anchor the SDK cannot verify — it never says"anchored:"),key_provenance("pinned_oob"= known non-demo pin;"pinned_demo"= only forgeable demo pins;"in_band_response"= no pin / substitution),demo_keys(Trueiff any valid signature matched a forgeable demo pin → not authoritative),classification("rule_reexecuted","not_present","rule_unsupported", or"witness_partial"— the record seals definitions or modulations whosestatusthe witness does not cover, so the rule cannot be re-derived for the WHOLE record).- Key pinning (
key_provenance, per-signaturepinned) is tamper-evidence of a snapshot, not proof of institutional independence: a single-party demo key is pinned (pinned_demo) yetcustodystays"unverified"and the verdict never reachesverified_authoritative. It is not a key-transparency log.
Breaking changes in 0.7.0
absence gained two values, and a consumer testing absence == "proven" needs to know why it may
now read false where 0.6.0 said true:
| value | meaning |
|---|---|
scope_does_not_cover |
the proof verifies, but the epoch's SIGNED covers window excludes the queried date |
stale |
the epoch is older than the freshness it promises (head.at + freshness.hours vs observedAt) |
Both mean the same thing in different words: the proof is impeccable and answers a different
question. Measured against the live endpoint on 2026-08-23, a query with no at returned
absence: "proven" while epoch.coversQuery in the same body was false.
New field epoch.fresh (true | false | null). null = nothing was promised, which does not
penalise.
Breaking changes in 0.6.0
Two fields were renamed on verify_absence() because their names were broad enough to be read as
the verdict they sat beside, and one of them sat one line from attested_not_authoritative:
| 0.5.x | 0.6.0 |
|---|---|
attested |
statement_present |
supported |
version_supported |
The negative response also changed shape. absenceProof (one) became absenceProofs (an array),
and epoch.provedIdentity became epoch.provedIdentities. A query that names no domain asks
about EVERY domain, and the server used to fill the gap with a default and prove the absence of a
key the caller never named. There is now one proof per domain asked, and absence reads proven
only if every one of them verifies.
Upgrade from 0.5.x: verify() in 0.4.0–0.5.1 returned hash_ok: True for content that cannot be
canonicalized at all. Treat any verdict from those versions as unverified and re-check with 0.6.0.
Negative answers
A 404 is not an empty body: it is a statement signed by the operator, so a negative is
attributable rather than something anyone could fabricate offline.
from grundnorm import verify_absence, is_attested
v = verify_absence(body, {"id": id, "jurisdiction": j, "at": at}) # pass the question you asked
v["verdict"] # attested_not_authoritative | proof_invalid | statement_tampered | ...
v["absence"] # "not_offered" | "proven" | "invalid" | "present_out_of_scope" | "scope_does_not_cover" | "stale"
(v["epoch"] or {}).get("scope") # READ THIS FIRST: completeness, covers, freshness, known exclusions
(v["epoch"] or {}).get("headOk") # the epoch head verified on its own, not vouched for by this answer
verdict and absence are two axes and are never collapsed into one field. A statement can be
perfectly authentic and carry no proof at all (not_offered), and a proven absence still says
nothing about the law: it establishes that the identity is not in the set the operator committed
to, under the scope in epoch.scope. Read the scope before the root.
Errors
- Nothing sealed for
(id, date)→ returnsNotFound(a normal outcome). - Bad key, bad input, or server error → raises
GrundnormError(.status,.code).
Demonstrator note: the demo corpus (GDPR sample) is signed by demo keys, not institutions, and its accuracy is not yet jurist-graded. See the project's
DEMO-TRUTHFULNESS.md.
Reporting a defect
Write to support@nexusquantum.legal. There is no public issue tracker: the repository is not open yet, and pointing this package at a URL that 404s would promise a channel that does not exist.
Security defects in the verifier are the ones we most want to hear about — every one reported so far has been a class rather than an instance, and each is credited in the CHANGELOG.
Metadata
Release files for grundnorm 0.7.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| grundnorm-0.7.2.tar.gz | 58.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| grundnorm-0.7.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 90.2 kB
Release files / grundnorm-0.7.2.tar.gz
| Download URL | grundnorm-0.7.2.tar.gz |
|---|---|
| Size | 58.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f1aa5b13d3b909f8d4e965dffd6a96188a81415e8285bae120f456d4ae4d66c3
|
|
BLAKE2b-256 checksum How to use checksums |
fc6ce88f554a0c5eaa7fd1799fc3987f1a7477a3650f27b592b8239a36e6c08b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.5
|
Release files / grundnorm-0.7.2-py3-none-any.whl
| Download URL | grundnorm-0.7.2-py3-none-any.whl |
|---|---|
| Size | 31.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cd3dea8eb6609c657edb31055ea7cd1763d59e770fe82d4ced7767fe6fb8b697
|
|
BLAKE2b-256 checksum How to use checksums |
4d9baa2a2176025570b573a544a7e32fa77cd8973ee0d483eef8bffe45b49468
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.5
|