Metadata-Version: 2.5
Name: grundnorm
Version: 0.6.0
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.
Project-URL: Homepage, https://grundnorm.nexusquantum.legal
Author-email: Quantum Nexus Ventures FZCO <support@nexusquantum.legal>
License: MIT
License-File: LICENSE
Keywords: ecli,ed25519,eli,grundnorm,law,legal,source-of-law,verifiable
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.9
Requires-Dist: cryptography>=41.0
Description-Content-Type: text/markdown

# 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_found` instead of a guess.
- Point-in-time: `[validFrom, validUntil)`.
- Python 3.9+. One dependency: `cryptography`.

## Install

```bash
pip install grundnorm
```

## Use

```python
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:

```python
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)

1. **Hash**: `sha256(canonicalize(norm["canonical"]["content"]))` equals `norm["seal"]["contentHash"]`,
   where `canonicalize` = JSON with keys sorted recursively (UTF-16 order), no whitespace, UTF-8.
2. **View integrity**: the English `atoms`/`purpose` are exactly what the sealed `canonical.content`
   projects to (`view_consistent`); `canonical.content` is the source of truth.
3. **Signatures**: each `seal.signatures[].signatureHex` is a valid Ed25519 signature by that signer's
   `publicKeyHex` over the UTF-8 bytes of the `seal.contentHash` hex string.
4. **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 fails `ok`. `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 the `verdict`.
- **`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` (`True` iff any valid signature matched a forgeable demo pin → not authoritative), `classification` (`"rule_reexecuted"`, `"not_present"`, or `"rule_unsupported"`).
- **Key pinning (`key_provenance`, per-signature `pinned`)** is **tamper-evidence of a snapshot**, not proof of institutional independence: a single-party demo key is pinned (`pinned_demo`) yet `custody` stays `"unverified"` and the verdict never reaches `verified_authoritative`. It is not a key-transparency log.

## Breaking changes in 0.6.0

Two fields were renamed on `verifyAbsence()` 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 `hashOk: 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.

```python
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"
v["epoch"]["scope"]       # READ THIS FIRST: completeness, covers, freshness, known exclusions
v["epoch"]["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)` → returns `NotFound` (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.
