← All guides

Evidence packages and policy — the reference

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.

1. Substrate identity

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:

Equal 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.

2. The evidence package

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.

2.1 Verifying a package

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": [...]}.

2.2 Expectations and replay

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.

2.3 What a package does not claim

3. Policy

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.

3.1 Rules

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).

3.2 Worked example: the closed-world case

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.

3.3 Versions

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.

4. One decision from evidence and authority: decide

evaluate 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.

4.1 Computed facts

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.