Experimental prerelease. Engine only. Not an MCP server. No I/O, network, persistence or transport.
Version 0.1.0 · claim-by-claim evidence: CLAIM_MAP.md · not independently verified.
The admissibility model and claim-map linter, as pure functions. No I/O, no network, no transport.
This is not an MCP server.It is the engine an MCP server would consume. There is no entrypoint, no tool handler and no deployment here.norms-mcp
, the server, does not yet exist as a published artifact. The naming distinction is deliberate: claiming a transport that is absent would be exactly the kind of overclaim this library exists to make visible.
npm test # 73 tests + claim-map sync check; no network, no account required
Computes, for each constant in a normative corpus, its evidentiary status and eligibility verdict — so a consumer can decline to rely on something that is not currently good for anything. This package computes; it publishes nothing, because it has no transport.
It enforces nothing. It computes eligibility and reports it. The decision belongs to whoever is asking.
The Italian word for the property is opponibile: something you can assert against someone, before a third party. Enforceable is the wrong translation — enforcement means somebody applies it. The closest is admissibility: whether a claim holds up when challenged.
Origin and verification answer different questions, and a constant does not stop being declared when it becomes ratified.
origin.type SOURCE_DOCUMENT | OWNER_DECLARATION
verification_state RATIFIED | UNCONFIRMED
UNCONFIRMED |
RATIFIED |
|
|---|---|---|
SOURCE_DOCUMENT |
transcribed, never checked | checked against its source |
OWNER_DECLARATION |
asserted, awaiting a ratification act | asserted, then formally approved |
The bottom-right cell is the one a single-axis scheme cannot name. It matters beyond bookkeeping: it is what lets a later reader distinguish norms derived from documents from norms that stand because the owner said so.
A binary field cannot distinguish checked and false from never checked.
currency CURRENT | STALE | UNKNOWN
authority_status VALID | INVALID | UNKNOWN
expiry_status ACTIVE | EXPIRED | REVIEW_DUE | UNKNOWN
UNKNOWN
fails closed — it yields a negative result — but never appears in
blocking
. A thing nobody has examined is not a thing found wanting.
import { eligibleAsGround } from 'norms-mcp-engine/model';
eligibleAsGround(entry);
// { eligible: false,
// blocking: [], // nothing was found wanting
// unknown: ['authority_status'], // nobody has checked
// unexamined: true }
eligible_as_ground = verification_state == RATIFIED
AND currency == CURRENT
AND authority_status == VALID
AND expiry_status == ACTIVE
That answers may this be used as a ground at all. It does not answer does it bear on the decision in front of me — a constant can be ratified, current, competently approved and unexpired, and still be about something else.
import { admissibleFor } from 'norms-mcp-engine/model';
admissibleFor(entry, { subject: 'android' });
Where the entry declares no scope
, the result is admissible: false
with
scope_known: false
and a note that applicability is the caller's judgement. The engine reports the match; it never decides.
Limit (v0.1): scope matching is exact-value only across subject
,
jurisdiction
and applicable_operations
. No hierarchies, wildcards or negation. An empty or malformed scope throws — it never becomes a match, because "unspecified" must not silently mean "applies to everything".
{
verification_state: 'RATIFIED',
ratification: {
date: '2026-07-31',
document: 'POLICY.md',
sha256: '<64 lowercase hex>',
section_id: 'sec-4-2', // stable id, resolved against
section_label: '§4.2 Retention' // human-readable, may drift
}
}
RATIFIED
without a complete block throws. This is the guard that stops "verified in conversation" from ever becoming a ratification.
The stored sha256
is the digest of the document the verification ran against. If the index moves and the ratification does not, the proof is stale — which is not the same as the constant being false.
import { revalidate } from 'norms-mcp-engine/model';
revalidate(entry, corpusIndex);
// { currency: 'STALE', reason: 'fingerprint diverged from index; proof requires revalidation' }
A document absent from the index yields UNKNOWN
, not STALE
.
Forcing an artificial decay condition onto a rule that ought to be permanent is its own error.
expiry_policy: CONDITIONAL | REVIEWED | PERMANENT
PERMANENT
never expires but throws unless it records permanence.authority
and permanence.reason
. Permanence is a decision, not an absence of one. The
check lives in validateEntry
, so it cannot be bypassed by never calling
evaluateExpiry
.
CONDITIONAL
without declared conditions yields UNKNOWN
, not ACTIVE
: the absence of conditions is not evidence that none fired.
for each document, in id-byte order:
len(id) || id || len(content) || content
Two properties, both of which have bitten real systems:
Lengths are UTF-8 byte counts. String.prototype.length
returns UTF-16 code units. One character outside the BMP makes two conforming implementations disagree. The prefix is 8-byte unsigned big-endian — fixed width and endianness are part of the specification.
Framing, not concatenation. Two different corpora can concatenate to the same byte stream. The suite demonstrates the collision and shows framing removing it:
ok COLLISION: plain concatenation is ambiguous
Per-document canonical form: UTF-8, no BOM, LF line endings, trailing whitespace stripped, Unicode NFC.
A claim map written as prose is a convention someone has to remember to apply. This makes it a test.
| Rule | |
|---|---|
| R1 | O requires every evidence field |
| R2 | O requires PUBLICLY_INSPECTABLE or INDEPENDENTLY_ATTESTED — a private digest is a commitment, not a verification |
| R3 | D requires derived_from and support_status |
| R4 | Support rule: a derived claim cannot carry stronger support than its weakest premise |
| R5 | A requires a named precondition |
| R6 | derived_from must reference claims that exist |
| R7 | No cycles in derived_from |
| R8 | No duplicate claim ids |
| R9 | support_status must be a recognised value |
| R10 | D with no premises must declare SPEC or NONE |
import { lintClaimMap } from 'norms-mcp-engine/claimmap';
lintClaimMap(claims);
// { ok: false, findings: [{ id: '2', rule: 'R4', message: '…' }], counts: {…} }
claims.mjs
is the canonical form. CLAIM_MAP.md
is generated from it by
scripts/build-claimmap.mjs
, and npm test
fails if the committed Markdown has drifted from the data or if the map does not pass R1–R10.
The inversion is deliberate. A map kept as prose is a convention someone has to remember to apply, and in this project's own history that convention failed three revisions running.
States: O
(observed, resolvable by a third party) · O-PENDING
(reported, not
yet resolvable — not admissible as evidence) · D
(derived) · A
(open, precondition named).
No MCP server. No transport, no tool handlers, no entrypoint.No persistence. Pure functions; the caller owns storage.NoapplyRevalidation
/applyExpiry
helpers.revalidate
andevaluateExpiry
return verdicts; the caller applies them.No JSON schema. Shapes are enforced byvalidateEntry
and the suite.No independent audit of the code. Seven review rounds; the last two read the source. None was a human reviewer.No independent verification. One author, one test run, no external replication.
See CLAIM_MAP.md
for the full evidentiary status of every claim made here.
Every public API validates its input and throws a ModelError
or CorpusError
with a stable code
. A malformed record is never interpreted as a verdict — neither negative nor positive — because malformed and ineligible are different facts, and a library about evidentiary status must not conflate them.
normaliseDocuments
is the single admission gate for a corpus: the digest and
the index apply identical rules, so they cannot disagree about which documents
exist. buildIndex
uses a null-prototype object and revalidate
looks up with
Object.hasOwn
, so an id such as __proto__
resolves to a real record or to nothing.
Apache License 2.0 — Copyright 2026 Francesco Riva. See LICENSE
for the full
text. Versions up to commit 83438ef
were published under MIT; this and later revisions are under Apache-2.0.
CLAIM_MAP.md
is under the same licence. A practical request, not a licence term: it is a record of what was claimed and when, so a modified copy should not circulate under the same name — its value lies in not having been altered after the fact.