grundnorm
v0.7.2
Published
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.
Downloads
624
Maintainers
Readme
grundnorm (TypeScript 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)— "what did the law say on day X". - Node.js 20+. Zero runtime dependencies.
Install
npm install grundnormUse
import { GrundnormClient } from "grundnorm";
// Defaults to the public GDPR demonstrator. For a pilot, pass your endpoint + metered nlk_ key:
// new GrundnormClient({ endpoint: "https://.../api/grundnorm/resolve", apiKey: "nlk_..." })
const client = new GrundnormClient();
const r = await 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") {
console.log(r.norm.atoms); // subject / modality / action / condition / exception / scope + evidence
console.log(r.verification?.verdict); // one discriminated verdict, never a boolean
}resolve() verifies the seal by default. Skip it with { verify: false }, or verify a stored
envelope later:
import { verify } from "grundnorm";
const v = verify(norm, { id, jurisdiction, at }); // pass the question you asked
// { verdict, hashOk, viewConsistent, envelopeConsistent, atWithinSealedWindow, statusSealed,
// provenanceConsistent, requestBound, unsealedFields, 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. isVerified(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
requestBound: null and a signed response for one identifier replays as the answer to another.
What the signature does not cover is listed in unsealedFields, 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 object keys sorted recursively (UTF-16 order), no whitespace, UTF-8. - View integrity: the English
atoms/purposeare exactly what the sealedcanonical.contentprojects to (viewConsistent).canonical.contentis the source of truth. - Signatures: every
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 (statusReDerivable). A mismatch failsok.null= no witness (older seals) → not re-derivable, no penalty. Bounded: this 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, not cryptography).
All run in your process against the response; the recipe is echoed in norm.canonical.recipe.
What it REPORTS but does not prove
quorum— a signature count (validSignatures,hasSovereign,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 — a count, not proof of institutional independence. For a spoof-proof gate read theverdict.attestation— honest flags the SDK cannot prove cryptographically:custody("unverified"for the demo — one party regardless of pinning),independence(as asserted in the record),ledger("unanchored", or"claimed_unverified:<backend>"for a record claiming an anchor the SDK cannot verify — it never says"anchored:"),keyProvenance("pinned_oob"= matches a known non-demo pin;"pinned_demo"= matches only forgeable demo pins;"in_band_response"= no pin matched or a substitution),demoKeys(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 (
keyProvenance, per-signaturepinned) is tamper-evidence of a snapshot, not proof of institutional independence: a single-party demo key is pinned yetcustodystays"unverified". 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 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 | statementPresent |
| supported | versionSupported |
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.
import { verifyAbsence, isAttested } from "grundnorm";
const v = verifyAbsence(body, { id, jurisdiction, 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?.scope // READ THIS FIRST: completeness, covers, freshness, known exclusions
v.epoch?.headOk // the epoch head verified on its own, not vouched for by this answerverdict 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)→{ status: "not_found" }(a normal outcome). - Bad key, bad input, or server error → throws
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 [email protected]. 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.
