Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

APH — Agent per Human Notarization Protocol

The APH emblem — a human figure and an AI chip joined in an infinity loop inside a chained seal — surrounded by scenes of people from many cultures and walks of life working alongside robots and AI assistants: an elder signing a document with a robot, a family with tablets, clinicians, office workers at laptops, and a delivery drone, all linked by glowing network lines carrying padlock and document icons.

Your agent just sent an email, posted to Slack, or hit an API on someone's behalf. The receiving side has exactly one question: did a human actually authorize this? APH answers it with cryptography instead of vibes — every agent action ships with a signed, revocable, replay-proof credential that any stranger can verify with nothing but public standards: no account with you, no SDK of yours, no phone call.

If you build agents that act for people, this is how the other side learns to trust yours. If you receive agent traffic, this is how you tell an authorized act from a freelancing model — before it lands. One cargo add aph-core, go get, pip, mix, or a zero-dependency TypeScript implementation away.

APH is an open protocol for cryptographically notarizing the actions an autonomous agent takes on behalf of a specific human, producing a W3C Verifiable Credential 2.0-shaped envelope that any downstream recipient can independently verify across vendors and across organizations.

Watch the explainer video — the driver's-license model and the verification story in plain language, before the spec makes them precise.

Mental model — the agent's driver's license

Think of an APH credential as an agent's driver's license:

  • A human (the issuing authority) authorizes a specific agent to act on their behalf within bounded parameters — and signs that authorization with their own key.
  • A notary service (the DMV) witnesses the decision, records when policy was evaluated, countersigns, and publishes its verification key so anyone can independently check the credential against the public record. Because it never holds the human's key, a notary cannot forge an authorization — which is why anyone may host one.
  • The license carries a scope — which channels, which content classes, which recipients, how often, for how long.
  • The license is revocable — the issuing human can pull it at any time.
  • The license is portable across jurisdictions — like an interstate driver's license, an APH credential issued by one organization's notary is verifiable by any other organization's agent or system using only public standards. No bilateral integration required.

When an agent presents a notarized message, the recipient can verify, without trusting the sending agent's runtime or its identity provider, that:

  1. A specific human authorized this specific action — proved either by the human's own signature on the envelope, or by their signature on the Delegation Mandate that authorized it, which travels embedded so the check stays offline. policy.attestationMode says which, so a recipient never has to guess.
  2. The action falls within the scope of the human's standing delegation.
  3. The notary that signed the license holds the private key it claims to hold (verifiable via DNS-anchored public key publication — see spec §8.4).
  4. The license has not expired and has not been revoked (checkable against a status list the notary publishes at an endpoint derived from its own did:web — see spec §6.3.3).

Where APH fits next to A2A and AP2

APH is a complement to Google's open agent protocols, not a replacement:

  • A2A (Agent2Agent) — standardizes how two agents discover each other and exchange messages. APH attaches to A2A messages as a Verifiable Credential extension so the receiving agent can verify the sending agent actually has its human's permission for this specific action.
  • AP2 (Agentic Payments) — standardizes how an agent obtains a human-signed mandate to make a payment. APH covers the broader case: any human-authorized action an agent takes, including but not limited to payment. AP2 and APH cross-link via the envelope's linkedMandate field so a single agent action can carry both a payment mandate AND a communication authorization.
  • APH (Agent per Human) — the missing piece. Where A2A defines the transport and AP2 defines payment authorization, APH defines per-action human authorization — an agent's verifiable credential to act on a specific human's behalf for a specific task on a specific channel.

In one sentence: A2A is the road network, AP2 is the toll booth, APH is the driver's license.

Concrete example — two agents negotiating a meeting

Alice's agent and Bob's agent are negotiating a meeting time over a public channel. Both agents act with autonomy within bounded parameters their humans set in advance. Each outbound message carries an APH envelope:

  • Alice's agent emits an A2A message proposing 3 pm Tuesday, carrying its APH envelope as extension metadata under the aph://extensions/notarization/v1 key (APH is transport-independent — the agent-to-agent rail is never the envelope's channel). The envelope is notarized by Squillo's notary on Alice's behalf, with channel = email (the medium the confirmed invite will land on), contentClass = Reply, policy.matchedScope = per-channel, and a DelegationMandate reference showing Alice pre-authorized her agent to schedule meetings for the next 30 days.
  • Bob's agent verifies the APH envelope by resolving Squillo's notary public key (via did:web.well-known/did.json or via the _aph._notary.squillo.com DNS TXT record — both anchored in public infrastructure Bob doesn't need a Squillo account to read), then checks the signature, the time window, the scope, and the body hash.
  • Bob's agent replies with a counter-proposal under its own APH envelope, notarized by Bob's organization's notary, which Alice's agent verifies the same way.
  • Neither human is in the loop for the negotiation itself, but every action either agent takes is provably bound to a license its human issued ahead of time and can revoke at any time.

If Alice decides she no longer wants her agent scheduling on her behalf, she revokes the DelegationMandate: her notary stops issuing new CommunicationMandates against it immediately, and it sets the mandate's bit in the status list it publishes, so envelopes already referencing it fail verification on Bob's side too. Bob's agent resolves that status endpoint from Squillo's own did:web rather than from anything Alice's agent sent — an old envelope does not get to name a friendlier host to answer for it (spec §6.3.3). Short validity windows remain good practice as defense in depth: they bound the damage if Bob's agent cannot reach the status surface at all.

What problem APH solves

  • Agents can already act on behalf of humans, but recipients have no portable way to tell if a human actually authorized this specific outbound message.
  • Existing protocols cover adjacent slices: A2A handles agent-to-agent transport, AP2 handles agent-initiated payment authorization, MCP handles tool-call typing — none binds a particular outbound message to a verifiable human keypair held on the human's device.
  • APH closes that gap with a notarization step that runs locally on the sending side and produces a portable, verifiable credential the recipient can check without trusting the sending agent's runtime or its identity provider.

Why "notarization"

A Notary Service is meaningful only if a third party can independently verify its signatures. APH therefore models notaries as a public/private keypair where the PUBLIC key is publishable like a DKIM or TLS key — anchored in DNS or HTTPS — so any verifier on the open internet can resolve it and check the signature with no prior trust relationship to the notary operator. See spec §8.4 for the three publication mechanisms (did:key offline, did:web.well-known/did.json, and DNS TXT at _aph._notary.<domain>).

Status

v0.1.0 (final, cut 2026-08-29) — the 0.1 line is frozen; normative changes are versioned from here (the land-in-place exception ended with the cut). v0.2 (final, cut 2026-08-29) — published as an additive delta (spec/aph-0.2.md): sealed payloads (RFC 0008), rotation attestation (RFC 0001), the JSON Schema family, signed vectors; a v0.1-only verifier refuses every new member at strict parse, which is the compatibility story working. The specification text, the canonical envelope shape, and a small set of reference example envelopes are published here for community review. A reference Rust implementation lives in this repository under interpreters/rust/ (wire types, flow state machines, signing helpers, and a conformance suite that validates the examples/ envelopes). A second implementation — sharing no code with it, written from the specification and the published examples — lives under interpreters/typescript/, and the two cross-verify each other's minted envelopes in both directions. It is independence of code and not of team: the same authors wrote both, so an outside implementation is still the thing that would test whether this document survives a stranger.

Machine-readable artifacts exist for part of the surface, not all of it: spec/schemas/ carries JSON Schemas for the two revocation shapes of §6.3.3 (there is none for the envelope itself — §7.1 plus the strict parser is the normative shape), and exactly four published envelopes carry real signatures rather than placeholders — one for each signing path §8.1/§8.2 make MUST-support and this implementation supports, plus the one the TypeScript implementation minted. Implementing APH in another language states precisely what is and is not covered, because an implementer who over-trusts the vectors ships a verifier that passes them and fails a stranger.

Two conventions to know before you adopt: aph:// (the extension-URI scheme) and _aph._notary.<domain> (the DNS key-publication name) are conventions, not IANA registrations — both requests are now drafted in spec/registrations/ and not submitted, so nothing below has changed (spec §13). Submitting is a human act and deliberately not automated, which is why every place an identity would go is left blank: the scheme request's Contact: and Change controller: fields, and the requester IANA would correspond with about the DNS request — whose registry entry defines no contact field at all, though the submission still needs a person behind it. The DNS request additionally surfaces an open naming question for the specification owner to settle before it goes anywhere. Neither affects whether an envelope verifies, and a conformant TXT parser refuses any record whose v tag is not APHv1, so a foreign record at a colliding name is ignored rather than misread as a key. What is genuinely at risk is name ownership: if those names are later assigned elsewhere, APH moves. spec/operations.md §6 enumerates every unregistered identifier with the consequence of each.

Relationship to other protocols

APH builds on the W3C Verifiable Credentials Data Model 2.0, JWS detached signatures (RFC 7515), JSON Canonicalization Scheme (RFC 8785), SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16), and OAuth 2.0 Token Exchange (RFC 8693). It composes with — but does NOT replace — A2A (agent discovery and transport), AP2 (payment mandates), and MCP (tool-call typing). Where applicable, an APH envelope MAY ride alongside an AP2 IntentMandate via the envelope's linkedMandate field so that send-consent and payment-authorization are linkable but separately signed.

Quick reference — the wire shape

A full schema lives under spec/aph-0.1.md. The example below is a complete v0.1 envelope notarizing a Slack reply. The same envelope shape applies across all supported channels (Email, Slack, Discord, Teams, WhatsApp, Google Chat, iMessage, and service — a service endpoint an agent delivers a state-changing act to, per RFC 0002) with only the credentialSubject.channel block changing per channel.

{
"aphVersion": "0.1",
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://w3id.org/aph/v1"
],
"type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
"id": "urn:uuid:00000000-0000-4000-8000-0000000000f0",
"issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"validFrom": "2026-05-21T00:00:00Z",
"validUntil": "2026-05-22T00:00:00Z",
"credentialSubject": {
"humanPrincipal": {
"id": "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy",
"displayName": "Scott Wyatt"
},
"agent": {
"id": "did:web:agent.squillo.com",
"agentCardUri": "https://agent.squillo.com/.well-known/agent-card.json",
"displayName": "Squillo Concierge",
"version": "1.0"
},
"channel": {
"kind": "slack",
"recipientAddressing": {
"teamId": "T01234567",
"channelId": "C01234567",
"parentTs": "1716249600.000100"
}
},
"communication": {
"contentClass": "Reply",
"bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bodySize": 1842,
"previewLines": 3,
"preview": "Hey team — quick update on the deploy:\n• prod rollout finished at 14:02 UTC\n• no error spikes in the first hour"
},
"policy": {
"decision": "AskEveryTime",
"matchedScope": "per-channel",
"delegationMandateId": null,
"actChain": []
},
"notarization": {
"notaryService": {
"id": "did:web:notary.squillo.com",
"name": "Squillo Notary Service",
"version": "0.1.0"
},
"decisionTimestamp": "2026-05-21T00:00:01Z",
"decisionLatencyMs": 1834
}
},
"linkedMandate": null,
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
"created": "2026-05-21T00:00:01Z",
"proofPurpose": "assertionMethod",
"proofValue": "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
}
}

This example is NotaryAttested — it carries no policy.attestationMode, and an absent field means NotaryAttested (spec §7.1.7). Read it as a notary asserts this human authorized this: the single proof is the notary's, not the human's. The stronger PrincipalSigned shape, where the human's own key signs and the notary countersigns in a two-element proof chain, is at spec §7.3.1. Both are valid; a verifier must never report the weaker one as the stronger.

The envelope ships on the wire in two simultaneous encodings:

  1. JSON-LD Verifiable Credential (above) — full self-describing form used for archive, audit logs, and recipient-side full-fidelity verification.
  2. JWS detached compact — short form carried in channel-native metadata (email header APH-Attestation:, Slack blocks metadata, etc.). The protected header pins alg (EdDSA or ES256), kid, typ: aph+jws, and cty: vc+ld+json. The payload is the JCS-canonicalized JSON-LD VC.

Repo layout

aph/
spec/
aph-0.1.md Specification text (v0.1.0, FINAL)
aph-0.2.md The v0.2 delta (FINAL): sealed payloads, rotation
schemas/ JSON Schema family, welded to the vectors in CI
a2a-extension.md A2A AgentCard extension descriptor
security-considerations.md Threat model / security companion
assets/
aph-banner.jpg README banner
examples/
slack_reply_envelope.json One per channel kind: shape only,
email_reply_envelope.json placeholder proofValues
discord_dm_envelope.json
teams_channel_envelope.json
whatsapp_envelope.json
google_chat_envelope.json
imessage_envelope.json
slack_new_with_extensions_envelope.json The §7.5 optional extensions
principal_signed_envelope.json Signed: Ed25519, eddsa-jcs-2022
es256_signed_envelope.json Signed: ES256, ecdsa-jcs-2019
detached_jws_envelope.json Signed: JsonWebSignature2020
ts_minted_envelope.json Signed: Ed25519, minted by the TypeScript
implementation and verified by the Rust
interpreters/
rust/ Reference Rust implementation (cargo workspace)
aph-core/ Wire types, mandates, flow state machines, signing helpers
aph-conformance/ Golden-envelope + contract conformance suite, channel binding specs
aph-cli/ `aph` binary: validate / inspect / golden (conformance fixtures)
aph-resolver/ Optional DNS TXT + did:web fetch adapters (the only crate carrying HTTP/DNS deps)
aph-ts/ wasm binding (parse/serialize for JS hosts)
aph-py/ pyo3 binding (the same surface, for Python hosts)
aph-core/examples/ Runnable, self-narrating usage examples
elixir/ rustler binding (the same surface, for BEAM hosts); its
NIF crate lives at native/aph_nif and is excluded from
the cargo workspace, because mix drives that build
typescript/ SECOND implementation: mint + verify, from the spec alone
(no wasm, no binding — Node >= 20, WebCrypto, zero runtime deps)
APH Spec/
0.1.0/ N Lang Specification Snapp (literate .n.md types)
how/ Worked examples served by `nlang how --plugin aph`
snapp/
aph@0.1.0-alpha.1.json Compiled Snapp bundle
.claude-plugin/ Agent-plugin + marketplace manifests
skills/ Agent skill: the protocol crash course (/aph:spec)
commands/ Agent commands: /aph:validate, /aph:conformance
.github/
workflows/
validate-examples.yml JSON validity + sanitization checks
rust-interpreter.yml cargo test for interpreters/rust
python.yml cargo test for the Python binding
elixir.yml mix test for the Elixir binding
typescript.yml tsc + node --test for the second implementation
README.md
LICENSE
CONTRIBUTING.md
CHANGELOG.md

Using the reference implementation (Rust)

aph-core is the protocol library — wire types, mandates, the two flow state machines, the role matrix, the error taxonomy, and the signing helpers. It depends only on serde, serde_json, thiserror, chrono, p256, and base64.

[dependencies]
aph-core = "0.1.0"

Verify an envelope you received

Parsing is strict by design (spec §7.1): an unknown field is a hard error, so a producer cannot smuggle a claim past a verifier that does not understand it.

let envelope: aph_core::NotarizationEnvelope = serde_json::from_str(received)?;let subject = &envelope.credential_subject;println!("{} authorized {} to send on {}",
subject.human_principal.display_name,
subject.agent.display_name,
subject.channel.kind);

Check a signature

The signature covers the canonical form of the envelope, not the JSON text it arrived as — which is what lets an envelope survive re-serialization by intermediaries. Strip the signature slot, canonicalize, then verify:

letmut unsigned = serde_json::to_value(&envelope)?;
unsigned["proof"]["proofValue"] = serde_json::json!("");let canonical = aph_core::canonicalize_rfc8785(&unsigned);let ok = aph_core::verify_detached_jws(&jws, canonical.as_bytes(),&notary_key);

Resolving notary_key from the issuer DID is the verifier's job (spec §8.4: did:key offline, DNS TXT at _aph._notary.<domain>, or did:web). aph_core::discovery ships that resolution split in two halves: the parsing and publication code is pure and offline (dns_txt, did_document, publish, and the did:key decode), while the DNS query and the HTTPS document fetch stay behind the two one-method traits in discovery::ports for your adapter to supply. discovery::composer::resolve drives them in the §8.4.6 order — did:key, then DNS TXT, then did:web — advancing on ABSENCE only, and never falling back to a weaker anchor after a failure.

Enforce scope and consent

// Standing authority: does this mandate still cover this send?if !mandate.is_valid_at(now) || !mandate.allows_channel("slack"){returnErr(aph_core::AphError::channel_not_allowed("slack"));}// Human-present flow: authority cannot be minted without the human// being asked — this transition is refused with APH_E002.letmut flow = aph_core::HumanPresentNotarizationFlow::new(mandate_id);
flow.transition_to(aph_core::HumanPresentNotarizationState::MandateIssued)?;// Err

Runnable examples

Each one narrates what it is doing as it runs:

cd interpreters/rust
cargo run -p aph-core --example parse_and_inspect # strict parsing, reading a claim
cargo run -p aph-core --example sign_and_verify # canonicalize -> sign -> verify -> tamper
cargo run -p aph-core --example mandates_and_flows # scope, validity, both state machines

Command line

cargo run -p aph-cli -- validate examples/slack_reply_envelope.json
cargo run -p aph-cli -- inspect examples/slack_reply_envelope.json
cargo run -p aph-cli -- golden # list conformance fixtures
cargo run -p aph-cli -- help# usage, plus the --json contract

Publishing a notary's keys

The two §8.4 discovery surfaces are rendered by the same code a verifier reads them with, so an operator never hand-writes either wire form:

# the DNS TXT value for a key (§8.4.5); --domain prints the record NAME on stderr
cargo run -p aph-cli -- render-txt did:key:z6Mk... --kid k1 --domain notary.example.com
# the DID Document (§8.4.4); each key is a did:key with its kid as the fragment
cargo run -p aph-cli -- render-did did:web:notary.example.com did:key:z6Mk...#k1
# the DNS TXT value publishing a vocabulary's digest (§8.5.1)
cargo run -p aph-cli -- render-vocab "snapp/aph_guardrails@0.1.0-alpha.1.json" --domain squillo.com

render-did takes several keys so a rotation overlap (§8.4.7) can be published in one document. render-vocab READS the digest from the bundle's own @snapp.integrity rather than recomputing it — two derivations of one fact drift, and a drifted digest does not fail loudly: it publishes a value that refuses bytes which are in fact correct. The record name goes to stderr and the value to stdout, so a name cannot be captured into the record's content by a redirect.

Both take PUBLIC key material only. A did:key IS a public key; nothing here accepts a signing seed, and nothing here should be extended to — a seed on a command line is readable by every other process on the host.

validate is a strict structural check — it does not verify signatures, time windows, or body hashes (spec §8.3 steps 2–8). Exit codes: 0 valid, 1 invalid, 2 usage.

Reading the verdict from a build

validate --json writes one JSON object to stdout and nothing to stderr. The exit codes are unchanged, so a gate may read the code, the object, or both — one call produces both and they cannot disagree. Without --json every byte the tool writes is what it has always written.

$ cargo run -q -p aph-cli -- validate --json examples/slack_reply_envelope.json
{"ok":true,"id":"urn:uuid:00000000-0000-4000-8000-000000000001","issuer":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"}

An unrecognized value in one of §7.1's closed vocabularies — a channel kind, a content class — is the refusal downstream implementations ask about most, so it names itself and hands back the whole set rather than leaving you to find it:

$ your-minter emit-envelope | cargo run -q -p aph-cli -- validate --json -
{"ok":false,"layer":"parse","reason":"closed_set","message":"invalid envelope: `squillo` is not in the closed set {slack, email, …} at line 27 column 23","field":"credentialSubject.channel.kind","value":"squillo","allowed":["slack","email",""]}

The set is abbreviated here on purpose: §7.1's vocabularies widen by design, and a copy of a closed vocabulary sitting in prose teaches the wrong set the moment one does. The object carries the whole current set every time it refuses, and aph help prints it — this page deliberately does not.

fieldpresent whenmeaning
okalwaystrue exactly when the envelope strict-parsed. Mirrors the exit code.
id, issuerok: truewhich envelope was admitted.
layerok: falsewhich layer refused: parse or io.
reasonok: falseclosed_set, malformed, or unreadable.
messageok: falsebyte-for-byte the line the same run prints to stderr without --json.
fieldreason: closed_setdotted wire path of the offending field, ready to paste into a search of your own document.
valuereason: closed_setthe value that is not in the set.
allowedreason: closed_setthe complete set, in spec order — so your error message never hard-codes the vocabulary.

There is deliberately no APH_E code in this object. A closed-vocabulary value is refused at strict parse — spec §8.3 step 1, below the protocol's closed sixteen-code error taxonomy — so reporting one would invent a code the specification does not define, and a consumer routing on it would be routing on fiction. layer names where the refusal came from instead. This is the same reading the second implementation applies, and the reason ChannelKind::from_str returns a plain message rather than an AphError.

Exit 2 is a usage error, not a verdict. A missing input argument prints usage on stderr and emits no JSON, because nothing was read and {"ok":false} would tell a gate an envelope had been refused.

Stability. The fields above keep their names and their meanings. New fields and new reason values may be added, so branch on ok first and treat an unrecognized reason as a refusal — never as a pass. aph help prints this same contract, so a consumer who has the binary does not need this page.

Gate your own envelopes in your own CI

validate reads stdin as -, so nothing about your minter has to be written in Rust. Build the binary once, put it on PATH, then pipe one envelope per run and let the exit code fail the build:

cargo build --release -p aph-cli # once; the binary lands at target/release/aph
your-minter emit-envelope | aph validate -

That one line is the whole gate. To also say why it failed — which is what turns an unknown-value refusal into a fix instead of a question — read the --json object with jq:

#!/usr/bin/env bash# scripts/aph-gate.sh — fails the build unless the envelope just minted strict-parses.set -uo pipefail
verdict=$(your-minter emit-envelope | aph validate --json -)
status=$?
verdict=${verdict:-null}# the minter itself failed; keep jq well-fedcase"$status"in
0) echo"admitted: $(jq -r .id <<<"$verdict")";exit 0 ;;
2) echo"aph: usage error — the command line is wrong, not the envelope";exit 2 ;;
esaccase"$(jq -r '.reason // "no-verdict"'<<<"$verdict")"in
closed_set)
printf'refused: %s = "%s" is not one of %s\n' \
"$(jq -r .field <<<"$verdict")" \
"$(jq -r .value <<<"$verdict")" \
"$(jq -c .allowed <<<"$verdict")" ;;
no-verdict) echo"refused: your minter produced no envelope to validate" ;;
*) echo"refused: $(jq -r .message <<<"$verdict")" ;;
esacexit 1

Nothing in it is CI-vendor-specific; it is one step wherever your build runs:

- name: APH envelope gaterun: ./scripts/aph-gate.sh

Two notes worth the ten seconds. pipefail makes status the minter's code when the minter is what failed, which is why verdict is normalized to null before jq ever sees it. And a closed_set refusal is not a defect in this tool: §7.1's vocabularies are closed by design, a conformant verifier MUST reject a value it does not recognize, and adding a value is a MINOR version event that carries a producer rule with it — see CONTRIBUTING.md.

JavaScript / TypeScript

cd interpreters/rust && wasm-pack build aph-ts --target web
import{parseEnvelopeJson,serializeEnvelope}from'./pkg/aph_ts.js';constenvelope=parseEnvelopeJson(received);// throws on invalid shape

Python

cd interpreters/rust/aph-py && maturin build --release
importjson, aphenvelope=json.loads(aph.parse_envelope_json(received)) # raises aph.AphError on invalid shape

Elixir / BEAM

cd interpreters/elixir && mix deps.get && mix test
{:ok,normalized}=APH.parse_envelope_json(received)# {:error, code} on invalid shape{:ok,mode}=APH.verify_proof_structure(received)# "PrincipalSigned" | "NotaryAttested":ok=APH.require_attestation_mode(received,"PrincipalSigned")

Refusals are {:error, message} rather than exceptions — on the BEAM a refused envelope is an ordinary outcome — and a protocol refusal's message leads with its APH_E* code, so a caller matches APH_E013 there exactly as a TypeScript caller matches it on the thrown message. Not published to hex.pm: see interpreters/elixir/README.md.

aph-ts, aph-py, the Elixir binding and the Go binding (interpreters/go — pure Go, running the reference as WebAssembly under wazero with no cgo; its committed wasm artifact is byte-diffed against a pinned-toolchain rebuild on every push) are four bindings of this one reference implementation, held at export parity — the same six operations, the same semantics, the same error identity, each in its language's idiom — under a standing rule that an addition to any one is unfinished until it lands in the others — enforced by a census test that counts every export surface against one roster, so they cannot drift into teaching different things. All four cross the FFI as JSON text in both directions, because the envelope's proof union is untagged and an object round-trip hands arm selection to a second deserializer. None is a second implementation, and none is evidence that one can be built: see interpreters/rust/aph-py/README.md.

The second implementation (TypeScript)

interpreters/typescript/ is a complete APH v0.1 implementation — mint and verify — written from spec/aph-0.1.md and the published examples/. It shares no code with the Rust: its own RFC 8785 canonicalizer, its own strict parser, its own §7.2.1 signing bases, its own base58btc and did:key codecs, signatures through the runtime's WebCrypto. Node ≥ 20, no runtime dependencies, and the TypeScript compiler is the only dev-time one.

cd interpreters/typescript && npm install && npm run build && npm test

What it proves, and what it does not. It proves the specification is implementable twice from its own text — the definitional gap the three bindings cannot close, because all three are bindings of the one reference. It is independence of CODE, not of TEAM: the same authors wrote both, so it is not evidence that the document survives a stranger. The invitation below still stands, and an outside implementation remains the missing half.

Cross-verification runs in both directions, as committed bytes. The TypeScript admits examples/principal_signed_envelope.json — all four Rust-made Ed25519 signatures — and refuses tampered, downgraded and forged-label variants with the §11 codes §11 assigns them. In the other direction it mints examples/ts_minted_envelope.json, which interpreters/rust/aph-conformance/tests/ts_minted_cross_verify.rs verifies in full: strict parse, §7.1.11 structure, §7.2.1 issuance order, §7.1.7.1 mandate bindings, and all four of its signatures. Neither stack invokes the other — there is no Node in cargo and no cargo in Node, only files.

The committed cross-artifact is Ed25519 only, and the reason is worth stating. Ed25519 is deterministic in both stacks, so its bytes can be pinned. WebCrypto's ECDSA is randomized and exposes no RFC 6979 mode, so a TypeScript ES256 envelope cannot be byte-pinned at all; ES256 is covered one-directionally instead — the TypeScript verifies this repository's deterministic ecdsa-jcs-2019 vector, and mint-then-verifies its own inside a single run.

It has already disagreed with the spec once, which is the point. §6.1's field table and §7.2.1's closing sentence give contradictory rules for the Delegation Mandate signing bases (remove the signature members, or empty them); the published bytes select removal, and the contradiction is now pinned by a test rather than absorbed by an implementer. Details in interpreters/typescript/README.md.

Conformance

interpreters/rust/aph-conformance carries golden fixtures, contract tests, and the three channel binding specs (email, chat platforms, MCP). It also validates every envelope in examples/ against the implementation and asserts that what the implementation emits is value-identical to those published files — the check that catches serializer-side drift. See interpreters/rust/README.md for the full picture, including the two deliberate divergences from RFC 8785 and RFC 7518 that the tests deliberately pin.

Implementing APH in another language

APH is only a protocol if a second implementation can be built from what is published here. This section is the entry point for that: what to point your own code at, in what order, and — just as importantly — what these artifacts do not prove.

The four targets, and what each one proves

1. Point your PARSER at examples/*.json (12 files, no toolchain required). Every file must deserialize under a strict schema: unknown top-level or credentialSubject-level fields are hard errors (§7.1), and channel.recipientAddressing is the one exception whose sub-fields are opaque and MUST NOT fail (§7.4). If your parser accepts a field APH never defined, a producer can smuggle a claim past you.

2. Point your VERIFIER at the four signed envelopes (no toolchain required). Three cover the signing paths §8.1 and §8.2 make MUST-support, so a verifier can be checked against every path it is required to implement rather than only the default one; the fourth was minted by a different implementation entirely. Every key in all four is a PUBLISHED test vector — they authorize nothing, and anyone can re-derive them from the RFC that prints them.

  • examples/principal_signed_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures. Two envelope proofs and two mandate signatures, under the RFC 8032 §7.1 TEST 2 (principal) and TEST 3 (notary) public test seeds. A verifier that reproduces all four has independently implemented RFC 8785 canonicalization, the per-proof signing bases of §7.2.1, the proof-chain linkage of §7.1.11, and the embedded-mandate check of §7.1.7.1. Getting §7.2.1 wrong is the likeliest failure: proofValue is set to the empty string, not removed, and the principal proof covers proof as a one-element array.

  • examples/es256_signed_envelope.json — ES256 / ecdsa-jcs-2019, two proofs. The same PrincipalSigned chain on the other curve, so diffing it against the file above shows exactly what §8.1's second algorithm changes. The principal key is the RFC 6979 Appendix A.2.5 sample scalar, the notary key is the d of the RFC 7515 Appendix A.3.1 ES256 JWK. proofValue here is P1363 r‖s (64 bytes, multibase) per the suite definition, never DER. It embeds no mandate — the Ed25519 file is the §7.1.7.1 vector.

  • examples/detached_jws_envelope.jsonJsonWebSignature2020, one proof. §8.2's other proof format: a compact detached JWS in proofValue over the same §7.2.1 base. It is NotaryAttested and its issuer is the notary's own P-256 did:key, so it verifies offline from itself with no network and no prior trust relationship. Its protected header carries the six members §8.2 requires, and a verifier MUST check them (§8.3 step 7) — that is what rejects alg: none. ⛔ Two deployed quirks travel with this format and are preserved deliberately: the header declares "b64":false with "crit":["b64"] while the payload is nevertheless base64url-encoded into the signing input, and the ES256 signature inside the token is DER, not the raw r‖s RFC 7518 specifies. A standards-pure RFC 7518 signer will produce a token this vector's verifier rejects; the encoding follows the carriage, not the algorithm.

  • examples/ts_minted_envelope.json — Ed25519 / eddsa-jcs-2022, four signatures, minted by the SECOND implementation. The same PrincipalSigned shape as the first file, produced by interpreters/typescript/ rather than by the Rust — so a third implementer checking against it is checking a document two independent codebases already agree on. Two differences from the Ed25519 golden make it the easier first target: both parties are did:key, so it verifies with no supplied key and no network at all, and its body binding is REAL — the complete body travels in preview, bodySize is its UTF-8 length and bodySha256 is its digest, so §8.3 step 8 is checkable from this one file. Same principal as the golden (RFC 8032 §7.1 TEST 2), so the two DIDs can be checked against each other and against the RFC.

Byte comparison is valid on the ES256 vectors, and here is why that is not obvious. Most ECDSA is randomized: sign the same bytes twice, get two different signatures, both correct. An implementer who assumes that will read a byte-for-byte comparison against an ECDSA vector as a mistake. It is not one here — the reference uses RFC 6979 deterministic ECDSA, where the nonce is derived from the key and the message, so these files are byte-reproducible. If your own ES256 signer is randomized your envelopes are still valid; they simply will not equal these byte for byte, so compare by verifying rather than by diffing.

3. Point your PRODUCER at this repository's parser. The CLI reads stdin, so nothing about your emitter has to be written in Rust:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate -

Exit 0 means your bytes strict-parse; 1 means they do not, with the serde error naming the field. Conversely, cargo run -q -p aph-cli -- golden <n> prints fixture n raw on stdout for piping into your own verifier. These two are the only targets that need a Rust toolchain.

Add --json and that refusal becomes something your build can branch on instead of something a person has to read — reason tells a closed-vocabulary refusal apart from generic malformed JSON, and for a closed set the object carries the offending value and the whole allowed set:

your-implementation emit-envelope | cargo run -q -p aph-cli -- validate --json -

Wire this into your CI the day you start minting, not the day something breaks. The copy-pasteable gate is Gate your own envelopes in your own CI; the field-by-field shape and its stability commitment are in Reading the verdict from a build. Three separate downstream implementations have asked what an unrecognized closed-vocabulary value meant; in every case the tool already answered it, and in every case nobody had run it. A guarantee that only a human at a terminal can reach is not reachable from a build.

A worked recipient, when the vectors are not enough. The four targets above hand you artifacts; the multi-party exchange tests hand you the ALGORITHM. interpreters/rust/aph-conformance/tests/multi_party/mod.rs assembles the §8.3 recipient procedure end to end in verify_inbound — mode gate, proof structure, principal key and proof, notary key through the §8.4.6 chain at the envelope's own decisionTimestamp, notary proof, issuance order, embedded-mandate binding, both mandate signatures, validity window, and the step-8a revocation check — driven by three suites in which two parties with fully separate keys, notary origins, and stores exchange envelopes as JSON text over a wire that carries nothing else. The refusal tests each assert a specific error code, so they double as a map from "what an attacker changed" to "which check refuses it". If you are implementing a verifier in another language, read that harness the way you would read pseudocode in the spec — except this copy compiles, and cargo test -p aph-conformance proves it.

4. Point your revocation code at spec/schemas/ and the spec's own printed records. The two schemas constrain the §6.3.3 status entry and status list credential; spec/schemas/README.md states the three rules no JSON Schema can express (same-origin binding, issuer binding, proof and freshness). For key discovery, both the §8.4.4 DID Document and the two §8.4.5 DNS TXT records are usable directly as parse vectors, but they are reproduced to two different standards and the difference is worth stating: the reference tests reassemble the two TXT tag-lists byte-for-byte — a byte comparison would pass — while the DID Document is reproduced verbatim in content but re-indented (2 spaces in the spec, 4 in the Rust literal that holds it). JSON whitespace is not semantic, so nothing about the vector is weaker; only the claim is.

What the vectors do NOT cover

Stated in full, because overclaiming coverage is worse than admitting a gap:

  • Every algorithm §8.1 requires has a signed vector except EdDSA inside a JWS.examples/es256_signed_envelope.json publishes ecdsa-jcs-2019 (two PrincipalSigned proofs, P1363 r‖s in proofValue) and examples/detached_jws_envelope.json publishes JsonWebSignature2020 (one NotaryAttested proof, verifiable offline from its own did:key issuer). What remains uncovered is the fourth combination — alg: EdDSA carried in a detached JWS. §8.1 makes it MUST-support; the reference implementation does not implement it, refuses it by name (APH_E010) rather than mis-reporting it as a bad signature, and therefore has no vector to publish. Golden fixture 3 still pins only the ecdsa-jcs-2019cryptosuite string with a placeholder proofValue; the published example, not that fixture, is the vector.
  • Eight of the twelve example files exercise shape only. The seven channel files and the §7.5 extensions file carry illustrative proofValues, so §8.3's signature step cannot be exercised against them and verification on those eight is expected to fail. The other four are the signed vectors in target 2.
  • §8.3's body-hash binding is exercised by two files — one of them end to end, refusal included.examples/principal_signed_envelope.json now attests the SHA-256 and exact byte length of the committed examples/principal_signed_body.txt: the conformance suite re-hashes that file the way a recipient does, checks the pair under the golden's four real signatures, and proves that a one-byte-different body refuses with APH_E009 specifically. examples/ts_minted_envelope.json binds the body it carries in preview. The other ten examples still pair bodySha256 = the SHA-256 of the empty string with a fictional bodySize — a combination no body can satisfy — and are shape-only on this axis; that includes the ES256 and detached-JWS vectors, which prove signatures, not bodies. An implementer who wants step 8 tested points their verifier at the golden AND its body file together.
  • The second implementation is not an independent TEAM.interpreters/typescript/ shares no code with the Rust and cross-verifies with it in both directions, which closes the "is this document implementable twice?" question. It does not close "does this document survive a reader who cannot ask the authors what they meant" — same authors, both times. That gap can only be closed from outside, which is what the Reporting section below is for.
  • The §6.3.3 revocation vectors are Rust constants, not files. The accept / refuse-at-parse / refuse-at-binding entry sets and the refuse-document set live in interpreters/rust/aph-conformance/src/lib.rs, each paired with the rule it violates. They are readable without linking anything, but a non-Rust implementer has to read them out of the source rather than load a directory.
  • The status list vectors carry no proof. They exercise every §6.3.3.3 rule up to the signature — issuer binding, purpose, vintage, freshness, and the MSB-first bit order — and stop there. An implementation that passes all of them may still have no proof check at all, which is the one failure that makes the whole mechanism forgeable. (The reference implementation's own proof check is pinned end to end by the cross-notary exchange test, forged-list case included — but that is Rust exercising Rust. A non-Rust implementer still has no proof vector to check against, so for them the gap stands.)
  • The §8.4.5 printed TXT example is a parse vector, not a verify vector. Its 32 key bytes are not a valid Ed25519 curve point, so it round-trips through a parser and cannot check a signature.
  • There is no JSON Schema for the envelope. §7.1 and the strict parser are the shape; a schema for it would be a third expression of the same rule.

Reporting

If your implementation disagrees with a published artifact, the disagreement is worth filing either way: the specification is normative, the schemas and fixtures are not, and where they conflict the fixture is the defect. See CONTRIBUTING.md.

Running a notary

Operating a Notary Service means holding a signing key, controlling the domain its did:web names, and republishing a revocation status list on a cadence tighter than the freshness bound verifiers enforce. spec/operations.md is the runbook for all three — what losing each one costs, the pre-authorized rotation that makes key loss survivable without any custodian, and the monitor that shows the republish deadline before it passes rather than after peers start refusing.

Agent plugin

The repository is also an installable plugin for agentic coding tools, giving an agent working knowledge of the protocol plus envelope-validation and conformance commands:

/plugin marketplace add squillo/aph
/plugin install aph@aph-protocol

It provides the /aph:spec skill (a protocol crash course grounded in the spec sections), /aph:validate, and /aph:conformance.

For OpenAI Codex and any tool following the agents.md convention, the repo root carries an AGENTS.md with orientation, CI-exact build/test commands, and the invariants — it points into the same skills/spec/SKILL.md, which follows the open Agent Skills format both ecosystems load, so both packs read one knowledge source.

N Lang Specification Snapp

APH Spec/0.1.0/ defines the protocol's JSON documents as N Lang types, compiled to snapp/aph@0.1.0-alpha.1.json. N Lang is a proprietary language by Squillo Inc., commercially licensable only through Squillo Inc.; the Snapp sources themselves are Apache-2.0 like the rest of this repository.

Contributing

See CONTRIBUTING.md for the contribution process, scope, spec-change rules, and versioning policy.

License

Apache License 2.0 — see LICENSE.

Authors

Squillo, Inc.

Releases

Packages

Contributors

Languages