grundnorm 0.1.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.
@@ -0,0 +1,96 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # Playwright
17
+ /test-results/
18
+ /playwright-report/
19
+ /blob-report/
20
+ /playwright/.cache/
21
+ /tests/e2e/.auth/
22
+
23
+ # next.js
24
+ /.next/
25
+ /out/
26
+
27
+ # production
28
+ /build
29
+
30
+ # misc
31
+ .DS_Store
32
+ *.pem
33
+
34
+ # debug
35
+ npm-debug.log*
36
+ yarn-debug.log*
37
+ yarn-error.log*
38
+ .pnpm-debug.log*
39
+
40
+ # env files (can opt-in for committing if needed)
41
+ .env*
42
+
43
+ # vercel
44
+ .vercel
45
+
46
+ # typescript
47
+ *.tsbuildinfo
48
+ next-env.d.ts
49
+
50
+ # repomix output files
51
+ repomix-output.*
52
+ tools/mcp-dev/node_modules/
53
+ tools/**/node_modules/
54
+
55
+ # graphify knowledge graph (generated)
56
+ # Ignoramos el grueso (cache/, obsidian/, html, ficheros internos .graphify_*)
57
+ # pero VERSIONAMOS el mapa para que TODAS las instancias lo tengan vía git pull
58
+ # (memoria permanente del repo, sin re-leer todo → ahorra tokens).
59
+ graphify-out/*
60
+ !graphify-out/GRAPH_REPORT.md
61
+ !graphify-out/graph.json
62
+ !graphify-out/manifest.json
63
+
64
+ # MCP server build artifacts
65
+ mcp-server/node_modules/
66
+ mcp-server/dist/
67
+ mcp-server/*.tsbuildinfo
68
+
69
+ # Claude Code worktrees (local-only embedded repos)
70
+ .claude/worktrees/
71
+
72
+ # Documentos sensibles sueltos en raíz (cap tables, decks, etc.)
73
+ Nexus-Legal-Cap-Table-*.pdf
74
+ /*.pdf
75
+
76
+ # Listas de contactos con PII (nombres/emails) — no deben versionarse.
77
+ # El historial previo requiere un scrub aparte (BFG/git filter-repo).
78
+ email-lists/*.csv
79
+ # Artefactos operativos y de runtime
80
+ scripts/*.log
81
+ .gpu_enrich.txt
82
+ # Artefactos de Python
83
+ __pycache__/
84
+ *.pyc
85
+ *.pyo
86
+ # Entornos virtuales de Python (locales)
87
+ .venv-embed/
88
+ .venv/
89
+
90
+ # SDK build artifacts
91
+ sdk/typescript/node_modules/
92
+ sdk/typescript/dist/
93
+ sdk/python/**/__pycache__/
94
+ sdk/python/*.egg-info/
95
+ sdk/python/build/
96
+ sdk/python/dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quantum Nexus Ventures FZCO
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,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: grundnorm
3
+ Version: 0.1.0
4
+ Summary: Official SDK for the Grundnorm grounded source-of-law truth layer — resolve a legal norm by open identifier (ELI/ECLI) + date and verify its cryptographic seal locally.
5
+ Project-URL: Homepage, https://grundnorm.nexusquantum.legal
6
+ Project-URL: Repository, https://github.com/djtellado/nexus-grundnorm
7
+ Project-URL: Issues, https://github.com/djtellado/nexus-grundnorm/issues
8
+ Author-email: Quantum Nexus Ventures FZCO <support@nexusquantum.legal>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ecli,ed25519,eli,grundnorm,law,legal,source-of-law,verifiable
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: cryptography>=41.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # grundnorm (Python SDK)
22
+
23
+ Resolve a legal norm by its **open identifier** (ELI/ECLI) and a **date**, and get back its canonical,
24
+ sealed meaning as an **independently verifiable** signed object. The SDK recomputes the content hash and
25
+ verifies every Ed25519 signature locally — **you never have to trust the server**.
26
+
27
+ - Deterministic, zero-LLM read path. Honest `not_found` instead of a guess.
28
+ - Point-in-time: `[validFrom, validUntil)`.
29
+ - Python 3.9+. One dependency: `cryptography`.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install grundnorm
35
+ ```
36
+
37
+ ## Use
38
+
39
+ ```python
40
+ from grundnorm import GrundnormClient
41
+
42
+ # Defaults to the public GDPR demonstrator. For a pilot:
43
+ # GrundnormClient(endpoint="https://.../api/grundnorm/resolve", api_key="nlk_...")
44
+ client = GrundnormClient()
45
+
46
+ r = client.resolve(
47
+ id="http://data.europa.eu/eli/reg/2016/679/art_5",
48
+ jurisdiction="EU",
49
+ at="2026-07-07", # omit for "today"
50
+ )
51
+
52
+ if r.status == "found":
53
+ print(r.norm["atoms"]) # subject / modality / action / condition / exception / scope + evidence
54
+ print(r.verification["ok"]) # True only if hash recomputes AND all signatures verify
55
+ ```
56
+
57
+ `resolve()` verifies the seal by default. Skip with `verify=False`, or verify a stored envelope later:
58
+
59
+ ```python
60
+ from grundnorm import verify
61
+ v = verify(norm) # {"ok", "hash_ok", "recomputed_hash", "signatures", "quorum"}
62
+ ```
63
+
64
+ ## How verification works (no trust required)
65
+
66
+ 1. `sha256(canonicalize(norm["canonical"]["content"]))` must equal `norm["seal"]["contentHash"]`,
67
+ where `canonicalize` = JSON with object keys sorted recursively, no whitespace, hashed as UTF-8.
68
+ 2. Each `seal.signatures[].signatureHex` is an Ed25519 signature by that signer's `publicKeyHex`
69
+ over the UTF-8 bytes of the `seal.contentHash` hex string.
70
+
71
+ ## Errors
72
+
73
+ - Nothing sealed for `(id, date)` → returns `NotFound` (a normal outcome).
74
+ - Bad key, bad input, or server error → raises `GrundnormError` (`.status`, `.code`).
75
+
76
+ > Demonstrator note: the demo corpus (GDPR sample) is signed by demo keys, not institutions, and its
77
+ > accuracy is not yet jurist-graded. See the project's `DEMO-TRUTHFULNESS.md`.
@@ -0,0 +1,57 @@
1
+ # grundnorm (Python SDK)
2
+
3
+ Resolve a legal norm by its **open identifier** (ELI/ECLI) and a **date**, and get back its canonical,
4
+ sealed meaning as an **independently verifiable** signed object. The SDK recomputes the content hash and
5
+ verifies every Ed25519 signature locally — **you never have to trust the server**.
6
+
7
+ - Deterministic, zero-LLM read path. Honest `not_found` instead of a guess.
8
+ - Point-in-time: `[validFrom, validUntil)`.
9
+ - Python 3.9+. One dependency: `cryptography`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install grundnorm
15
+ ```
16
+
17
+ ## Use
18
+
19
+ ```python
20
+ from grundnorm import GrundnormClient
21
+
22
+ # Defaults to the public GDPR demonstrator. For a pilot:
23
+ # GrundnormClient(endpoint="https://.../api/grundnorm/resolve", api_key="nlk_...")
24
+ client = GrundnormClient()
25
+
26
+ r = client.resolve(
27
+ id="http://data.europa.eu/eli/reg/2016/679/art_5",
28
+ jurisdiction="EU",
29
+ at="2026-07-07", # omit for "today"
30
+ )
31
+
32
+ if r.status == "found":
33
+ print(r.norm["atoms"]) # subject / modality / action / condition / exception / scope + evidence
34
+ print(r.verification["ok"]) # True only if hash recomputes AND all signatures verify
35
+ ```
36
+
37
+ `resolve()` verifies the seal by default. Skip with `verify=False`, or verify a stored envelope later:
38
+
39
+ ```python
40
+ from grundnorm import verify
41
+ v = verify(norm) # {"ok", "hash_ok", "recomputed_hash", "signatures", "quorum"}
42
+ ```
43
+
44
+ ## How verification works (no trust required)
45
+
46
+ 1. `sha256(canonicalize(norm["canonical"]["content"]))` must equal `norm["seal"]["contentHash"]`,
47
+ where `canonicalize` = JSON with object keys sorted recursively, no whitespace, hashed as UTF-8.
48
+ 2. Each `seal.signatures[].signatureHex` is an Ed25519 signature by that signer's `publicKeyHex`
49
+ over the UTF-8 bytes of the `seal.contentHash` hex string.
50
+
51
+ ## Errors
52
+
53
+ - Nothing sealed for `(id, date)` → returns `NotFound` (a normal outcome).
54
+ - Bad key, bad input, or server error → raises `GrundnormError` (`.status`, `.code`).
55
+
56
+ > Demonstrator note: the demo corpus (GDPR sample) is signed by demo keys, not institutions, and its
57
+ > accuracy is not yet jurist-graded. See the project's `DEMO-TRUTHFULNESS.md`.
@@ -0,0 +1,27 @@
1
+ """Resolve GDPR Article 5 against the public demonstrator and verify its seal locally.
2
+
3
+ Run: pip install -e . && python examples/resolve.py
4
+ """
5
+ from grundnorm import GrundnormClient
6
+
7
+ client = GrundnormClient() # defaults to the public GDPR demonstrator
8
+
9
+ r = client.resolve(
10
+ id="http://data.europa.eu/eli/reg/2016/679/art_5",
11
+ jurisdiction="EU",
12
+ at="2026-07-07", # omit for "today"
13
+ )
14
+
15
+ if r.status == "not_found":
16
+ print("not_found:", r.note)
17
+ else:
18
+ print(f'{r.norm["articleLabel"]} — {len(r.norm["atoms"])} atoms, provenance {r.norm["provenance"]}')
19
+ for a in r.norm["atoms"]:
20
+ print(f' · [{a["modality"]}/{a["status"]}] {a["subject"]} — {a["action"]}')
21
+ v = r.verification
22
+ print(
23
+ f'\nseal verified locally: {"YES" if v["ok"] else "NO"} '
24
+ f'(hash {"ok" if v["hash_ok"] else "MISMATCH"}, '
25
+ f'{v["quorum"]["valid_signatures"]}/{len(v["signatures"])} signatures, '
26
+ f'sovereign: {v["quorum"]["has_sovereign"]})'
27
+ )
@@ -0,0 +1,15 @@
1
+ """Official Python SDK for the Grundnorm grounded source-of-law truth layer."""
2
+ from .client import DEFAULT_ENDPOINT, DEMO_KEY, GrundnormClient, GrundnormError, NotFound, Resolved
3
+ from .verify import canonicalize, verify
4
+
5
+ __all__ = [
6
+ "GrundnormClient",
7
+ "GrundnormError",
8
+ "Resolved",
9
+ "NotFound",
10
+ "verify",
11
+ "canonicalize",
12
+ "DEFAULT_ENDPOINT",
13
+ "DEMO_KEY",
14
+ ]
15
+ __version__ = "0.1.0"
@@ -0,0 +1,85 @@
1
+ """Grundnorm Python SDK — resolve a legal norm by open identifier + date, verify the seal locally."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from dataclasses import dataclass
9
+ from typing import Any, Dict, Optional, Union
10
+
11
+ from .verify import verify as _verify
12
+
13
+ DEFAULT_ENDPOINT = "https://ptqvvfnnnfltiwuvkxxw.supabase.co/functions/v1/grundnorm-resolve"
14
+ DEMO_KEY = "nlk_demo_grundnorm_2026"
15
+
16
+
17
+ class GrundnormError(Exception):
18
+ """Auth / validation / server error from the resolve endpoint."""
19
+
20
+ def __init__(self, message: str, status: int, code: Optional[str] = None):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.code = code
24
+
25
+
26
+ @dataclass
27
+ class NotFound:
28
+ """Nothing sealed for (id, date) — a normal outcome, not an error."""
29
+
30
+ identifier: str
31
+ at: str
32
+ note: str
33
+ status: str = "not_found"
34
+
35
+
36
+ @dataclass
37
+ class Resolved:
38
+ """A resolved, sealed norm plus its local verification (None if verify=False)."""
39
+
40
+ norm: Dict[str, Any]
41
+ verification: Optional[Dict[str, Any]]
42
+ status: str = "found"
43
+
44
+
45
+ class GrundnormClient:
46
+ def __init__(self, endpoint: str = DEFAULT_ENDPOINT, api_key: str = DEMO_KEY, timeout: float = 30.0):
47
+ self.endpoint = endpoint
48
+ self.api_key = api_key
49
+ self.timeout = timeout
50
+
51
+ def resolve(
52
+ self,
53
+ id: str,
54
+ jurisdiction: str,
55
+ at: Optional[str] = None,
56
+ domain: Optional[str] = None,
57
+ verify: bool = True,
58
+ ) -> Union[Resolved, NotFound]:
59
+ """Resolve a norm's sealed canonical meaning by identifier + date. Deterministic, zero-LLM.
60
+
61
+ Returns ``Resolved`` on success (with a local seal ``verification`` unless ``verify=False``),
62
+ ``NotFound`` when nothing is sealed for (id, date), and raises ``GrundnormError`` otherwise.
63
+ """
64
+ params = {"id": id, "jurisdiction": jurisdiction}
65
+ if at:
66
+ params["at"] = at
67
+ if domain:
68
+ params["domain"] = domain
69
+ url = self.endpoint + "?" + urllib.parse.urlencode(params)
70
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {self.api_key}"})
71
+
72
+ try:
73
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
74
+ body = json.loads(r.read().decode("utf-8"))
75
+ except urllib.error.HTTPError as e:
76
+ body = {}
77
+ try:
78
+ body = json.loads(e.read().decode("utf-8"))
79
+ except Exception:
80
+ pass
81
+ if e.code == 404 and body.get("status") == "not_found":
82
+ return NotFound(body.get("identifier"), body.get("at"), body.get("note"))
83
+ raise GrundnormError(body.get("error", f"HTTP {e.code}"), e.code, body.get("code")) from None
84
+
85
+ return Resolved(body, _verify(body) if verify else None)
@@ -0,0 +1,64 @@
1
+ """Canonicalization + Ed25519 verification. Must match the Grundnorm server byte-for-byte."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from typing import Any, Dict
7
+
8
+ from cryptography.exceptions import InvalidSignature
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
10
+
11
+
12
+ def canonicalize(value: Any) -> str:
13
+ """Deterministic JSON: object keys sorted recursively, no insignificant whitespace.
14
+
15
+ Mirrors the server's canonicalize(): objects -> sorted keys, arrays -> in order, primitives ->
16
+ json.dumps. Output is hashed as UTF-8.
17
+ """
18
+ if value is None or not isinstance(value, (dict, list)):
19
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
20
+ if isinstance(value, list):
21
+ return "[" + ",".join(canonicalize(v) for v in value) + "]"
22
+ keys = sorted(value.keys())
23
+ return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonicalize(value[k]) for k in keys) + "}"
24
+
25
+
26
+ def _sha256_hex(s: str) -> str:
27
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
28
+
29
+
30
+ def _verify_signature(content_hash_hex: str, signature_hex: str, public_key_hex: str) -> bool:
31
+ try:
32
+ pk = Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
33
+ # The message signed is the UTF-8 bytes of the content-hash HEX STRING (not the raw hash bytes).
34
+ pk.verify(bytes.fromhex(signature_hex), content_hash_hex.encode("utf-8"))
35
+ return True
36
+ except (InvalidSignature, ValueError):
37
+ return False
38
+
39
+
40
+ def verify(norm: Dict[str, Any]) -> Dict[str, Any]:
41
+ """Verify a resolved norm WITHOUT trusting the server.
42
+
43
+ Recomputes the content hash from the exposed canonical preimage and checks every Ed25519
44
+ signature against the public keys in the response. Returns a dict with ``ok`` True iff the hash
45
+ recomputes AND every signature verifies (at least one signature present).
46
+ """
47
+ seal = norm["seal"]
48
+ recomputed = _sha256_hex(canonicalize(norm["canonical"]["content"]))
49
+ hash_ok = recomputed == seal["contentHash"]
50
+
51
+ signatures = []
52
+ for s in seal["signatures"]:
53
+ ok = _verify_signature(seal["contentHash"], s["signatureHex"], s["publicKeyHex"])
54
+ signatures.append({"signerId": s["signerId"], "signerName": s["signerName"], "role": s["role"], "ok": ok})
55
+
56
+ valid = sum(1 for s in signatures if s["ok"])
57
+ has_sovereign = any(s["ok"] and s["role"] == "N2_sovereign" for s in signatures)
58
+ return {
59
+ "ok": hash_ok and len(signatures) > 0 and all(s["ok"] for s in signatures),
60
+ "hash_ok": hash_ok,
61
+ "recomputed_hash": recomputed,
62
+ "signatures": signatures,
63
+ "quorum": {"valid_signatures": valid, "has_sovereign": has_sovereign},
64
+ }
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "grundnorm"
7
+ version = "0.1.0"
8
+ description = "Official SDK for the Grundnorm grounded source-of-law truth layer — resolve a legal norm by open identifier (ELI/ECLI) + date and verify its cryptographic seal locally."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Quantum Nexus Ventures FZCO", email = "support@nexusquantum.legal" }]
13
+ keywords = ["grundnorm", "legal", "law", "eli", "ecli", "verifiable", "ed25519", "source-of-law"]
14
+ dependencies = ["cryptography>=41.0"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Topic :: Scientific/Engineering",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://grundnorm.nexusquantum.legal"
25
+ Repository = "https://github.com/djtellado/nexus-grundnorm"
26
+ Issues = "https://github.com/djtellado/nexus-grundnorm/issues"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["grundnorm"]
@@ -0,0 +1,170 @@
1
+ {
2
+ "@context": "https://grundnorm.nexusquantum.legal/ns/v1",
3
+ "@type": "GroundedNorm",
4
+ "identifier": "http://data.europa.eu/eli/reg/2016/679/art_5",
5
+ "scheme": "eli",
6
+ "at": "2026-07-07",
7
+ "jurisdiction": "EU",
8
+ "domain": "legislation",
9
+ "articleLabel": "GDPR — Article 5 (Principles relating to processing of personal data) · DEMO",
10
+ "status": "sealed",
11
+ "legalForce": "official_reference",
12
+ "provenance": "N2_sovereign",
13
+ "validFrom": "2018-05-25",
14
+ "validUntil": null,
15
+ "version": 1,
16
+ "supersedesHash": null,
17
+ "atoms": [
18
+ {
19
+ "id": "a1",
20
+ "statement": "the controller — must process personal data lawfully, fairly and in a transparent manner in relation to the data subject.",
21
+ "subject": "the controller",
22
+ "modality": "obligation",
23
+ "action": "process personal data lawfully, fairly and in a transparent manner in relation to the data subject",
24
+ "condition": null,
25
+ "exception": null,
26
+ "scope": "for the purposes of this Regulation",
27
+ "evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner in relation to the data subject",
28
+ "inverse": "the controller may process personal data unlawfully, unfairly or opaquely",
29
+ "consensus": {
30
+ "agree": 3,
31
+ "reads": 3,
32
+ "independence": "multi"
33
+ },
34
+ "status": "fixed"
35
+ },
36
+ {
37
+ "id": "a2",
38
+ "statement": "the controller — must limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed.",
39
+ "subject": "the controller",
40
+ "modality": "obligation",
41
+ "action": "limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed",
42
+ "condition": null,
43
+ "exception": null,
44
+ "scope": "for the purposes of this Regulation",
45
+ "evidence": "adequate, relevant and limited to what is necessary in relation to the purposes for which they are processed",
46
+ "inverse": "the controller may collect personal data beyond what is necessary for the purpose",
47
+ "consensus": {
48
+ "agree": 3,
49
+ "reads": 3,
50
+ "independence": "multi"
51
+ },
52
+ "status": "fixed"
53
+ }
54
+ ],
55
+ "purpose": {
56
+ "aim": "Establish the core principles that govern all processing of personal data",
57
+ "problem": "Unbounded, opaque or excessive processing of personal data",
58
+ "protectedInterest": "The fundamental right to the protection of personal data",
59
+ "protectedParty": "The data subject (natural person)",
60
+ "evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner",
61
+ "consensus": {
62
+ "agree": 3,
63
+ "reads": 3,
64
+ "independence": "multi"
65
+ }
66
+ },
67
+ "canonical": {
68
+ "hashAlgorithm": "sha256",
69
+ "canonicalization": "json-sorted-keys-v1",
70
+ "recipe": "1) contentHash: sha256(canonicalize(canonical.content)) must equal seal.contentHash, where canonicalize = JSON with object keys sorted recursively (lexicographic by UTF-16 code unit), no insignificant whitespace, hashed as UTF-8. 2) signatures: each seal.signatures[].signatureHex is an Ed25519 signature by that signer's publicKeyHex over the UTF-8 bytes of the seal.contentHash hex string.",
71
+ "content": {
72
+ "atoms": [
73
+ {
74
+ "id": "a1",
75
+ "accion": "process personal data lawfully, fairly and in a transparent manner in relation to the data subject",
76
+ "status": "fijado",
77
+ "sujeto": "the controller",
78
+ "alcance": "for the purposes of this Regulation",
79
+ "inverse": "the controller may process personal data unlawfully, unfairly or opaquely",
80
+ "evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner in relation to the data subject",
81
+ "condicion": null,
82
+ "consensus": {
83
+ "agree": 3,
84
+ "reads": 3,
85
+ "independence": "multi"
86
+ },
87
+ "excepcion": null,
88
+ "modalidad": "obligacion",
89
+ "statement": "the controller — must process personal data lawfully, fairly and in a transparent manner in relation to the data subject."
90
+ },
91
+ {
92
+ "id": "a2",
93
+ "accion": "limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed",
94
+ "status": "fijado",
95
+ "sujeto": "the controller",
96
+ "alcance": "for the purposes of this Regulation",
97
+ "inverse": "the controller may collect personal data beyond what is necessary for the purpose",
98
+ "evidence": "adequate, relevant and limited to what is necessary in relation to the purposes for which they are processed",
99
+ "condicion": null,
100
+ "consensus": {
101
+ "agree": 3,
102
+ "reads": 3,
103
+ "independence": "multi"
104
+ },
105
+ "excepcion": null,
106
+ "modalidad": "obligacion",
107
+ "statement": "the controller — must limit personal data to what is adequate, relevant and necessary for the purposes for which they are processed."
108
+ }
109
+ ],
110
+ "domain": "legislation",
111
+ "purpose": {
112
+ "evidence": "Personal data shall be: processed lawfully, fairly and in a transparent manner",
113
+ "problema": "Unbounded, opaque or excessive processing of personal data",
114
+ "consensus": {
115
+ "agree": 3,
116
+ "reads": 3,
117
+ "independence": "multi"
118
+ },
119
+ "finalidad": "Establish the core principles that govern all processing of personal data",
120
+ "bienJuridico": "The fundamental right to the protection of personal data",
121
+ "sujetoProtegido": "The data subject (natural person)"
122
+ },
123
+ "sourceRef": {
124
+ "uri": "http://data.europa.eu/eli/reg/2016/679/art_5",
125
+ "scheme": "eli",
126
+ "validFrom": "2018-05-25",
127
+ "validUntil": null,
128
+ "jurisdiction": "EU"
129
+ },
130
+ "legalForce": "official_reference",
131
+ "articleLabel": "GDPR — Article 5 (Principles relating to processing of personal data) · DEMO",
132
+ "jurisdiction": "EU"
133
+ }
134
+ },
135
+ "seal": {
136
+ "contentHash": "d5eba87825e051ac1e2634ca197f36d07843aaf99462dbf669c530947f1b4ba9",
137
+ "merkleRoot": "3ef510f2cadc7d6a769f0d83a2576d07d4455f370fdaf56233d1ed00b9d01000",
138
+ "ledgerBlockHash": "1a5348646b242cd206eeac6b6ed68c52b55c326f2e34de4034910ee505f8dd03",
139
+ "blockIndex": 0,
140
+ "anchoredBackend": "mock",
141
+ "anchoredRef": "demo-tx-d5eba87825e0",
142
+ "anchoredUrl": null,
143
+ "signatures": [
144
+ {
145
+ "signerId": "EU-LEG-parliament",
146
+ "signerName": "European Parliament · DEMO",
147
+ "role": "N2_sovereign",
148
+ "publicKeyHex": "87a00dd91e92fc623f08540140dca4f88d0b2048cca34cb612de5738acb8145c",
149
+ "signatureHex": "a2db7c0ddf9def43c63986207a3cd019d6be88235f7469e0a582ea90812a47b671e3c7e5e94b2ea8841c21bda61e8440301a0e0671c46141bf3dab33d25b530e",
150
+ "signedAt": "2026-07-07T10:00:00+00:00"
151
+ },
152
+ {
153
+ "signerId": "EU-LEG-council",
154
+ "signerName": "Council of the EU · DEMO",
155
+ "role": "N2_sovereign",
156
+ "publicKeyHex": "95960c1b509792befdea49870040d856061f88b2f78c8b40363fff4d9b8d36e2",
157
+ "signatureHex": "5dd05925f2641f6252b8e22c43df8e4a5825bb9bad3d9cf8b19d5a3c8bc009a7b154c4da7f790589640103c20cac89459b55e823ff307113a66edf9dbe950f0b",
158
+ "signedAt": "2026-07-07T10:00:00+00:00"
159
+ },
160
+ {
161
+ "signerId": "EU-LEG-jurist",
162
+ "signerName": "Legal reviewer · DEMO",
163
+ "role": "N1_expert",
164
+ "publicKeyHex": "25c80d8fa40894c89e31f232cc1dd49bc2bc3b3b1f0e651a0201d569aa6224f4",
165
+ "signatureHex": "ba4c0837aff57a24984b81345a317da420265f0cf56ec4698900f82ebec39cca269e2d464bf4bdcc3468cc076229852b730df773c85491892040fd992acbe70f",
166
+ "signedAt": "2026-07-07T10:00:00+00:00"
167
+ }
168
+ ]
169
+ }
170
+ }
@@ -0,0 +1,28 @@
1
+ import unittest
2
+
3
+ from grundnorm import canonicalize
4
+
5
+
6
+ # These vectors are IDENTICAL to the TypeScript SDK's canonicalize.test.mjs — cross-language parity is
7
+ # the whole point: the same content must produce the same hash on the server, in TS, and in Python.
8
+ class TestCanonicalize(unittest.TestCase):
9
+ def test_sorts_keys_recursively(self):
10
+ self.assertEqual(canonicalize({"b": 1, "a": 2}), '{"a":2,"b":1}')
11
+ self.assertEqual(
12
+ canonicalize({"z": {"y": 1, "x": 2}, "a": [1, {"c": 3, "b": 4}]}),
13
+ '{"a":[1,{"b":4,"c":3}],"z":{"x":2,"y":1}}',
14
+ )
15
+
16
+ def test_array_order(self):
17
+ self.assertEqual(canonicalize([3, 1, 2]), "[3,1,2]")
18
+
19
+ def test_primitives_and_escaping(self):
20
+ self.assertEqual(canonicalize(None), "null")
21
+ self.assertEqual(canonicalize(True), "true")
22
+ self.assertEqual(canonicalize(5), "5")
23
+ self.assertEqual(canonicalize("café"), '"café"') # non-ASCII is NOT escaped
24
+ self.assertEqual(canonicalize('a"b\\c'), '"a\\"b\\\\c"') # quote + backslash escaped
25
+
26
+
27
+ if __name__ == "__main__":
28
+ unittest.main()
@@ -0,0 +1,42 @@
1
+ import copy
2
+ import json
3
+ import os
4
+ import unittest
5
+
6
+ from grundnorm import verify
7
+
8
+ FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "gdpr-art5.json")
9
+
10
+
11
+ # Fixture = a real sealed record captured from the live endpoint (GDPR Art. 5, demo signers).
12
+ class TestVerify(unittest.TestCase):
13
+ def setUp(self):
14
+ with open(FIXTURE, encoding="utf-8") as f:
15
+ self.norm = json.load(f)
16
+
17
+ def test_verifies_real_record(self):
18
+ v = verify(self.norm)
19
+ self.assertTrue(v["hash_ok"])
20
+ self.assertTrue(v["ok"])
21
+ self.assertGreaterEqual(len(v["signatures"]), 1)
22
+ self.assertTrue(all(s["ok"] for s in v["signatures"]))
23
+ self.assertEqual(v["recomputed_hash"], self.norm["seal"]["contentHash"])
24
+
25
+ def test_detects_tampered_content(self):
26
+ t = copy.deepcopy(self.norm)
27
+ t["canonical"]["content"]["atoms"][0]["statement"] += " TAMPERED"
28
+ v = verify(t)
29
+ self.assertFalse(v["hash_ok"])
30
+ self.assertFalse(v["ok"])
31
+
32
+ def test_detects_corrupted_signature(self):
33
+ t = copy.deepcopy(self.norm)
34
+ sig = t["seal"]["signatures"][0]["signatureHex"]
35
+ t["seal"]["signatures"][0]["signatureHex"] = ("1" if sig[0] == "0" else "0") + sig[1:]
36
+ v = verify(t)
37
+ self.assertFalse(v["signatures"][0]["ok"])
38
+ self.assertFalse(v["ok"])
39
+
40
+
41
+ if __name__ == "__main__":
42
+ unittest.main()