This page is for the party doing the checking: a supervisor, an external auditor, a counterparty's risk function, someone contesting a determination made about them. It covers delegation credentials, refusal records, lapse records and decision attestations, which is what the published engine knows how to read — if you are holding a PolicyEvaluationCredential, see the note in section 02 before you start. You are not our customer and you should not have to become one. Everything below runs on your machine, against artifacts we had no part in putting in your hands.
No API key. No bearer token. No account. Point the verifier at a local copy of the issuer's DID document and it makes no network call at all — not to us, not to anyone. That is the mode to use when the artifact you are holding should not leave your machine.
$ npm install @observer-protocol/policy-engine $ curl -O https://observerprotocol.org/verify-samples/verifies-delegation-mandate.json import { verifyCredentialObject } from '@observer-protocol/policy-engine'; import fs from 'node:fs'; const credential = JSON.parse(fs.readFileSync('./verifies-delegation-mandate.json')); const config = { credentialPath: 'verifies-delegation-mandate.json', // Pin the issuer you expect. A verifier that trusts whoever the // credential names as its issuer is not verifying anything, // it is agreeing. A mismatch here is a denial. issuerDid: 'did:web:bitcoinsingularity.ai', schemaAllowlist: ['https://observerprotocol.org/schemas/delegation/v2.2.json'], revocation: { maxStalenessHours: 24, onUnreachable: 'cache-then-deny', fetchTimeoutMs: 5000 }, didCache: { maxStalenessHours: 24 }, cacheDir: '/tmp/op-cache', auditLog: '/tmp/op-cache/audit.log', rails: {}, allowContractCalls: false, // offline: { didDocumentPath: './did.json' } // no network at all }; console.log(await verifyCredentialObject(credential, config, Date.now()));
{
allow: true,
reason: 'credential verified',
notes: [ 'credential carries no credentialStatus entry —
revocation not checkable for this credential' ],
checks: {
validityWindow: 'passed',
issuerResolution: 'network',
issuerProof: 'eddsa-jcs-2022-verified',
signerBoundary: 'not-configured',
revocation: 'status-absent'
}
}
Read the checks, not the verdict. allow: true on its own tells you almost nothing. This run says the signature verified against a key published at the issuer's own domain, and it also says that no signer boundary was configured and that this credential carries no revocation entry, so neither of those was established. A verifier that returned only the green result would have hidden both. Both are recorded in the section below as limits of this artifact.
The fastest way to establish that a tool is real is to hand you the inputs it must reject. All three files below are served from this domain. The verdicts and reason strings are the engine's own output from a run on 8 August 2026, copied rather than described.
ObserverDelegationCredential, issued 4 August 2026, byte-identical to the copy under /credentials/.proof.proofValue. Every other byte is identical. If this verified, nothing else on this site would be worth reading.They are fixtures for the verifier and are not authorisations for anything. The agent named inside them is real; the documents are not valid credentials about it. The directory README states this too ↗
A delegation credential is one of five constructions this estate signs. /verify-samples/ also carries a decision attestation, four evaluation verdicts, two enforcement refusals and a payment instruction, taken verbatim from a run of a determination and payment stack against two real Federal Register instruments, over constructed loan cases. Each is published with the case that produced it, with its expected verdict recorded in the same CI check as the three above.
Four of the eight do not verify against the version this page tells you to install, and they stay published saying so. The records are sound. Each verdict was signed under op.evaluation.verdict.v4 and says so in its own construction.type; rc.12 rebuilds v3 and never reads that field, so the bytes differ by one string and the signature fails. The failure is recoverable by trial across a contiguous band: rebuild once per candidate version and exactly one verifies. That is a different fact from the two tampered fixtures above, which must never verify at any version, and the README keeps the two apart rather than leaving you to guess which kind of red you are looking at. A further one of the eight, the payment instruction, is checked by nothing at all at any published version, which is recorded as its own state rather than as a failure. The three groups, and what verifying any of them does not establish ↗
A determination is independently verifiable by a party who was not there. The record carries the document that was signed, as base64 of the bytes that were received, beside a documentHash. Sha256 over those stored bytes reproduces the hash, so you can rebuild the verifier's input from the record alone, and verifyDecisionAttestation is exported by the version the install line above gives you. /verify-samples/ppp-determination-refused-outcome.json is a real stored determination record and the run below is its output, not a description of it.
Read the state, not the absence of an error. attested means a key recovered from the decider's own did:key signed a canonicalisation of the whole document, that the decision identifier in the document is the one the payment cited, and that the outcome is a member of the vocabulary the document declares. It does not mean the policy, the vocabulary or the decider's artifact was fetched and compared: those references are confirmed present and resolved against nothing. inputsDigest is not even confirmed present, and the sample below carries none. Field by field, including what a reader cannot establish ↗
A PolicyEvaluationCredential is a different artifact and the next box is about it. Both record a determination; only one of them has a verifier. Conflating them is the error this page keeps separate on purpose.
Without installing anything: /check runs this same sequence in your browser. Paste the record, get the verdict, and nothing you paste leaves your browser. It covers the decision attestation and nothing else, so the rest of this page is still the route for a delegation credential or a refusal record.
$ npm install @observer-protocol/policy-engine $ curl -O https://observerprotocol.org/verify-samples/ppp-determination-refused-outcome.json import { verifyDecisionAttestation, ed25519Verify, base58Decode } from '@observer-protocol/policy-engine'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; const record = JSON.parse(fs.readFileSync('./ppp-determination-refused-outcome.json')); // The record stores the document as base64 of the bytes that were received. const bytes = Buffer.from(record.document, 'base64'); const hash = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); console.log('documentHash matches the stored bytes:', hash === record.documentHash); // did:key -> the multicodec-prefixed key bytes the engine expects. const didKeyBytes = (did) => base58Decode(did.slice('did:key:z'.length)); console.log(verifyDecisionAttestation( record.decisionId, JSON.parse(bytes.toString('utf8')), record.signature, (msg, sig, pk) => ed25519Verify(Buffer.from(pk), Buffer.from(msg, 'utf8'), Buffer.from(sig)), didKeyBytes, ));
documentHash matches the stored bytes: true
{
state: 'attested',
decisionId: 'urn:ppp-cases:decision:MF-0001:APR',
decider: 'did:key:z6MkoMCP6imN1HfKC1gVHDMffgP3af58VnGY621KRHrEsbDq',
outcome: 'determination-refused-schema',
...
inputsDigest: undefined
}
A PolicyEvaluationCredential — the signed artifact recording a determination, that this action was checked and the decision was X — is not the same artifact as a refusal record, and it is the one you cannot verify with anything we publish. Every one ever issued fails the same way. Two of them are served under /credentials/ and you will hit this immediately if you run the check above across that directory.
The signatures are sound. They verify under canonical W3C Data Integrity tooling — @digitalbazaar/data-integrity with the eddsa-jcs-2022 cryptosuite — against did:web:observerprotocol.org#key-3, with no Observer endpoint in the path. The documents are intact and the key is the right one. If you want to check an evaluation credential today, that is the route, and it is not ours.
The gap is ours, and it is wider than a missing field. The published engine has no dispatch on credential type at all: the string PolicyEvaluationCredential does not appear in it. Its one structural validator assumes a delegation credential and checks delegation fields in sequence, so an evaluation credential fails at whichever delegation assumption it reaches first. That is reported as a missing credentialSchema, which is misleading — supplying one moves the failure to credentialSubject.id must be a DID, because an evaluation credential's subject has no identifier. It has a decision. The hosted service is built the same way: its endpoint accepts {agentDid, mandate} and has no other shape.
And there is no schema to point at. No PolicyEvaluationCredential schema is published at any URL. AIP v0.8 defines the credential type and requires schema validation when verifying it, without ever assigning it a schema. That is a contradiction in the specification rather than a bug in a deployment, and closing it means publishing a schema at a permanent URL and adding type dispatch to the engine. Both are protocol decisions and neither is a copy change, so neither was done in the pass that wrote this page.
The two artifacts stay published. Removing them would have made this page look clean while leaving the reader to discover the same thing on their own, which is precisely what this page exists to argue against. Everything under /credentials/ and /verify-samples/ is now checked in CI against a recorded expected verdict, in both directions, so the day this is fixed the build will say so.
An approval resolution is the record of a human decision, signed at both ends: the routing and the outcome. Rebuilding its signed bytes needs resolutionPayload. The version npm install serves does not export it.
Read out of every published tarball rather than out of a release note: resolutionPayload was exported at 1.0.0-rc.8, withdrawn across 4 consecutive releases, 1.0.0-rc.9 through 1.0.0-rc.12, and restored at 1.0.0-rc.13. npm's latest tag sits inside that band, at 1.0.0-rc.12, which is the version this page documents and the version the install line above gives you.
So a reader following this page's own instructions cannot check a resolution, and until now the page did not say so. That is worse than a missing feature: the instruction was complete, it ran, and it produced a package silently missing one constructor. We hold 122 signed resolution records that a reader on 1.0.0-rc.12 has no route to.
The withdrawal appears in no changelog. The package's own release notes record neither the removal nor the restoration, so nothing but the tarballs could have told you. That is why the figures in this paragraph are read from a measurement in results/ and compared against npm on every build, instead of being typed here where nothing could contradict them.
What to do about it. Installing @rc gets you a version that exports it, and that is a different package from the one this site documents, so we are not going to tell you it is the same thing. This site stays pinned to what latest serves, because a page that documents a version nobody receives is the failure this whole surface is written against. The gap closes when latest moves past the band.
Every field has a limit. Where a field establishes less than its name suggests, that is said here rather than left for you to discover. Field names and derivations are taken from the published schema at https://observerprotocol.org/schemas/delegation/v2.4.json and from the engine source, not from our marketing copy.
Until now the line above named /schemas/delegation/v2.2.json. That version defines 0 of the 9 fields documented below. It is a reserved placeholder of 576 bytes whose entire body is {"type": "object"} plus a comment, sitting between v2.1 and v2.3. The values on this page were right, taken from the artifact and the engine; the stated source was wrong, and nothing here could have told you, because the sentence and the schema were never compared. They are compared on every build now.
All 7 other published versions define all 9. The citation moves to v2.4 because that is the only published version which both defines every field below and accepts the credential this page walks you through: validated with ajv, the artifact fails v2, v2.1 and v2.3 on proof.type, and fails v2.5, v2.6 and v2.7 on a required credentialStatus it does not carry.
Pinning that placeholder establishes nothing about structure, for two independent reasons, and fixing only the first would not help. The schema imposes no constraints, so validating against it succeeds vacuously: run the flagship credential through it with ajv and it passes, and it would pass with every field below deleted. Separately, nothing dereferences the URL at all. schemaAllowlist is an identifier comparison, allowlist.includes(credentialSchema.id), so the document at that address is never fetched and never applied. Structure is checked by hand-written rules inside the engine. A stricter schema at that URL would change nothing about what the verifier does.
The code samples still allowlist v2.2, deliberately, and here is why. 5 of the 8 credentials served from this domain carry credentialSchema.id pointing at v2.2, including the one both samples run against. Since the allowlist is an identifier comparison, a sample allowlisting anything else would refuse the artifact it is shown verifying. So the samples are correct as instructions and the pin is still meaningless as evidence, and that gap closes by re-issuing the artifacts, which is a signing operation rather than an edit and has not happened. The samples were not changed in this pass and this paragraph exists so that the reason is on the page rather than in a commit message.
did:web resolves to a document at that domain's /.well-known/did.json carrying the public keys.proof.verificationMethod appears in its assertionMethod. The engine does this and reports issuerResolution.did:web binds to domain control, not to a legal entity. It tells you the same party that controls that hostname signed this. It does not tell you who that party is, and if the domain changes hands the historical signature does not change with it.identity.valid with the note that the mandate subject binds to the DID.credentialStatus below.America/New_York, Monday to Friday.false here.revocation: 'status-absent', and the hosted service returns the note "credential carries no credentialStatus entry — revocation not checkable for this credential".onUnreachable: 'cache-then-deny': if the list cannot be reached, a cached answer is used and then the credential is denied. Nothing is let through on a fetch failure.allow: true here as "validly issued and unexpired", not as "currently in force". A status list hosted on an origin other than the pinned issuer's is also refused until you allowlist it — and Observer Protocol's own clause-zero revocation demonstration is exactly such a pair, so it does not verify out of the box.verificationMethod.issuerProof: 'eddsa-jcs-2022-verified'. Canonicalisation matters: two documents that differ only in key order produce the same signature input, and two that differ in any value do not.There is also a hosted verifier. It is open — no token, no account, rate-limited at 60 requests per caller per minute — and it returns a signed result. Use it if you want a second opinion or a quick look. Do not use it for an artifact that should not leave your machine, and do not mistake it for the claim this site makes.
$ curl -X POST https://verify.observerprotocol.org/v1/verify \ -H 'Content-Type: application/json' \ -d '{"agentDid": "<subject DID>", "mandate": <the credential>}' { "identity": { "valid": true, ... }, "mandate": { "valid": true, "notes": ["credential carries no credentialStatus entry — revocation not checkable for this credential"] }, "proof": { "cryptosuite": "eddsa-jcs-2022", "verificationMethod": "did:web:observerprotocol.org#key-7" } }
This section used to argue that the hosted service was a different build. It reported engine.running: "0.3.3" and concluded that any agreement between the two was "agreement observed on samples, not a shared code path". That argument is false and has been since 9 August 2026. Convergence landed that day, in commit d3278efb3cb2, built 2026-08-09, and nothing here recorded it. The page went on making an argument its own subject had stopped supporting.
Read from the service's own /version today: engine.running 1.0.0-rc.10, engine.builtAgainst 1.0.0-rc.10, agree: true. It is the published package. The published package is 1.0.0-rc.12, so the honest description is the same engine, two releases behind, and not a separate implementation. What remains true is that a version gap is a real gap: rc.10 and rc.12 are not the same code, and a difference between them is a difference you can meet.
The old "agree on 7 of 8" figure is withdrawn, and the denominator was the defect rather than the agreement. Three of those eight artifacts never reach the hosted engine at all, so a ratio over eight was never reproducible by a reader. One is the testbed-issuer demonstration credential, and that deployment refuses it at its issuer allowlist, so the refusal is evidence about the allowlist and not about the engine. The other two are the PolicyEvaluationCredential artifacts, for the reason in the next paragraph. A figure whose denominator counts artifacts that cannot be submitted is not a measurement of agreement; it is a measurement of how many things we tried.
A PolicyEvaluationCredential cannot be submitted here at all. Post one as the request body and the service answers HTTP 400, body must carry agentDid (string) and mandate (object). There is no agentDid to supply: a PEC's credentialSubject carries decision, evaluator, proposal and evaluatedAt, and no id. The endpoint's shape assumes a delegation credential granting authority to a subject, and an evaluation credential has no subject in that sense. This is the same root cause as the gap in section 02, met at the transport rather than in the validator: both the package and the service are built around one credential shape, and the artifact that records a determination does not have it. Wrapping a PEC in an invented agentDid does get a response, and what comes back is a structural refusal about credentialSchema, which is the offline failure repeated rather than a second opinion about it.
The service is also candid about a second limit, in its own /version response: the schema and issuer allowlists it enforces come from environment variables on the host, inRepo: false, so a change to what it accepts "leaves no reviewable record". The offline path has no such property, because the allowlist is the one you wrote.
Two further limits, and they are worse than the two above.
A scope answer that does not vary with its input. MEASURED AGAINST THE OLD BUILD, NOT RE-MEASURED SINCE CONVERGENCE. Attach a proposal and scope.inScope returned false for every USDC amount, a compliant one, an absurd one, any of them. The cause was that engine 0.3.3 could not scale USDC minor units, so the comparison it was asked to make could not be made and it failed closed. Measured on 9 August 2026, at two amounts three orders of magnitude apart, both returning the same inScope: false, with the service naming the cause in the answer: currency "USDC" has no known minor-unit exponent — amount cannot be scaled (fail closed). Failing closed was the correct behaviour and the outcome was still useless: a check that returns the same answer for every input is not telling you about your input. Read a false here as “not evaluated”, never as “out of scope”. The build under that measurement is no longer the one deployed, and this limit has not been re-measured against 1.0.0-rc.10. It is left standing rather than deleted or quietly updated, because "we changed the engine" is not evidence that a behaviour changed with it. Treat it as open until somebody re-runs it. The offline package does not share this limit.
A refusal that does not say why — CLOSED, re-measured 9 August 2026. This said that when mandate.valid was false, notes came back [] and a caller could not tell whether the credential failed on its schema, its signature, its validity window or its issuer. That was true when written. It is not true now: the service returns a specific reason. Measured against the two artifacts published here because they must fail, must-not-verify-tampered-signature returns [proof] eddsa-jcs-2022 signature does not verify against the issuer key, and must-not-verify-expired-mandate returns validity: credential expired (validUntil 2026-02-01T00:00:00Z). notes is still []; reason is the field to read. The retired limit is left here rather than deleted, because a disclosure that vanishes reads as one that was never true.
The offline path is the claim. The hosted path is a convenience. If they ever disagree, the offline result is the one to act on, and we would like to hear about it.
Using this tooling implies nothing about Observer Protocol, Agentic Terminal, or any firm whose records you are examining. There is no account to create, no form on this page, and nothing here that asks you for a way to contact you. If you are checking a record because someone is asking you to accept it, the correct outcome of this page is that you can answer that question yourself and then leave.
The verifier is MIT-licensed and self-hostable. A verifier you run from your own infrastructure is worth considerably more than one you load from us, and we would rather you did that. The source, the known limits, and the schemas are all public.
A browser-based verifier that runs entirely client-side is not available yet, and we are not going to ship one that sends your artifact to a server while calling it client-side. The published package depends on Node built-ins — node:crypto, node:fs, node:dns among them — and the DNS use is a live guard against a hostile URL rather than incidental. Porting it means replacing that guard honestly, not stubbing it. Until then, the two paths above are what exist.