An APH notarization gate for
jido agents: a Jido.Plugin that refuses to
let an agent act on a signal that carries no notarization envelope, plus a
demo that is scrupulous about what that refusal actually proves.
▶ Watch the explainer video — what APH is, and how it works in the Elixir ecosystem for jido, in plain language before this README makes it precise.
DRAFT — published for review, not for production use. Every suite is green, on this machine and on CI. What "draft" means here is everything else: nothing has been reviewed outside the project, no release is tagged, nothing is published to Hex, three of four governance questions are still unanswered, and the API may change without notice. Read Status before depending on any of it.
Community integration, not an agentjido package. The repository is named
aph-jidobecause this is APH's integration with jido; the Elixir package keeps the name the code was built with,:jido_aph, so the two deliberately differ. Nothing here is endorsed by, affiliated with, or maintained by the agentjido project. See Governance for what the aph maintainer has and has not ruled on.
Everything in this repository follows from one paragraph in the aph Elixir
binding's own documentation, quoted here verbatim from
../aph/interpreters/elixir/lib/aph.ex — the @doc on
APH.verify_proof_structure/1, the strongest check this gate can run:
A successful return says the structure is sound. It says NOTHING about whether any signature verifies — a caller that reports "the human signed this" on the strength of this function alone is reporting a claim no key has backed.
So JidoAph.Guard does not say it. Its fixed verdict wording is
notarization-shaped, mode policy satisfied, and the bare words
"verified" and "signed" never appear in anything the guard writes. Scoped to
its own words on purpose, because a refusal log line is not all the guard's:
aph-ex's messages pass through verbatim, and a strict-parse refusal quotes the
offending field name back, so an envelope carrying a top-level field named
verified logs unknown field `verified`, expected one of …. That fragment
is the sender's word, not the gate's. Everything the guard authors is asserted
by test over real captured logs, in four places: the demo's happy path, its
refusal matrix, and both transcript tests. The assertions are word-boundary
matches, not substring bans, because the guard must be free to write
PrincipalSigned (closed spec vocabulary, §7.1.7) and tagged unverified —
both of which are the honest thing to say and both of which a substring ban
would forbid.
What the guard does claim, and all it claims:
- the envelope parsed under APH's strict schema, unknown fields denied (spec §8.3 step 1);
- its proof structure matches its declared label in both directions
(§7.1.11) — which is what catches a forged
PrincipalSignedlabel; - the configured attestation-mode policy is satisfied with no silent downgrade (§8.3.1 step 1a).
Zero cryptography runs on the BEAM. No signature is checked, no key is resolved, no clock is read, no digest is recomputed. That is not a gap this README glosses; it is the whole subject of the next section.
The demo's teaching climax exists because of exactly this: a structurally
perfect envelope carrying a transplanted proofValue passes every single
guard check and is admitted, and is then refused on signature verification by
an independent implementation. This is why structural validity is never
called verification.
Reproduced from PRD-001 §7.3, and carried byte-identically in
JidoAph.Guard's own moduledoc so the code and the README cannot drift apart.
The third column is stated, not implied.
| Check (spec ref) | Guard (BEAM, aph-ex) | Deep leg (TS sidecar, optional) | Nowhere in this repo |
|---|---|---|---|
| Envelope byte bound (§7.1.7.1) | YES — before parse | YES (maxEnvelopeBytes) | |
| Strict parse, unknown fields denied (§8.3 step 1) | YES | YES | |
| Attestation-mode policy (§8.3.1 step 1a, APH_E012) | YES | YES (requireMode) | |
| Proof structure, label-vs-structure both directions (§7.1.11, APH_E013) | YES | YES | |
| Signatures over JCS/RFC 8785 (§8.3) | no | YES — all four Ed25519 | |
| Issuance order (§7.2.1) | no | YES | |
| Embedded delegation-mandate binding (§8.3.1 step 1d) | no | YES | |
| Validity window (60s skew) | no | YES — against pinned now, stated |
|
| bodySha256 over received bytes (§8.3 step 8, APH_E009) | no | YES | |
| Closed channel/contentClass vocabulary (§7.1.5/§7.1.6) | no (aph-ex exposes no such op) | YES (TS enforces) | |
| Key discovery: DNS TXT / did:web (§8.4) | no | no (TS never fetches; did:key decodes offline; the golden's one did:web notary key is supplied via options.keys and the transcript says so) |
YES — named in README |
| Revocation / credentialStatus transport | no | no (golden carries none; TS refuses status-bearing envelopes) | YES — named in README |
| Live notary contact | no | no | YES — named in README |
The guard is depth 0–1 plus mode policy. The deep leg is full offline §8.3 in a second, independent implementation, with exactly one key supplied out of band and said so out loud. The right-hand column is the honest end of the story: key discovery, revocation transport, and any contact with a live notary happen nowhere in this repository, and no output anywhere implies otherwise.
This is a table of checks, and the claims map the guard now hands the
routed Action added no row to it. Those four field values are READ from the
document and compared against nothing: channel_kind is not the
closed-vocabulary row (still "no"), and human_principal_did is not the
signatures row (still "no").
One clone.
git clone https://github.com/squillo/aph-jido.git
cd aph-jido
mix deps.get
mix testWhat mix deps.get pulls, and why it is one dependency instead of a second
clone. aph-ex is not on hex.pm, so :aph is fetched straight from git —
{:aph, git: "https://github.com/squillo/aph.git", subdir: "interpreters/elixir", ref: "f01e3470…"}. A subdir: dependency clones the WHOLE repository and
treats the subdirectory as the project root, which is what makes one dependency
enough: deps/aph/ ends up holding the Elixir binding, the Rust crate its NIF
compiles against, the golden fixtures every test and the demo read, and the
independent TypeScript implementation the deep leg shells out to.
The ref: is a commit, deliberately. CI pins the same SHA and fails on fixture
drift, so an unpinned dependency could hand a reader goldens whose bytes this
repository's recorded digests do not describe, while CI stayed green. Bumping
the pin means changing both the ref: in each mix.exs and the digests in
.github/workflows/ci.yml — one reviewed change, which is the point.
This used to be two sibling clones with a relative path dependency, and the
first reader from outside the project hit exactly the wall that design built:
mix deps.get succeeded, mix compile failed, and the message named a path
without saying where to get it. See D3 in
PRD-001.
Developing against a live aph tree. Set APH_PATH to the ROOT of an aph
checkout — the directory holding examples/ and interpreters/ — and both
apps switch the dependency and the fixture corpus to it together:
APH_PATH=/path/to/aph mix deps.get && APH_PATH=/path/to/aph mix testA Rust toolchain is mandatory, not optional: the aph-ex binding is a
rustler NIF that compiles from source, and no prebuilt .so ships. The first
compile builds the NIF and all of jido; expect several minutes.
These are the versions this repository was actually built and tested on — not a compatibility range, and not the floor:
| Version | Notes | |
|---|---|---|
| Elixir | 1.20.3 | mix.exs declares ~> 1.18, which is jido's own floor |
| Erlang/OTP | 29.0.5 | erts-17.0.5 |
| cargo | 1.96.0 | mandatory — builds the aph-ex NIF |
| Node | v26.3.0 | optional, deep leg only; the TS package's floor is >= 20 |
Resolved dependencies (mix.lock): jido 2.3.3, jido_signal 2.2.2
(~> 2.2 — deliberately not 3.0.0-beta.1, which jido 2.3.3 does not
accept), jido_action 2.3.2, rustler 0.30.0, zoi 0.18.7. The aph-ex rustler
pins are upstream-owned and untouched here.
The demo is a separate mix app under demo/, with its own deps and its own
formatter config:
cd demo
mix deps.get
mix test # Node-free; :deep-tagged tests are excluded by default
mix demo.run # the structural leg — no Node, no networkThe corpus is read at runtime from the :aph dependency's own checkout —
deps/aph/examples/, located through Mix.Project.deps_paths() so that the
library and the demo each find their own. APH_PATH, or an explicit
config :jido_aph, aph_repo_path:, overrides it. With none of them resolvable
the loader raises loudly with the remedy rather than failing somewhere subtle.
Starting from demo/, where the previous block left you:
cd deps/aph/interpreters/typescript && npm install && npm run build # builds dist/
cd - # back to demo/ — both commands below need it
mix test --include deep # or: APH_DEEP=1 mix test
mix demo.deep_verifydist/ is a build artifact, gitignored upstream, and mix deps.get fetches
the TypeScript sources without building them — which is why this leg is
optional and the core demo never needs Node.
The cd - matters. Both mix commands live in the demo app: run them at the
repository root instead and mix demo.deep_verify reports that the task does
not exist, while mix test --include deep prints 81 passed — the
library suite, which contains no :deep-tagged tests at all. A pass for work
that never ran is the one outcome this repository is built to refuse, so read
that number as the wrong-directory signal it is.
npm install, not npm ci: upstream commits no lockfile and says why in its
own interpreters/typescript/.gitignore — "a lockfile would be a
durable-looking record of a dependency graph this package's whole claim is
that it does not have. CI installs with npm install." npm ci refuses to
run without one, so it fails on every fresh checkout. The build writes only
untracked artifacts inside deps/; nothing here ever writes to the aph
repository itself.
mix demo.deep_verify degrades on purpose: with Node or the built dist/
missing it prints DEEP LEG UNAVAILABLE — nothing was verified, and nothing is claimed., the reason, the full setup instructions, and exits non-zero. It
never falls back to a shallower check and reports it as a verification.
Take these three modules; the demo is illustration, not a dependency.
JidoAph.Guard — use Jido.Plugin, name: "aph_guard". All gating happens
in prepare_signal/2, which halts the chain before routing, so a refused
signal never reaches an Action. The gate order is fixed and every step is
mandatory:
byte_size(envelope_json) <= 65_536— before any parse, because canonicalization happens on unauthenticated input (§7.1.7.1) — then a UTF-8 well-formedness check on the now-bounded bytes, since the extension schema's:stringis a binary type and theaph-exops raise on anything that is not text;APH.parse_envelope_json/1— strict, unknown fields denied (§8.3 step 1);APH.require_attestation_mode/2when:require_modeis configured (§8.3.1 step 1a,APH_E012);APH.verify_proof_structure/1— always, even with no mode policy, because the mode gate alone admits a forgedPrincipalSignedlabel (§7.1.11,APH_E013). A mandatory test proves that admission-then-catch.
Six config keys, Zoi-validated at agent compile time with unknown keys
refused rather than stripped (a silently-dropped require_mode would
disable the mode gate): :required, :require_mode, :signal_patterns,
:refusal, :depth, :deep_verifier.
defmodule MyApp.Gateway do
use Jido.Agent,
name: "gateway",
plugins: [
{JidoAph.Guard,
%{required: true, require_mode: "PrincipalSigned",
signal_patterns: ["slack.reply.requested"]}}
],
signal_routes: [{"slack.reply.requested", MyApp.Actions.DeliverReply}]
endOn admit, the guard contributes one runtime-context key that the routed Action
reads as context.aph:
%{aph: %{verdict: "notarization-shaped, mode policy satisfied (PrincipalSigned)",
structure_mode: "PrincipalSigned",
require_mode: "PrincipalSigned",
depth: :structural,
claims: %{envelope_id: "urn:uuid:00000000-0000-4000-8000-0000000000f3",
human_principal_did: "did:key:z6Mkia…",
agent_did: "did:web:agent.squillo.com",
channel_kind: "slack"}}}With no mode policy configured the verdict is "notarization-shaped" alone —
the guard never names a policy nobody asked it to enforce. :depth reports
what the gate ran, never what the config declared.
:claims is a separate key and the separation is the label: everything under
it is the envelope's own unverified claim about itself, read out of the
document after the gate and compared against nothing — no clock, no delivery
context, no key, nothing previously seen. N1–N6 below hold over every one of
those four strings. human_principal_did does not mean that human authorized
anything, and agent_did is unauthenticated even in a fully signature-checked
envelope, because §7.1.11 gives the agent no proof role at all. It is surfaced
anyway, and deliberately: with the guard silent about who the envelope names,
an implementer who wants the principal's DID digs it out of the signal and
labels it nothing.
Refusals return {:error, reason} with aph-ex's own message passed through
byte-untouched. Through a live agent they arrive at the
Jido.AgentServer.call/2 caller wrapped as
%Jido.Error.ExecutionError{message: "Plugin prepare_signal failed", details: %{plugin: JidoAph.Guard, reason: reason}} — match on details.reason, not
on the message, which is a fixed framework string.
JidoAph.Envelope — read-only field access over an envelope aph-ex has
already admitted, and the one place a second JSON parser is allowed to run.
Its only constructor, from_normalized/1, takes APH.parse_envelope_json/1's
normalized output — never the wire bytes — so serde_json stays the sole
adjudicator of unknown fields, duplicate keys, and the untagged proof union.
Accessors (id/1, valid_until/1, human_principal_did/1, agent_did/1,
channel_kind/1, recipient_addressing/2, content_class/1,
body_sha256/1, attestation_mode/1, …) return the document's own value, or
nil when the field is absent — and a check built on one of them must read
that nil as a refusal, never as a pass. Nothing here verifies anything; it
decodes with stdlib JSON (Elixir ≥ 1.18), so no dependency was added.
JidoAph.Signal.Ext.Notarization — the registered Jido.Signal.Ext that
carries the envelope, with JidoAph.attach_notarization/3 and
JidoAph.read_notarization/1 as the consumer-facing helpers:
{:ok, signal} =
JidoAph.attach_notarization(signal, envelope_json, body_b64: Base.encode64(body))
case JidoAph.read_notarization(signal) do
nil -> :no_notarization_on_this_signal
%{envelope_json: json} = data ->
# body_b64 is optional: reach for it with Map.get, never a required match
{json, Map.get(data, :body_b64)}
endThe envelope crosses every boundary as JSON text. It is never decoded into a map on the trust path, in either direction: aph-ex is JSON-in/JSON-out, the untagged proof union is decided by the bytes, and two JSON texts that parse equal can hash differently.
JidoAph.DeepVerifier — a behaviour and nothing else:
verify(envelope_json, opts) :: {:ok, map} | {:error, term}, with :body_bytes,
:now, :require_mode, and :keys as the documented options. There is no
in-library implementation and a test sweeps lib/ to keep it that way. The two
known routes — a TypeScript sidecar (implemented in demo/) and a future Rust
sidecar over the published crates — both live outside this library, because the
aph binding surface is parity-locked at four ops across Elixir/wasm/Python and
nothing here grows, wraps, or forks it.
Folded in from docs/a2a-carry-mapping.md, which
is derived from a pinned byte-level test
(test/jido_aph/signal/ext/notarization_wire_shape_test.exs) rather than from
framework documentation — jido_signal's serialized extension shape is
undocumented upstream, so it is pinned, not guessed.
| Rail | Identifier | Travels on the wire? |
|---|---|---|
A2A: Message.metadata key (extension URI) |
aph://extensions/notarization/v1 |
Yes — it is the metadata key |
| jido: signal extension namespace | aph.notarization.v1 |
No — binds only via the receiver's extension registry |
The A2A URI is pinned in spec/a2a-extension.md §2: compared byte-for-byte,
opaque, never dereferenced. jido extension namespaces must match
^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$, which forbids : and / — so the URI
literally cannot be the namespace, and the two live as two single constants in
JidoAph.Signal.Ext.Notarization (a2a_uri/0, namespace/0). A self-grep test
proves the URI literal appears exactly once under lib/ and is never assembled
from substrings.
Serializing a signal that carries the extension produces this frame, asserted byte-for-byte by the pinned test (which pins both observed member orders — see the first bullet below for why there are two):
{"body_b64":"Qk9EWQ==","envelope_json":"{\"pin\":\"not an envelope\"}","id":"wire-shape-pin-0001","jido_schema_version":1,"source":"/scribe","specversion":"1.0.2","type":"slack.reply.requested"}| Fact carried | jido signal rail (pinned by test) | A2A rail (spec + precedent) |
|---|---|---|
| Which extension this is | Receiver-registry binding of namespace aph.notarization.v1; the identifier does not travel |
Message.metadata key aph://extensions/notarization/v1; the identifier is the key (§2) |
| The envelope | Top-level envelope_json member — one escaped JSON string, envelope text verbatim |
metadata[uri].envelope — envelope inline |
| Authorized body bytes | Top-level body_b64 member — base64 of the received bytes, verbatim; absent key = no body traveled |
metadata[uri].bodyRef points at a message part, and its own note requires verbatim transport in a real deployment |
| "You must present notarization" | Guard config required: true → reject and log the envelope-less signal |
Recipient deployment policy REQUIRES APH and the AgentCard declares no APH extension → reject and log for audit (§5 step 5). AgentExtension.required (§3) is the sender's flag — whether the agent asks recipients to verify — and defaults to false in v0.1 |
| Envelope-less traffic under a permissive policy | Guard required: false → pass through tagged unverified |
§5 permissive flow: "Not notarized" indicator, deliver under existing trust rules |
| Support discovery | Compile-time: the mounted plugin and registered extension are the declaration | Runtime: scan agent_card.extensions[] for the URI — no AgentCard exists on the jido rail |
Three consequences worth carrying away, all pinned by that test:
- The carry frame is not byte-canonical. Extension members flatten to top
level with no
extensionswrapper, and their order relative to the core members is stable within a VM but not across VMs. Never hash, sign, or byte-compare the frame. APH binds the envelope text and the body bytes; the frame binds nothing. - An unregistered receiver loses the notarization silently. jido_signal
registers extensions from an
@after_compilehook that a warm_buildnever fires; on a VM without the namespace registered, wire-schema validation stripsenvelope_json/body_b64with no error. For arequired: trueguard that fails closed; for a naive consumer it is silent loss. CallJidoAph.Signal.Ext.Notarization.ensure_registered/0before deserializing — the attach/read helpers already do. jido_schema_versionrides back as an opaque extension. A bridge must construct A2A metadata fromread_notarization/1and key it bya2a_uri(), never blind-forward the extensions map.
jido signals are the rail here, not A2A HTTP. Nothing in this repository
serves or fetches an AgentCard, speaks JSON-RPC message/send, or opens a
network connection of any kind. What the extension mirrors is the carry
pattern and the AgentExtension required: true / required: false semantics
— the mapping above is a semantic correspondence, not a structural one (A2A
nests both facts in one object under the URI key; the jido frame flattens two
ungrouped members). And carrying asserts nothing on either rail: presence of
the extension is transport, never verification.
The golden envelope's two proofs are anchored two different ways, and the difference is the entire §8.4 story — the one this repository does not implement.
The human principal is a did:key: the identifier is the public key,
base58-encoded behind a multicodec prefix, so it decodes offline and needs
nothing supplied. Its bytes travel inside the envelope itself.
The notary is a did:web. A did:web key is published at a document a
verifier would fetch (§8.4.4), and this repository fetches nothing — so
that one key arrives as a parameter to the deep leg, under the verification
method did:web:notary.squillo.com#key-1. That is a fixture identifier. It
was not resolved, not fetched, and not contacted — here or anywhere else in
this repository — and nothing presents it as live or resolvable. Its key bytes
are neither invented nor transcribed into this repository: demo/priv/node/verify.mjs
imports RFC8032_TEST_3 from the aph clone's own testkit, the published
RFC 8032 §7.1 TEST 3 vector that upstream examples/README.md names as the
notary's seed throughout that corpus. Anyone can re-derive it; nobody has to
trust this repository for it. The sidecar accepts no caller-supplied :keys at
all — it refuses the option — so its one trust anchor stays the reviewed one.
The live notary surface, did:web:aph-notary.squillo.com, is named in this
repository for exactly one purpose: to say that it was not contacted, by
anything, at any point. Domain canon throughout is squillo.com; the retired
domain appears nowhere, and scripts/hygiene_check.sh
fails the build if it ever does.
Every claim in this repository is attributable to specific bytes in the
SHA-pinned :aph dependency. These digests were recomputed from the fixture files, not read out of
upstream prose — upstream's examples/README.md body-binding summary is
currently stale, and this repository reports that as an erratum (see
Governance) rather than repeating it.
| Fact | Value |
|---|---|
| aph pin | f01e3470f86533c4099db8ab0ab6b155bd0ea4aa |
examples/principal_signed_envelope.json |
3382 bytes, sha256 8a6015d873786b533fa00f16b3f789a7984be61d631d5cd9e3515f7c5e721466 |
examples/principal_signed_body.txt |
427 bytes, sha256 dae0b23f649c05222b955ff4752507c6d85a51e00566da4fea1867e50b3b60cb |
envelope's credentialSubject.communication.bodySha256 |
dae0b23f649c05222b955ff4752507c6d85a51e00566da4fea1867e50b3b60cb — equal to the recomputed digest of the tracked body file |
envelope's bodySize |
427 — equal to the bytes on disk |
| envelope validity window | 2026-05-21T00:00:00Z .. 2026-05-22T00:00:00Z (past; the deep leg's pinned now is 2026-05-21T12:00:00Z, and says so) |
examples/slack_reply_envelope.json |
sha256 b8ffb64d42682db4406cc0c25d4c15f52c23eaff7da456aad5d82c6fb71a3640 — the mode-absent fixture, refusal row 3 |
The golden's body binding is byte-real: the authorized bytes are published
in the aph repo as a tracked file and they hash to the envelope's own digest.
That is what makes the demo's body_b64 carry real rather than notional — the
authorized bytes travel verbatim and the deep leg hashes the bytes it received,
never a re-serialization.
The pin is a pin on purpose. Upstream fixture regeneration or a verification-surface change should break this repository loudly, and bumping the pin is a deliberate reviewed change (CI's drift gate, PRD-001 T15). The demo transcripts read the SHA from whatever clone they actually ran against and say so, rather than asserting a pin they do not enforce.
This repository is exactly the artifact class that expires the aph repo's
pre-production spec exception: it parses and verifies envelopes and carries
attestationMode and APH_E* codes as data. aph's CONTRIBUTING.md asks
consumers to tell the maintainers before shipping anything that asserts
wire facts, not after.
- Notification sent: 2026-08-27, recorded in full at
docs/governance/2026-08-27-preproduction-notification.md. - Four rulings requested: (a) whether BEAM-side
:crypto.hashfor §8.3 step 8 would ever be acceptable (this design avoids it regardless); (b) whether in-org shipping counts as outside-the-repo (we notify regardless); (c) whether reading../aph/examplesgoldens from a downstream repo is the intended consumption pattern versus vendored copies; (d) sign-off on thejido_aphname, while a rename is still cheap. - Erratum reported: upstream
examples/README.mdcallsts_minted_envelope.jsonthe only file with a real body binding and counts the empty-string hash in eleven of twelve files. Recomputed againstf01e3470: the count is ten of twelve, andprincipal_signed_envelope.json's binding is equally real — its 427-byte body is tracked in the repo and hashes to the envelope'sbodySha256. Reported rather than patched, because the aph repo is read-only to this project. - Acknowledgment: RECORDED 2026-08-27. The aph maintainer, who owns this
repository, authorized publication and ruled on question (d) only. Precisely
what that covers:
- Publication authorized — public, in draft status, as
squillo/aph-jido. - (d) Name settled:
aph-jidofor the repository. Leading withaph-rather thanjido_is the point of the change: it names this as APH's integration rather than something a reader could mistake for an agentjido package. The Elixir application and its modules keep the names the code was built with —:jido_aphandJidoAph.*— so repository name and package name deliberately differ. - (a), (b) and (c) remain OPEN. No ruling was given on BEAM-side
:crypto.hashfor §8.3 step 8, on whether in-org shipping counts as outside-the-repo, or on sibling-clone goldens versus vendored vectors. They are recorded as unanswered rather than assumed: the design avoids (a) regardless, and (c) still governs D7, so an answer to it can still change how this repository consumes the corpus.
- Publication authorized — public, in draft status, as
Publish is no longer blocked. Publication is itself the disclosure the notification exists to make: the pre-production exception expires with it. The cross-repo pointer PR (PRD-001 T18) remains open work and is the aph maintainer's to accept or decline.
scripts/hygiene_check.sh enforces two naming
invariants over every git-tracked file. The core job in
.github/workflows/ci.yml runs it unchanged, with
no arguments:
- The retired domain appears nowhere. No allowlist, no exceptions. The script assembles the forbidden string at runtime so its own source never contains it and it cannot pass itself by accident.
did:web:notary.squillo.comappears only where it is quoted and explained — the deep transcript,verify.mjs's keys map, the sidecar and its narration, the tests pinning the key-sourcing story, the PRD, and the key-sourcing section above. The allowlist is an explicit path list with a reason recorded per entry; anywhere else is a failure. The library proper (lib/) is not on that list and does not mention the identifier at all.
scripts/hygiene_check.sh # check this repo
scripts/hygiene_check.sh --self-test # prove the checks actually fail on planted violationsThe self-test builds a throwaway git repo, plants one violation of each rule plus one allowlisted occurrence that must pass, and asserts the checker's verdict on each. A hygiene gate that has never been observed to fail is not evidence of anything.
lib/jido_aph.ex attach/read helpers
lib/jido_aph/signal/ext/notarization.ex the two single-constant identifiers
lib/jido_aph/guard.ex the four-step structural gate
lib/jido_aph/envelope.ex read-only fields, normalized output only
lib/jido_aph/deep_verifier.ex behaviour only — the seam
test/jido_pins/ jido 2.3.3 contracts, pinned by test
test/support/corpus.ex runtime corpus resolution (dep, APH_PATH, config)
docs/a2a-carry-mapping.md the mapping, derived from the pinned wire test
docs/governance/ the pre-production notification
docs/transcripts/ both committed transcripts
scripts/hygiene_check.sh the naming gate, self-testable
.github/workflows/ci.yml the two-job gate; only `core` is required
demo/ two agents, two mix tasks, the TS sidecar
PRDs/PRD-001-Jido-APH-Guard.md the design record this repo implements
Every test in this repository opens with a comment saying why it exists and what it pins. Tampered envelopes are derived in memory at runtime and never committed; the signed fixtures upstream are never text-edited.
The real stdout of the structural leg, committed at
docs/transcripts/demo_run.txt and regenerable
with mix demo.run --out ../docs/transcripts/demo_run.txt from demo/. It
needs no Node and reaches no network. A golden-output test
(demo/test/demo_run_transcript_test.exs) asserts the provenance banner, the
verdict wording, APH_E012, APH_E013, and the honesty footer against real
stdout; the core CI job greps the same strings out of a live run
(.github/workflows/ci.yml). The task is also a gate, not only a story: if
the run departs from what the transcript narrates — a leg refusing at a
different step than it declared, say — it prints a DEVIATIONS block and
exits non-zero rather than publishing a tidy fiction.
The transcript is assembled rather than console-captured, and says so: the
narrative is written by the task process while the guard logs from jido's
signal-call task, so nothing orders the two streams. A custom :logger handler
collects the demo's own events and each leg renders its own, inline.
Full transcript (426 lines)
==============================================================================
jido_aph — mix demo.run
An APH-notarized signal crossing two jido agents, and a gate that refuses to
act without one. Read the honesty footer at the bottom before quoting
anything from the middle.
==============================================================================
[1] PROVENANCE — the exact bytes every claim below rests on
------------------------------------------------------------------------------
Nothing here is vendored. The fixtures are read at runtime from a real aph
checkout (PRD-001 D7) — by default the pinned `:aph` dependency, whose git
`subdir:` brings the whole repository into deps/, examples/ included. The
SHA below is what attributes every claim in this transcript to bytes a
reader can fetch for themselves.
corpus source the :aph dependency's checkout, resolved at
runtime (APH_PATH, or an explicit
:aph_repo_path, override it)
aph HEAD f01e3470f86533c4099db8ab0ab6b155bd0ea4aa
aph worktree clean at HEAD
aph examples/ clean
That SHA is READ from the checkout, not asserted against a pin. mix.exs
does pin the dependency by ref, but this banner reports what it FOUND —
an APH_PATH working tree would print its own SHA here. Failing the build
on fixture drift is CI's job (PRD-001 T15); the demo's job is to say
which bytes it actually ran on.
golden envelope examples/principal_signed_envelope.json
bytes 3382
id urn:uuid:00000000-0000-4000-8000-0000000000f3
aphVersion 0.1
channel.kind slack (closed vocabulary, §7.1.5)
contentClass Reply (closed vocabulary, §7.1.6)
attestationMode PrincipalSigned (§7.1.7)
proof 2-element chain
validFrom 2026-05-21T00:00:00Z
validUntil 2026-05-22T00:00:00Z
authorized body examples/principal_signed_body.txt
bytes on disk 427
envelope claims bodySize 427
envelope claims bodySha256
dae0b23f649c05222b955ff4752507c6d85a51e00566da4fea1867e50b3b60cb
Read those last two lines exactly as written: they are the ENVELOPE's own
words, quoted. bodySize is compared against the bytes on disk and agrees.
bodySha256 is NOT recomputed — not here, and not in any leg below. No
cryptography runs on the BEAM in this demo, and PRD-001 §5 counts hashing
as cryptography.
mode-absent fixture examples/slack_reply_envelope.json
bytes 2148
policy carries NO attestationMode key
An absent attestationMode normatively means NotaryAttested (§7.1.7), so a
PrincipalSigned policy must refuse it rather than quietly accept whatever
the envelope happens to offer. Leg [6] is that refusal; leg [5] forges a
PrincipalSigned label onto this same fixture.
[2] THE GATE — what JidoAph.Guard runs, in this order, before routing
------------------------------------------------------------------------------
S1 §7.1.7.1 envelope byte bound (65536 B),
enforced BEFORE any parse
S2 §8.3 step 1 APH.parse_envelope_json/1 — strict parse, with
unknown fields denied
S3 §8.3.1 step 1a APH.require_attestation_mode/2 — only when a mode
policy is configured (APH_E012)
S4 §7.1.11 APH.verify_proof_structure/1 — ALWAYS, even with
no mode policy, because S3 alone admits a forged
label (APH_E013)
And the six things no leg below runs, at any depth, ever:
N1 Ed25519 signatures over JCS/RFC 8785 (§8.3)
N2 key discovery — DNS TXT / did:web (§8.4)
N3 validity window (60s skew)
N4 revocation / credentialStatus
N5 bodySha256 over the received bytes (§8.3 step 8, APH_E009)
N6 closed channel / contentClass vocabulary (§7.1.5 / §7.1.6)
Each leg names, from what actually happened, which of S1-S4 RAN, which one
REFUSED, and which were NEVER REACHED. N1-N6 are constant: they are what
this whole rail does not do.
Lines marked `log [level]` are verbatim Logger output from JidoAph.Guard
and Demo.Actions.DeliverReply, routed into this narrative so they appear
inside the leg that produced them. jido's AgentServer emits its own [error]
line for a refused prepare_signal hook after it has already replied to the
caller; that line is real, and it is not reproduced here.
[3] LEG 1 — HAPPY PATH: the golden envelope crosses two agents
------------------------------------------------------------------------------
Scribe reads examples/principal_signed_envelope.json — 3382 bytes of
verbatim JSON text — and examples/principal_signed_body.txt, the 427
authorized body bytes. It attaches both to a "slack.reply.requested" signal
with JidoAph.attach_notarization/3 — envelope as text, body as base64 — and
hands the signal to Gateway with Jido.AgentServer.call/2.
Gateway mounts JidoAph.Guard as required: true, require_mode:
"PrincipalSigned", signal_patterns: ["slack.reply.requested"].
The envelope is a PRE-MINTED committed golden. Presenting it asserts nothing
about this run: nothing here was minted, and no human authorized anything.
log [info] aph_guard: notarization-shaped, mode policy satisfied
(PrincipalSigned) — admitting signal "slack.reply.requested"
log [info] deliver_reply: would deliver (no channel adapter)
outcome ADMITTED — call/2 returned {:ok, %Jido.Agent{}}
routed action Demo.Actions.DeliverReply RAN
context.aph, as the routed action received it:
verdict
"notarization-shaped, mode policy satisfied (PrincipalSigned)"
structure_mode "PrincipalSigned"
require_mode "PrincipalSigned"
depth :structural
claims the envelope's own UNVERIFIED claims,
read after the gate, compared against nothing:
envelope_id "urn:uuid:00000000-0000-4000-8000-0000000000f3"
human_principal_did
"did:key:z6MkiaMbhXHNA4eJVCCj8dbzKzTgYDKf6crKgHVHid1F1WCT"
agent_did "did:web:agent.squillo.com"
channel_kind "slack"
`structure_mode` is the mode the proof STRUCTURE supports; `require_mode`
is the policy that was configured. They agree here, and the guard reports
them separately on purpose — a verdict that conflated them would be
claiming the envelope PROVED the policy rather than merely matching it.
`depth: :structural` states what this gate RAN, never what a config
declared.
`claims` is what the DOCUMENT asserts about itself, decoded from the
bytes aph-ex normalized and compared against nothing at all: no clock,
no delivery context, no key, nothing previously seen. N1-N6 below hold
over every one of these four strings. They are reported anyway, and
under their own key, because the alternative shipped for a while and
was worse — with the guard silent about who the envelope names, an
action that wants the principal's DID digs it out of the signal and
labels it nothing.
cross-check APH.verify_proof_structure/1 on the same bytes
returns this identical result, byte for byte:
the guard passed it through untouched, and the
framework's error wrapping did not alter it
gate steps, this leg:
S1 §7.1.7.1 RAN
S2 §8.3 step 1 RAN
S3 §8.3.1 step 1a RAN
S4 §7.1.11 RAN
never run, this or any leg:
N1 signatures N2 key discovery N3 validity window
N4 revocation N5 bodySha256 N6 closed vocabulary
[4] LEG 2 — REFUSAL 1/6: no notarization extension, required: true
------------------------------------------------------------------------------
A "slack.reply.requested" signal with no notarization extension at all —
the ordinary case of an agent that simply does not speak APH.
This is the a2a-extension.md §5 step 5 mirror: where the extension is absent
and the recipient's deployment policy REQUIRES APH, "the endpoint REJECTS
messages from the agent and SHOULD log the rejection for audit" — both
halves, the rejection and the log line, are visible in this block.
log [warning] aph_guard: refusing signal "slack.reply.requested":
notarization extension missing: signal "slack.reply.requested"
carries no APH envelope and this guard requires one (required:
true, a2a-extension.md §5)
outcome REFUSED before routing
routed action Demo.Actions.DeliverReply did NOT run
reason, verbatim, as Jido.AgentServer.call/2 handed it back:
notarization extension missing: signal "slack.reply.requested" carries
no APH envelope and this guard requires one (required: true,
a2a-extension.md §5)
No envelope existed, so no gate step ran and no APH_E code is claimed:
the taxonomy codes belong to protocol rules, and a document that was
never presented reached none of them.
gate steps, this leg:
S1 §7.1.7.1 NOT REACHED
S2 §8.3 step 1 NOT REACHED
S3 §8.3.1 step 1a NOT REACHED
S4 §7.1.11 NOT REACHED
never run, this or any leg:
N1 signatures N2 key discovery N3 validity window
N4 revocation N5 bodySha256 N6 closed vocabulary
[5] LEG 3 — REFUSAL 2/6: a forged PrincipalSigned label (APH_E013)
------------------------------------------------------------------------------
Derived in memory, on this run: examples/slack_reply_envelope.json with a
"PrincipalSigned" attestationMode written over its policy. Its proof is a
single object, which cannot carry that label — §7.1.11 requires the label
and the structure to agree in BOTH directions.
This is the forgery a mode gate ALONE waves through: on these exact bytes
APH.require_attestation_mode/2 answers :ok, because the label does say what
the policy asked for. Only S4 catches the lie, which is why the guard runs
S4 unconditionally.
Nothing derived here is ever written to disk; the committed fixtures are
never edited (PRD-001 §10 gate 4).
log [warning] aph_guard: refusing signal "slack.reply.requested": APH_E013:
proof chain invalid: `attestationMode` is `PrincipalSigned`
but `proof` is a single object; the label MUST accompany a
two-element chain (§7.1.11)
outcome REFUSED before routing
routed action Demo.Actions.DeliverReply did NOT run
reason, verbatim, as Jido.AgentServer.call/2 handed it back:
APH_E013: proof chain invalid: `attestationMode` is `PrincipalSigned`
but `proof` is a single object; the label MUST accompany a two-element
chain (§7.1.11)
S3 on these bytes :ok <- admitted the forgery
S4 on these bytes refused, above
cross-check APH.verify_proof_structure/1 on the same bytes
returns this identical result, byte for byte:
the guard passed it through untouched, and the
framework's error wrapping did not alter it
gate steps, this leg:
S1 §7.1.7.1 RAN
S2 §8.3 step 1 RAN
S3 §8.3.1 step 1a RAN
S4 §7.1.11 REFUSED <- the leg stops here
never run, this or any leg:
N1 signatures N2 key discovery N3 validity window
N4 revocation N5 bodySha256 N6 closed vocabulary
[6] LEG 4 — REFUSAL 3/6: mode-absent vs a PrincipalSigned policy (APH_E012)
------------------------------------------------------------------------------
examples/slack_reply_envelope.json, untampered. Its policy carries no
attestationMode key at all, and an absent mode normatively means
NotaryAttested (§7.1.7) — weaker than the configured PrincipalSigned
policy, so §8.3.1 step 1a refuses it up front instead of silently
downgrading to whatever the envelope offers.
Its structure is sound, so the refusal is the mode policy and nothing else.
log [warning] aph_guard: refusing signal "slack.reply.requested": APH_E012:
attestation mode refused: required `PrincipalSigned`, envelope
is `NotaryAttested`
outcome REFUSED before routing
routed action Demo.Actions.DeliverReply did NOT run
reason, verbatim, as Jido.AgentServer.call/2 handed it back:
APH_E012: attestation mode refused: required `PrincipalSigned`, envelope
is `NotaryAttested`
S4 on these bytes {:ok, "NotaryAttested"} <- the structure is sound
policy.decision "AskEveryTime"
That decision field records the human's STANDING CONFIGURATION — the
policy mode they chose for this channel — and is never the verdict on this
act. §7.1.7 is explicit that it "Records the human's standing
configuration, NOT the verdict on this act", the verdict being carried by
the state the envelope was issued from, so an AskEveryTime envelope
truthfully carries AskEveryTime after the human said yes.
"Implementations that read this field as a per-act decision have shipped
real defects; do not." Nothing in this repo reads it as one: the guard
never looks at decision, and this refusal is about attestationMode alone.
cross-check APH.require_attestation_mode/2 on the same bytes
returns this identical result, byte for byte:
the guard passed it through untouched, and the
framework's error wrapping did not alter it
gate steps, this leg:
S1 §7.1.7.1 RAN
S2 §8.3 step 1 RAN
S3 §8.3.1 step 1a REFUSED <- the leg stops here
S4 §7.1.11 NOT REACHED
never run, this or any leg:
N1 signatures N2 key discovery N3 validity window
N4 revocation N5 bodySha256 N6 closed vocabulary
[7] LEG 5 — REFUSAL 4/6: an unknown envelope field (strict parse, no code)
------------------------------------------------------------------------------
Derived in memory, on this run: the golden with one extra top-level field,
"jidoAphUnknownField". §8.3 step 1 parses STRICTLY, with unknown fields
denied, so a key the protocol never defined is a hard refusal rather than a
silently dropped one.
The refusal carries the parser's own message and no APH_E code — a document
that never parsed reached no protocol rule and may cite none.
log [warning] aph_guard: refusing signal "slack.reply.requested": unknown
field `jidoAphUnknownField`, expected one of `aphVersion`,
`@context`, `type`, `id`, `issuer`, `validFrom`, `validUntil`,
`credentialSubject`, `linkedMandate`, `credentialStatus`,
`proof` at line 1 column 1770
outcome REFUSED before routing
routed action Demo.Actions.DeliverReply did NOT run
reason, verbatim, as Jido.AgentServer.call/2 handed it back:
unknown field `jidoAphUnknownField`, expected one of `aphVersion`,
`@context`, `type`, `id`, `issuer`, `validFrom`, `validUntil`,
`credentialSubject`, `linkedMandate`, `credentialStatus`, `proof` at
line 1 column 1770
cross-check APH.parse_envelope_json/1 on the same bytes
returns this identical result, byte for byte:
the guard passed it through untouched, and the
framework's error wrapping did not alter it
gate steps, this leg:
S1 §7.1.7.1 RAN
S2 §8.3 step 1 REFUSED <- the leg stops here
S3 §8.3.1 step 1a NOT REACHED
S4 §7.1.11 NOT REACHED
never run, this or any leg:
N1 signatures N2 key discovery N3 validity window
N4 revocation N5 bodySha256 N6 closed vocabulary
[8] LEG 6 — REFUSAL 5/6: over the byte bound, refused before any parse
------------------------------------------------------------------------------
Derived in memory, on this run: the golden followed by 70,000 spaces —
73382 bytes against the 65536-byte bound of §7.1.7.1.
The bound exists because canonicalization happens on UNAUTHENTICATED input,
so it has to be enforced before a parser ever sees the bytes. That ordering
is provable from outside the guard: trailing whitespace is legal JSON, so
the strict parser ADMITS these very bytes.
log [warning] aph_guard: refusing signal "slack.reply.requested": envelope
exceeds the 65536-byte bound (73382 bytes); refused before any
parse (spec §7.1.7.1)
outcome REFUSED before routing
routed action Demo.Actions.DeliverReply did NOT run
reason, verbatim, as Jido.AgentServer.call/2 handed it back:
envelope exceeds the 65536-byte bound (73382 bytes); refused before any
parse (spec §7.1.7.1)
S2 on these bytes {:ok, <canonical JSON text>}
^ the parser would take them, so the refusal
above can only be the byte bound
gate steps, this leg:
S1 §7.1.7.1 REFUSED <- the leg stops here
S2 §8.3 step 1 NOT REACHED
S3 §8.3.1 step 1a NOT REACHED
S4 §7.1.11 NOT REACHED
never run, this or any leg:
N1 signatures N2 key discovery N3 validity window
N4 revocation N5 bodySha256 N6 closed vocabulary
[9] LEG 7 — ROW 6/6: a bare signal under required: false, delivered tagged
------------------------------------------------------------------------------
The same bare signal as leg [4], sent to a gateway identical to Gateway in
every respect but one: required: false.
This is the other half of the a2a-extension.md §5 mirror — step 6, the
permissive deployment that "MAY display a 'Not notarized' UI indicator and
proceed to deliver the message under the recipient's existing trust rules".
Delivery happens; it happens FLAGGED.
log [warning] aph_guard: signal "slack.reply.requested" carries no
notarization envelope; passing through tagged unverified
(required: false)
log [info] deliver_reply: would deliver (no channel adapter)
outcome PASSED THROUGH, TAGGED — nothing was examined
routed action Demo.Actions.DeliverReply RAN
context.aph %{notarization: :absent, tag: :unverified}
Note what is not there: no verdict, no "notarization-shaped", no mode.
Nothing was examined, so nothing is claimed. The tag rides the RUNTIME
CONTEXT the receiving guard authored, never the signal — a wire-borne
tag would be sender-suppliable, and is silently stripped on any VM
where the extension namespace is unregistered.
gate steps, this leg:
S1 §7.1.7.1 NOT REACHED
S2 §8.3 step 1 NOT REACHED
S3 §8.3.1 step 1a NOT REACHED
S4 §7.1.11 NOT REACHED
never run, this or any leg:
N1 signatures N2 key discovery N3 validity window
N4 revocation N5 bodySha256 N6 closed vocabulary
[10] HONESTY FOOTER — what this run did not establish
------------------------------------------------------------------------------
Seven signals were gated. One was admitted, five were refused, one was
passed through and tagged. The admission means exactly three things: the
envelope PARSED strictly, its declared attestation mode satisfied the
configured policy without downgrade, and its proof STRUCTURE matched that
label in both directions.
Here is what it does not mean — about any envelope above, the admitted one
included:
N1 No Ed25519 signature was checked. Not the principal's, not the
notary's, not the two on the embedded delegation mandate. Zero
cryptography ran on the BEAM (§8.3).
N2 No key was discovered or resolved: no DNS TXT lookup, no did:web
fetch, no did:key decode. Nothing on this rail resolves anything
(§8.4).
N3 The validity window was never compared against any clock. The golden
declares 2026-05-21T00:00:00Z .. 2026-05-22T00:00:00Z and no leg
above read it.
N4 Revocation was never consulted. The golden carries no
credentialStatus, and no status transport exists in this repo.
N5 bodySha256 was never recomputed over the 427 received bytes. The
digest quoted in the banner
(dae0b23f649c05222b955ff4752507c6d85a51e00566da4fea1867e50b3b60cb)
is the envelope's own claim (§8.3 step 8, APH_E009).
N6 The closed channel and contentClass vocabularies were never enforced.
aph-ex exposes no such op (§7.1.5 / §7.1.6).
The live notary at did:web:aph-notary.squillo.com was not contacted, by
anything, at any point. Nothing above reached the network at all.
aph-ex says it plainly of the one structural op this gate leans on hardest
(../aph/interpreters/elixir/lib/aph.ex, verify_proof_structure/1):
"A successful return says the structure is sound. It says NOTHING
about whether any signature verifies — a caller that reports "the
human signed this" on the strength of this function alone is
reporting a claim no key has backed."
That is the whole reason the verdict reads "notarization-shaped, mode
policy satisfied" and never a word stronger, and the reason the guard's own
log lines above are checked by test for those two bare words.
Cryptographic depth is a different leg, in a second and independent
implementation, and it is not this one:
mix demo.deep_verify (optional, Node >= 20)
==============================================================================
The real stdout of the optional deep leg, committed at
docs/transcripts/demo_deep_verify.txt
and regenerable with mix demo.deep_verify --out ../docs/transcripts/demo_deep_verify.txt
from demo/. It requires Node and the built TS dist/; it still reaches no
network. A :deep-tagged golden-output test
(demo/test/demo_deep_verify_transcript_test.exs) asserts the pinned-now
statement, the wall-clock refusal, the depth-split line, and the key-sourcing
line against real stdout; the optional deep CI job greps the same strings
(.github/workflows/ci.yml).
Two notes on reading it. The bare words "verified" and "signed" appear freely
here and that is correct — this leg earned them, having checked four Ed25519
signatures; the bare-word rule binds the guard's own log lines, which are
extracted and checked separately inside this very transcript. And the wall-clock
refusal is APH_E003 (MandateExpired), a validity-window refusal that arrives
after every signature has already verified — it is proof the pin is
load-bearing, not proof of a signature problem.
Full transcript (407 lines)
==============================================================================
jido_aph — mix demo.deep_verify
The optional second leg. The same golden envelope `mix demo.run` gated
STRUCTURALLY is handed here to an independent implementation of the same
specification — the aph repo's TypeScript verifier, running under node — which
checks every signature. Read [7] and [8] before quoting anything from the
middle: this leg adds a great deal, and there are still three things it does
not do.
==============================================================================
[1] PROVENANCE — the bytes, and the verifier that ruled on them
------------------------------------------------------------------------------
Nothing here is vendored: neither the fixtures nor the verifier. Both are
read at runtime from one aph checkout (PRD-001 D5/D7) — by default the
pinned `:aph` dependency, which carries examples/ and interpreters/
typescript/ alike. The SHA below is what attributes every verdict in this
transcript to bytes a reader can fetch and re-run for themselves.
corpus + verifier one aph checkout, resolved at runtime
aph HEAD f01e3470f86533c4099db8ab0ab6b155bd0ea4aa
aph worktree clean at HEAD
aph typescript/ source clean at HEAD
That last line is about SOURCE, and it is worth saying what it does not
cover: `dist/` is a build artifact, git-ignored upstream, so nothing here
can prove the built JavaScript was compiled from the source at that SHA.
Rebuilding it is one command, named in this task's own failure message,
and CI's :deep job builds it fresh at the pinned SHA (PRD-001 T16).
verifier interpreters/typescript, under node
entry point demo/priv/node/verify.mjs
calls dist/src/verify.js verifyEnvelope (§8.3 + §8.3.1)
dist interpreters/typescript/dist (inside the clone above)
node runtime v26.3.0
The verifier runs in a SUBPROCESS, not on the BEAM. Zero cryptography runs
on the BEAM anywhere in this repository (PRD-001 §5), and aph-ex — the NIF
the guard is built on — exposes four structural operations and no signature
check at all, on purpose and parity-locked.
golden envelope examples/principal_signed_envelope.json
bytes 3382
id urn:uuid:00000000-0000-4000-8000-0000000000f3
aphVersion 0.1
channel.kind slack (closed vocabulary, §7.1.5)
contentClass Reply (closed vocabulary, §7.1.6)
attestationMode PrincipalSigned (§7.1.7)
proof 2-element chain
mandate embedded §6.1 mandate, allowedChannels ["slack"]
credentialStatus absent — §6.3.3.4 case 1, SKIP (not a pass)
authorized body examples/principal_signed_body.txt
bytes on disk 427
envelope claims bodySize 427
envelope claims bodySha256
dae0b23f649c05222b955ff4752507c6d85a51e00566da4fea1867e50b3b60cb
`mix demo.run` quotes that digest and says plainly that it never recomputes
it. This leg does recompute it — inside node, over the body bytes as the
sidecar received them, never over a re-serialization of a parsed object
(§8.3 step 8). Leg [3] reports whether that check actually ran.
[2] THE PIN — the instant every verdict below rests on
------------------------------------------------------------------------------
The borrowed verifier takes `now` as a PARAMETER and reads no clock. Its own
source says why, at the top of src/verify.ts: "this verifier parses bytes it
is handed and NEVER fetches", and separately, of `now`: "Required — this
module reads no clock", because "a library that read its own clock could not
be tested deterministically".
So the caller pins the instant. This task pins it out loud:
pinned now 2026-05-21T12:00:00Z
envelope validFrom 2026-05-21T00:00:00Z
envelope validUntil 2026-05-22T00:00:00Z
inside the window yes — compared on this run, not asserted
clock skew 60 s, the §8.3 RECOMMENDED default
(DEFAULT_CLOCK_SKEW_SECONDS, src/types.ts)
`now` is pinned because the window is fixed, not because time is negotiable.
The instant is not invented either: it is the aph repo's own
GOLDEN_EVALUATION_INSTANT (interpreters/typescript/testkit/golden.ts), so
this demo and its upstream evaluate the same fixture at the same moment.
A verifier that quietly read the wall clock would do two dishonest things
at once: it would pass today and fail tomorrow for reasons no reader could
see, and it would hide which instant a verdict rested on. Leg [5] runs the
same envelope against the real clock and is refused — which is what makes
this pin load-bearing rather than decorative.
[3] LEG 1 — THE GOLDEN, VERIFIED AT THE PINNED INSTANT
------------------------------------------------------------------------------
The whole golden envelope — 3382 bytes of JSON TEXT, unparsed, exactly as
it sits in the clone — plus the 427 authorized body bytes, plus the pinned
instant, plus one key. Handed across a process boundary to verifyEnvelope and
asked for the full §8.3 / §8.3.1 procedure.
require_mode "PrincipalSigned"
now 2026-05-21T12:00:00Z
body bytes supplied yes, 427 — so §8.3 step 8 can run
outcome VERIFIED — verifyEnvelope returned a VerifiedEnvelope
what it reports, in its own field names (src/verify.ts):
attestationMode "PrincipalSigned"
bodyHashChecked true
embeddedMandateChecked true
Those last two are reports of what RAN, not of what is possible.
`bodyHashChecked: true` means §8.3 step 8 hashed the delivered bytes and
they matched the envelope's bodySha256. `embeddedMandateChecked: true`
means the §6.1 mandate embedded in this envelope's policy was checked —
its bindings to this human and this agent, its allowedChannels, its window
around the envelope's own, and BOTH of its signatures.
transport envelope 3382 B, body 427 B
measured by the verifier on the far side of the
process boundary, and equal to what was handed in
Four Ed25519 signatures were checked in total: the principal's and the
notary's over the envelope, and the mandate's principalSignature and
notarySignature over their own §6.1 bases. `mix demo.run` checked none.
verifier steps, this leg:
D1 §7.1.7.1 RAN
D2 §8.3 step 1 RAN
D3 §8.3.1 step 1a RAN
D4 §7.1.11 RAN
D5 §8.3 step 1b-1c RAN
D6 §7.2.1 step 1e RAN
D7 §8.3 steps 2-5 RAN
D8 §8.3.1 step 1d RAN
D9 §8.3 step 6 RAN
D10 §8.3 step 8 RAN
D11 §8.3 step 8a RAN — credentialStatus absent, so SKIP
[4] KEY SOURCING — where each verifying key came from
------------------------------------------------------------------------------
The golden's two proofs are anchored two different ways, and the difference is
the entire §8.4 story — the one this repository does not implement.
One key handed in out of band, one that needs none, and zero fetched.
self-describing 1 (did:key — §8.4.3)
did:key:z6MkiaMbhXHNA4eJVCCj8dbzKzTgYDKf6crKgHVHid1F1WCT#z6MkiaMbhXHNA4eJVCCj8dbzKzTgYDKf6crKgHVHid1F1WCT
supplied out of band 1 (§8.4.4 would fetch; nothing here does)
did:web:notary.squillo.com#key-1
fetched 0
The human principal is a `did:key`: the identifier IS the public key,
base58-encoded behind a multicodec prefix, so it decodes offline and needs
nothing handed in. Its bytes travelled inside the envelope, on the signal,
through the guard, into node.
The notary is a `did:web`. A `did:web` key is published at a .well-known
document a verifier would FETCH (§8.4.4), and this leg fetches nothing — so
its key arrives as a parameter. Those bytes are neither invented nor
transcribed into this repository: demo/priv/node/verify.mjs imports
RFC8032_TEST_3 from the aph clone's own testkit — the published RFC 8032
§7.1 TEST 3 vector that examples/README.md names as the notary's seed
throughout that corpus. Anyone can re-derive it; nobody has to trust this
repository for it.
did:web:notary.squillo.com#key-1 is a FIXTURE IDENTIFIER.
It was not resolved, not fetched and not contacted, here or anywhere else
in this repository. A verdict above rests on a key a human handed the
verifier, and saying so is the whole purpose of this section.
The two lists together are the entire proof chain, because there is no
third source: resolveVerifyingKey (src/verify.ts) decodes a did:key or
reads the supplied map, and throws AphKeyUnavailableError for anything
else — so a method resolved by some other route could not have reached
this report.
[5] LEG 2 — THE WALL-CLOCK BEAT: the same envelope, right now
------------------------------------------------------------------------------
The same bytes, the same key, the same everything — with `now` read from this
machine's clock instead of pinned.
now 2026-08-28T02:47:15.136804Z <- varies every run
envelope window 2026-05-21T00:00:00Z .. 2026-05-22T00:00:00Z
outcome REFUSED on the validity window
code APH_E003
the verifier's own words, verbatim:
APH_E003 (MandateExpired): evaluated at 2026-08-28T02:47:15.136804Z,
outside the envelope window 2026-05-21T00:00:00Z .. 2026-05-22T00:00:00Z
Read that as the good news it is. The golden's window closed on
2026-05-22, so a fixture minted in May cannot be presented as authority
in August. §8.3 step 6 is exactly the rule that says so, with 60 seconds
of skew tolerance and not a day more.
It is also the proof that leg [3]'s pin is doing work. If `now` were
ignored, or defaulted, or quietly widened, this call would have returned
the same VerifiedEnvelope as leg [3]. It did not. The pin is stated in
section [2] rather than hidden precisely so this refusal can be shown
next to it instead of explained away.
Note what this refusal is NOT: it is not a signature problem. §8.3 step 6
runs at D9, after every signature in the document has already verified —
the enumeration below says so.
verifier steps, this leg:
D1 §7.1.7.1 RAN
D2 §8.3 step 1 RAN
D3 §8.3.1 step 1a RAN
D4 §7.1.11 RAN
D5 §8.3 step 1b-1c RAN
D6 §7.2.1 step 1e RAN
D7 §8.3 steps 2-5 RAN
D8 §8.3.1 step 1d RAN
D9 §8.3 step 6 REFUSED <- the leg stops here
D10 §8.3 step 8 NOT REACHED
D11 §8.3 step 8a NOT REACHED
[6] LEG 3 — THE DEPTH-SPLIT BEAT: one envelope, two verdicts
------------------------------------------------------------------------------
Derived in memory, on this run, and written nowhere: the golden with the
principal's proofValue replaced by the NOTARY's own. That is not a corrupted
field — it is a genuine 64-byte Ed25519 signature in the same multibase
base58btc spelling, over the wrong bytes. Nothing about the document's SHAPE
is wrong: two-element proof chain, both DataIntegrityProof/eddsa-jcs-2022,
both verificationMethods untouched, the PrincipalSigned label still matching
the carriage §7.1.11 requires for it.
The only thing wrong with it is that the human never signed it.
Committed fixtures are never text-edited and derived negatives are never
committed (PRD-001 §10 gate 4).
HALF ONE — every check JidoAph.Guard runs, run on these exact bytes:
S1 §7.1.7.1 2736 B under the 65536 B bound: PASSES
S2 §8.3 step 1 strict parse: PASSES
S3 §8.3.1 step 1a mode gate: :ok
S4 §7.1.11 proof structure: {:ok, "PrincipalSigned"}
...and the composite, through the agent stack the demo actually ships. The
real Demo.Agents.Gateway, mounting JidoAph.Guard with required: true and
require_mode: "PrincipalSigned", was started and handed the forgery on a
"slack.reply.requested" signal:
log [info] aph_guard: notarization-shaped, mode policy satisfied
(PrincipalSigned) — admitting signal "slack.reply.requested"
log [info] deliver_reply: would deliver (no channel adapter)
call/2 returned {:ok, %Jido.Agent{}} — ADMITTED
routed action Demo.Actions.DeliverReply RAN
context.aph.verdict
"notarization-shaped, mode policy satisfied (PrincipalSigned)"
The message would have gone out.
HALF TWO — the same bytes, this leg:
outcome REFUSED
code APH_E011
the verifier's own words, verbatim:
APH_E011 (PrincipalSignatureInvalid): the principal proof did not verify
over its §7.2.1 base (the envelope with the notary proof discarded,
`proof` kept as a ONE-ELEMENT ARRAY, and the principal's own proofValue
emptied)
control still verifies (attestationMode "PrincipalSigned")
the untampered golden, same call, same instant, run
after the forgery — so the refusal above is the
forgery's doing and not the harness's
One envelope. The structural gate admitted it and the action ran; the
signature check refused it. Both are correct, and the guard was never
wrong, because the guard never claimed this: its verdict says
"notarization-shaped, mode policy satisfied" and stops there, and aph-ex
says of the very operation it leans on hardest that a successful return
"says NOTHING about whether any signature verifies".
This is why structural validity is never called verification.
It is also why a deployment running only the guard is not thereby broken:
it is a deployment that has chosen depth 0-1, and the honest thing is to
say which depth ran, in the log line, every time.
verifier steps, this leg:
D1 §7.1.7.1 RAN
D2 §8.3 step 1 RAN
D3 §8.3.1 step 1a RAN
D4 §7.1.11 RAN
D5 §8.3 step 1b-1c REFUSED <- the leg stops here
D6 §7.2.1 step 1e NOT REACHED
D7 §8.3 steps 2-5 NOT REACHED
D8 §8.3.1 step 1d NOT REACHED
D9 §8.3 step 6 NOT REACHED
D10 §8.3 step 8 NOT REACHED
D11 §8.3 step 8a NOT REACHED
[7] WHAT THIS LEG ADDS over the structural gate
------------------------------------------------------------------------------
The eleven steps below are what verifyEnvelope really runs, in the order
src/verify.ts runs them. D1-D4 are the same four JidoAph.Guard runs — the
value there is not novelty but INDEPENDENCE: a second implementation, in a
second language, reaching the same structural verdict. D5-D11 are the ones
the guard cannot reach at all, because no cryptography runs on the BEAM.
D1 §7.1.7.1 envelope byte bound, before any parse
D2 §8.3 step 1 strict parse, unknown fields denied — and, unlike the
guard's S2, the CLOSED channel and contentClass
vocabularies of §7.1.5 / §7.1.6 enforced here
D3 §8.3.1 step 1a attestation-mode policy (APH_E012)
D4 §7.1.11 label versus proof structure, both directions
(APH_E013)
------------------ everything below this line is new -----------------
D5 §8.3 step 1b-1c the PRINCIPAL's Ed25519 signature over its §7.2.1
base — the envelope with the notary proof discarded,
`proof` kept as a ONE-ELEMENT ARRAY, and the
principal's own proofValue emptied (APH_E011)
D6 §7.2.1 step 1e issuance order: the notary's decisionTimestamp, then
the principal's proof, then the notary's — each
signature covering only bytes that existed when it
was made (APH_E013)
D7 §8.3 steps 2-5 the NOTARY's Ed25519 signature over its own §7.2.1
base (APH_E001)
D8 §8.3.1 step 1d the embedded §6.1 delegation mandate: bound to this
human and this agent, its allowedChannels covering
this channel, its window enclosing the envelope's,
and its OWN two signatures — principalSignature
(APH_E011) and notarySignature (APH_E006)
D9 §8.3 step 6 the validity window against the pinned `now`, 60 s
skew (APH_E003)
D10 §8.3 step 8 bodySha256 recomputed over the body bytes AS
RECEIVED — never over a re-serialization of a parsed
object, because two JSON texts that parse equal can
hash differently (APH_E009)
D11 §8.3 step 8a credentialStatus. Absent here, so §6.3.3.4 case 1:
SKIP. Present would be case 2 — REFUSED, APH_E008 —
because a verifier with no status transport must not
let an attacker who can break the status check
thereby choose that it is skipped
All canonicalization is JCS / RFC 8785. Four Ed25519 signatures in total,
and every one of them sits in `mix demo.run`'s not-checked column.
[8] WHAT IT STILL DOES NOT DO
------------------------------------------------------------------------------
Three things, and none of them is an oversight. Each is a network act, and
this whole rail is offline by construction.
X1 NETWORK KEY DISCOVERY (§8.4). The borrowed verifier never fetches;
that is the first sentence of its own source. Nothing resolved a
did:web document, queried a DNS TXT record, or opened a socket. This
is exactly why the notary's key had to be handed in as a parameter —
section [4] names it, counts it and says where its bytes came from,
rather than letting a verdict quietly rest on an anchor nobody
declared.
X2 REVOCATION (§6.3.3). No status was consulted, because no status
transport exists in this repository at all. The golden carries no
credentialStatus member, so D11 skipped — correctly, and by the
specification's own trichotomy. An envelope that DID carry one would
be refused APH_E008 here, never waved through.
X3 THE LIVE NOTARY. did:web:notary.squillo.com#key-1 is a
fixture identifier and was never contacted. The live surface
did:web:aph-notary.squillo.com was not contacted either, by anything,
at any point.
And one more, which is not about depth at all: nothing here was MINTED. The
envelope is a pre-minted committed golden — 3382 bytes that were sitting
in the clone before this task started — so running it asserts nothing
whatsoever about whether a human authorized anything today. What it asserts
is that the signatures on a document from May 2026 are the signatures that
document claims, evaluated at an instant this transcript prints in full.
[9] HONESTY FOOTER — what to say, and what not to, about this run
------------------------------------------------------------------------------
Say this: the golden envelope independently passes full offline §8.3 in a
second implementation of the specification — all four Ed25519 signatures
over their RFC 8785 canonical bases, issuance order, the embedded mandate's
bindings and both of its signatures, the validity window against a PINNED
instant that this transcript prints, and bodySha256 recomputed over the body
bytes as received — with exactly one key supplied out of band, named in
section [4].
Do not say this: that any key was discovered, that any revocation status was
checked, that any notary was contacted, or that anything was minted.
And do not read leg [6] backwards. The structural gate admitted a forgery and
was not thereby wrong: it answered the question it was asked, in the words it
was allowed to use. The failure mode this repository is built to avoid is not
a shallow check — it is a shallow check DESCRIBED as a deep one.
The structural leg, which needs no Node and reaches no network:
mix demo.run
==============================================================================
Draft, published for review. Public since 2026-08-27 at
squillo/aph-jido. Draft means what it
says: no release is tagged, no version is published to Hex, nothing here has
been reviewed by anyone outside the project, and the library API may change
without notice. It is published to be read and argued with, not to be depended
on.
.github/workflows/ci.yml carries both jobs — core gate (no Node) and deep leg (optional, Node >= 20) — and both passed on the first run Actions ever
executed for this repository:
run 33126658704,
commit 00495e7, 2026-08-27.
That sentence is worth stating precisely, because it was false until the moment
it wasn't. Until the publishing push there was no remote, so every gate
described above rested on a local replay of the workflow's own steps — real
evidence, but not the thing the workflow claims. The run above is the thing
itself: the :aph dependency resolved at the pinned SHA, the fixture digests
matched, mix demo.run was executed on a machine with no Node and its
transcript grepped, and the optional deep job built the TypeScript
implementation from that same dependency and ran the :deep suite against it.
Require the core check and nothing else: deep declares no needs: and
nothing declares needs: deep, so it can be red, skipped, or deleted without
touching the Node-free pipeline.
Licensed Apache-2.0 (LICENSE), matching
squillo/aph. The guard and the extension are
meant to be copied into other people's agents; a license that permits that is
the point.
See PRD-001 for scope, decisions, and the tasks that built this. The non-goals there are load-bearing, not throat-clearing: no new aph-ex ops, no cryptography in Elixir, no real A2A HTTP, no Slack delivery, no key discovery, no revocation, no live notary, no minting, no hex.pm publish.