What a signed evidence package contains and what each verification check proves; what a substrate identity is; and every rule the per-domain policy can express. The API walkthrough §§10–15 shows the calls; this is the contract behind them.
A deployment's behaviour is fixed by four things and nothing else:
| component | what is hashed |
|---|---|
| engine | release tag, git commit, sha256 of the exact earl-verify binary placed in the image |
| grounded KB | the package fingerprint (sha256 over its files) |
| checkpoint | sha256 over every file in the trained checkpoint, by sorted relative path |
| policy | sha256 over the canonical JSON of the active policy version, or null |
| inputs (working checkpoint only) | sha256 of the vocabulary module, of the canonical curriculum JSON, and of each corpus document |
The substrate identity is the sha256 of the canonical JSON of that record
(earl-substrate-identity/1, sorted keys, no whitespace, UTF-8). It is
computed once, at freeze, and then carried everywhere unchanged:
identity.json and manifest.json → substrate inside the frozen packageGET /identity on the container, and substrate_identity in every
/verify and /health replysubstrate_identity on the frozen package and on every deployment built
from itsubject.substrate_identity and subject.identity in every evidence packageEqual identities answer identically. The platform's deployment proxy compares
the container's stated identity with its own record on every call and sets
substrate_identity_mismatch: true when they differ.
Format earl-evidence-package/1. One per adjudication, whether run on the
working checkpoint (POST /domains/{id}/adjudication-runs) or through a
deployment (POST /deployments/{id}/verify). Returned inline; stored; never
updated.
{
"format": "earl-evidence-package/1",
"package_id": "8b5c…",
"issued_at": "2026-09-22T21:04:11.318204+00:00",
"issuer": {
"platform": "EARL Platform Backend",
"key_id": "<sha256 hex of the raw 32-byte public key>",
"public_key": "<64 hex chars — raw Ed25519 public key>"
},
"subject": {
"source": "deployment",
"domain_id": 7, "domain_name": "payments routing",
"deployment_id": 3, "deployment_name": "payments-live",
"frozen_domain_package_id": 2, "frozen_domain_codename": "payments-v1",
"substrate_identity": "<sha256>",
"identity": { "...the full identity record..." },
"engine_release_tag": "v0.65.0",
"policy_hash": "<sha256 or null>", "policy_version": 2
},
"request": {
"mode": "verify",
"candidate_text": "Atlas settles eur currency\nNorthstar supports the amex scheme",
"candidate_text_sha256": "<sha256>",
"claims": ["Atlas settles eur currency", "Northstar supports the amex scheme"]
},
"result": { "...the complete adjudication reply, every claim field kept..." },
"signature": {
"algorithm": "Ed25519",
"payload_sha256": "<sha256 of the canonical bytes>",
"value": "<128 hex chars — 64-byte signature>"
}
}
Canonical bytes. The signature is over the package without its
signature member, serialised as json.dumps(obj, sort_keys=True,
separators=(",", ":"), ensure_ascii=False).encode("utf-8").
payload_sha256 is the sha256 of exactly those bytes, so a reader can tell
"bytes changed" from "wrong key".
Encodings follow the containment kit's evidence packages: keys and
signatures as hex; key_id = sha256 over the raw public key, the same
fingerprint convention as its --pin-author.
Keys. The platform holds one Ed25519 private key, generated on first use
under its storage volume (EARL_EVIDENCE_SIGNING_KEY_PATH to place it
elsewhere). It never leaves the backend. The public key is served at
GET /api/evidence/public-key (with its key_id and a PEM form). Rotating
the key replaces the file; packages signed by the old key still verify
against the public key they carry, and their key_id says which key that was.
Offline, with the file alone:
python verify_evidence_package.py evidence-<id>.json --pin-key <key_id>
The script is served at GET /api/evidence/verifier (Python ≥ 3.8; uses the
cryptography package when present, otherwise a pure-Python RFC 8032
verifier embedded in the script). Exit status 0 when every check passes,
1 otherwise. --json prints the verdict as JSON.
| check | proves |
|---|---|
format |
it is an earl-evidence-package/1 |
public-key |
the embedded key is 32 raw bytes |
key-id |
key_id equals sha256 of that key (the package is internally consistent about its signer) |
issuer-pinned |
key_id equals the fingerprint you obtained out of band — this is the check that proves the package came from this platform |
payload-hash |
the bytes are the bytes that were signed |
signature |
the Ed25519 signature verifies over them |
candidate-text-hash |
the claims in request are the claims that were adjudicated |
identity-record |
subject.identity hashes to subject.substrate_identity |
Without --pin-key a package from any key that signs consistently is
"valid"; pinning is what makes it ours. Get the fingerprint from
GET /api/evidence/public-key once and keep it with your trust anchors.
POST /api/evidence-packages/verify runs the same checks on the platform,
pinned to its current key, and returns {"valid": ..., "checks": [...]}.
A package may carry what each claim should have received
(request.expectations: claim, expected_verdict, expected_disposition)
and the expected-versus-actual summary (evaluation.expectations: matched and
mismatched counts, the mismatches, expectations that matched no claim line,
all_matched). Claims are matched to expectations by text, case- and
whitespace-insensitive, ignoring trailing punctuation.
POST /evidence-packages/{id}/replay re-adjudicates the package's claims on
the same source and records a new package with replay_of = the original's
id and evaluation.replay:
{"claims_original": 10, "claims_replayed": 10, "claims_compared": 10,
"identical": true, "differences": [],
"original_policy_hash": "...", "replay_policy_hash": "..."}
A difference names the claim and the field (verdict, disposition,
endpoints, endpoints_asserted, demotion_reason) with both values. The
replay package is signed after the comparison is added, so the comparison is
covered by the signature.
source:
"working_checkpoint") carries an identity with scope:
"working_checkpoint": the host engine binary that ran, the grounded KB,
every checkpoint file, the policy, and under inputs the hashes of the
authored material behind that checkpoint — the vocabulary module, the
curriculum, and every corpus document of the training run. It names the
exact bytes and the exact inputs, but a working checkpoint can be retrained
at any time, so the identity is a record, not a promise; a frozen package's
identity is the one a deployment holds still.Format earl-domain-policy/1. Evaluated after the engine verdict, on the
working checkpoint during test runs and inside the container for a frozen
package (the same evaluator file, app/policy.py, byte for byte; the
container refuses to start if policy.json disagrees with the hash in its
manifest).
{
"format": "earl-domain-policy/1",
"name": "payments routing v1",
"description": "optional",
"dispositions": {"VERIFIED": "ALLOW", "CONTESTED": "DENY",
"UNCERTAIN": "REVIEW", "HALLUCINATED": "DENY"},
"rules": [ ... ]
}
Disposition per claim: ALLOW, REVIEW or DENY. Start from
dispositions[verdict] (the defaults shown above apply for any verdict not
listed), then every matching rule raises it by its action. Severity is
DENY > REVIEW > ALLOW; a rule can never lower a disposition, so nothing
can turn a contested claim into an allowed one. Every matching rule is
recorded on the claim (policy.matched_rules, with its reason), and the
run carries a policy.summary count per disposition and the policy_hash.
Every rule has id (unique, non-empty), type, action (review or
deny) and an optional reason (shown to readers). Unknown keys are refused.
| type | fields | matches when |
|---|---|---|
require_asserted_pair |
— | verdict is VERIFIED and endpoints_asserted is not true (the verdict landed on an easier pair than the claim asserts) |
min_grounding |
threshold 0–1, verdicts (default ["VERIFIED"]) |
verdict is in verdicts and grounding is below threshold — or missing, which never satisfies a floor |
closed_world |
entity, permitted (list, may be empty) |
entity is one of the verdict's endpoints and any other endpoint is not in permitted. Case-insensitive. Acts on the pair the verdict is about, never on words in the sentence, and never on a negated claim (any negated_claim_* demotion reason): denying a pair outside the list agrees with the list |
max_contradictions |
max ≥ 0 |
contradictions exceeds max |
demotion_reasons |
reasons (non-empty list) |
demotion_reason is one of reasons |
Verdict aliases are normalised before evaluation (grounded → VERIFIED,
conflicted → CONTESTED, case-insensitive).
Corpus: "Northstar supports the Visa and Mastercard schemes only." Claim:
"Northstar supports the amex scheme." The engine, honestly, returns
UNCERTAIN with endpoints: ["northstar", "amex"] and
demotion_reason: relation_unasserted — nothing in the corpus denies Amex,
nothing asserts it. With
{"id": "northstar-schemes", "type": "closed_world", "entity": "northstar",
"permitted": ["visa", "mastercard"], "action": "deny",
"reason": "Northstar supports Visa and Mastercard only"}
the disposition is DENY, and the claim row says why:
"policy": {"disposition": "DENY", "engine_verdict": "UNCERTAIN",
"default_disposition": "REVIEW",
"matched_rules": [{"id": "northstar-schemes", "type": "closed_world",
"action": "DENY",
"reason": "Northstar supports Visa and Mastercard only"}]}
"Northstar supports the visa scheme" (VERIFIED, pair northstar · visa)
does not match the rule and is ALLOW. "Unlike Northstar, Meridian supports
the amex scheme" verifies on meridian · amex; northstar is not in the
verdict's endpoints, so the rule does not fire.
PUT /domains/{id}/policy validates and appends a version; identical content
returns the current version unchanged. GET …/policy is the newest;
GET …/policy/versions the history. A frozen package records the version and
hash it captured (manifest.json → policy), and policy_hash is part of its
substrate identity, so changing the policy and re-freezing yields a new
identity, while the old deployment keeps deciding exactly as it did.
decideevaluate says what the evidence supports. decide also says whether a given action may proceed, and who
may take it, as one recorded decision.
d = wf.decide("Treasury funds HHS.", "release_payment", actor="treasury_approver")
d.result["decision"] # ALLOW / REVIEW / DENY
d.result["evidence"] # the evidence decision and each claim's disposition
d.result["authority"] # the authority decision and the rule that gave it
d.result["reasons"] # every contribution that made the decision less than ALLOW
The authority rules live in the domain policy, in an optional authority section:
"authority": {
"default": "review",
"actions": [
{"id": "release", "action": "release_payment", "actors": ["treasury_approver"],
"min_allowed_claims": 1, "otherwise": "review",
"reason": "a payment is released by a treasury approver on verified evidence"}
]
}
How the decision is made:
- Evidence decision: the most severe disposition the policy gives any claim (section 3). With no claims, REVIEW.
- Authority decision: from the rule for the action:
- DENY when the rule lists actors and actor is not one of them;
- the rule's otherwise (review or deny) when fewer than min_allowed_claims claims are allowed;
- otherwise ALLOW.
- An action with no rule gets the section's default (review when absent, and always without an authority section).
- The decision: the more severe of the two. Authority can only add severity, so an authorised actor cannot clear a contested claim.
The record:
- One signed record (capability: decision.decide), bound to the evidence package of the claims, like evaluate.
- It carries the policy version and hash, the action, the asserted actor and the authenticated caller.
- outcome_class is result only for ALLOW.
A trained domain holds facts. It cannot hold a computation, such as whether every waypoint of a route lies
inside an area, or whether a value meets a threshold. No corpus states such a thing for each request, and nothing can
learn it. A policy therefore declares computed facts: a function from the platform's sealed library, applied to
the domain's own data and to one field of the request's facts_input.
"data": {"OPS_AREA": [[31.5, 34.58], [31.5, 34.92], [31.75, 34.92], [31.75, 34.58]],
"NO_FLY_WEST": [[31.5, 34.5], [31.5, 34.58], [31.75, 34.58], [31.75, 34.5]]},
"computed_facts": [
{"id": "inside-ops", "function": "geo.all_points_inside", "input": "waypoints", "data": ["OPS_AREA"],
"claim": "the route lies inside the operations area", "actions": ["mission.reposition"]},
{"id": "outside-no-fly", "function": "geo.no_point_inside", "input": "waypoints", "data": ["NO_FLY_WEST"],
"claim": "the route avoids every no-fly area", "actions": ["mission.reposition"]}
]
d = wf.decide("Merlin is online.
Merlin is available.", "mission.reposition", facts_input={"waypoints": [[31.6, 34.7]]})
d.result["computed_facts"] # one claim per fact: verdict, and its provenance
The library (version 1.0.0; every result names it):
| function | reads | holds when |
|---|---|---|
geo.all_points_inside |
points, one polygon | every point is inside the polygon (a point on an edge counts as inside) |
geo.no_point_inside |
points, one or more polygons | no point is inside any of them |
num.at_least / num.at_most |
a number, a threshold | the comparison holds |
set.member |
a value, a list | the value is in the list |
How a computed fact decides:
- Verdicts. Each one applying to the action becomes a claim result of class computed:
- VERIFIED when the function holds;
- CONTESTED when it does not;
- UNCERTAIN, with demotion_reason: computed_input_missing, when its input or data is missing or malformed.
Write a fail-closed demotion_reasons rule if a missing input must deny.
- Same treatment as learned claims. Dispositions, rules and min_allowed_claims apply to it exactly as to a
learned claim, so authority can only add severity.
- Data, never code. The developer supplies data: polygons, thresholds, lists. The platform supplies the
functions. The text of the request is never read for these facts.
Provenance and recomputation:
- On every result: the function, the library version, the sha256 of the data it read and of the input, and the
output.
- On the record: the decide record carries the full facts_input and its sha256, beside the policy version and
hash.
- Offline: recompute with the evaluator itself, GET /api/evidence/policy-evaluator (no authentication), the
same file every decision uses:
from domain_policy import recompute_computed_facts
assert recompute_computed_facts(policy_document, action, record["request"]["facts_input"]) == record["result"]["computed_facts"]
Not yet:
- Frozen packages embed the same evaluator, but their /verify endpoint takes no facts_input, so computed facts
are evaluated by decide on the platform.
- The evidence package covers the learned claims. The computed facts are on the signed decide record.
Limits:
- Advisory. The call enforces nothing. Enforcement exists only where the deployed adjudicator or a gateway is the sole path to the action.
- actor is asserted, not verified. It is what your system says about who is acting; the platform records it beside the caller it authenticated.
- Inherited evidence limits. The evidence verdicts are evidence.evaluate's, so its limits apply: not qualified for unattended approval decisions.
- One rule set everywhere. The decision function is domain_policy.decide in the same file that frozen domain packages embed (app/policy.py), so a package decides with exactly the rules the platform tested.