One Go module for Agent Passport identity and the agent-event NDJSON envelope, so every service in the stack speaks the same wire language instead of reimplementing it.
agent-stack-go is the public, importable home of the wire types every Go
service in the TAIPANBOX agent-governance stack (Wardryx, Mockryx, and
future siblings) needs to speak the same identity and event language as
TokenFuse, Idryx, Qryx, and Engram. Idryx's equivalents live under
internal/ and cannot be imported outside that repo, which is why this
module exists: one shared source, not six drifting copies.
The stack this module supports is a defensive, self-protection system: it exists so an organization running AI agents can govern and audit its own agents, never to attack, surveil, or act against anyone else.
agent-stack-go is the shared-contract plane of the TAIPANBOX agent-governance stack: the Go bindings for Agent Passport identity and the agent-event NDJSON envelope that the stack's other Go services import instead of reimplementing.
flowchart TB
Agent["AI agent (any framework)"] -->|"LLM call (base-URL swap)"| TF["TokenFuse proxy: spend + enforcement"]
TF -->|"POST /v1/decide (PEP)"| WX["Wardryx: policy PDP"]
WX -.->|"allow / deny / hold"| TF
TF -->|"cheapest model, budget OK"| LLM[("LLM provider")]
TF -->|"CallRecords"| CL["TokenFuse Cloud: control plane, incidents, replay, evidence, kill-switch"]
VCX["Vouchryx: delegation proved, and endable"] -->|"short-lived token: act + cnf"| TF
TF -.->|"polls /v1/revocations"| VCX
VCX ==>|"delegation_issued / denied / revoked"| BUS
TF ==>|"agent-event NDJSON"| BUS{{"agent-event bus + Agent Passport"}}
WX ==> BUS
Agent -->|"web fetch"| SCX["Scopyx: governed web egress"]
SCX -->|"POST /v1/decide"| WX
SCX ==>|"web_fetch / web_blocked"| BUS
ENG["Engram: memory"] -->|"reflect via base_url"| TF
ENG ==> BUS
BUS ==> IDX["Idryx: identity graph, detectors, Agent-BOM"]
IDX ==>|"identity_finding"| BUS
BUS ==> QX["Qryx: crypto / PQC, passport + hash-chain scan"]
QX ==>|"crypto events"| BUS
BUS ==> VX["Verdryx: quality / drift"]
VX ==>|"quality events"| BUS
TF -->|"outcome-tagged traces"| VX
MX["Mockryx: pre-prod safety rehearsal"] -->|"hostile scenarios"| TF
MX ==>|"sim events"| BUS
BILL[("cloud, SaaS and model bills")] --> CC["CostCrew: the bill, worked by a crew of agents"]
CC ==>|"spend_spike / budget_threshold / crew moves"| BUS
BUS ==> TRX["Trailryx: the record plane, sealed and packed"]
BUS ==> HX["reads the log, mails you (heraldyx)"]
HX -->|"one mail, a view and never an action"| OPS["your mailbox"]
HX ==>|"alert_sent"| HJ[("heraldyx's own hash-chained journal, not this bus")]
YOU(["you, in a browser over your own tunnel"]) --> GX[["Genaryx: the console over all of it"]]
GX -->|"signed commands: the kill, an approval, a policy"| CL
GX -->|"signed commands"| WX
GX ==>|"console_command"| BUS
GX -.->|"reads it"| IDX
GX -.->|"reads it"| QX
GX -.->|"reads it"| VX
GX -.->|"reads it"| MX
GX -.->|"reads it"| ENG
GX -.->|"reads it"| SCX
GX -.->|"reads it"| CC
TFP["terraform-provider-taipan"] -->|"budgets + passports as code"| CL
ASG[["agent-stack-go: shared Go contract"]] -.->|imported by| IDX
ASG -.->|imported by| WX
ASG -.->|imported by| MX
ASG -.->|imported by| TFP
ASG -.->|imported by| HX
ASG -.->|imported by| QX
SPEC[["agent-passport: the spec"]] -.->|governs| BUS
- Consumes: the agent-passport spec, which its
passportandeventpackages conform to (each checked by its own schema conformance test, against the canonical schema rather than against a copy of our own beliefs). - Produces: shared Go types for the Agent Passport document, the agent-event NDJSON envelope, and delegation-chain validation.
- Talks to: imported by Idryx, Wardryx, Mockryx, Qryx, heraldyx, scopyx, terraform-provider-taipan, vouchryx and CostCrew, so all nine speak the same identity and event language as TokenFuse and Engram, which reach it through their own languages.
The full stack is TokenFuse (spend), Wardryx (policy), Vouchryx (delegation), Engram (memory), Idryx (access), Qryx (crypto), Verdryx (quality), Mockryx (pre-prod), scopyx (governed web egress), CostCrew (the bill), Trailryx (the record) and heraldyx (the mail out), on the shared Agent Passport + agent-event contract (agent-stack-go / agent-passport), configured via terraform-provider-taipan and driven from Genaryx, the console over all of it.
Run the whole open stack locally with one command via stack-up; the stack's home on the web is it-rat.com.
Four packages, one contract, stdlib plus two direct dependencies:
github.com/gowebpki/jcs (the RFC 8785/JCS canonicalization the prev_hash
integrity chain hashes over in event; canonical JSON is precisely the kind
of wheel not to hand-roll) and github.com/santhosh-tekuri/jsonschema/v6
(JSON Schema validation, used by the agent-conform command and the
conformance tests, never by a library package):
| Package | Wire schema | What it defines |
|---|---|---|
passport |
taipanbox.dev/agent-passport/v0.1 and /v1.0 (v1.0 closes the document's top level, SPEC 6.4.1; Parse accepts both) |
the Agent Passport document: identity, owner, runtime, provisioning parent, attestation posture |
event |
taipanbox.dev/agent-event/v1.0 (v0.1, v0.2 and v0.3 still accepted, SPEC 6.4.1) |
the agent-event NDJSON envelope, plus an append-only Writer, tolerant Scan/ReadFile readers, and the ChainedWriter/VerifyChain SPEC 6.5 prev_hash integrity chain (Canonicalize/ChainHash) |
chain |
n/a (a v0.2 normative rule) | delegation-chain helpers: acyclic, root-first, capped at chain.MaxDepth (32) entries |
delegation |
RFC 8693 + RFC 9449 + RFC 9068 | the delegation TOKEN: signing, offline verification against a held key set, DPoP sender-constraint, and the act chain. And an agent's OWN access token (typ at+jwt): VerifyAccess checks it and its proof with tokenfuse's rules (exact aud required, 30 s leeway, ath), VerifyRelayedAccess checks one relayed without its proof, and ReplayCache makes a proof single use across the doors of a process; the two languages run one shared table of cases, delegation/testdata/agent-token-vectors.json. The DPoP replay cache is in memory, bounded by twice the proof freshness window in the worst case, and a restart forgets it, so a captured proof could be replayed once in the window right after. vouchryx issues with it, and Revocations is the revocation cache an enforcement point fills Options.Revoked from. No enforcement point checks with it yet: nothing in the estate calls Verify on a request path, and wiring the doors is its own wave. Standard library only |
| Field | JSON key | Type | Required | Notes |
|---|---|---|---|---|
Schema |
schema |
string |
yes | one of SchemaV01, SchemaV02, SchemaV03 or SchemaV10 |
TS |
ts |
string |
yes | timestamp, not shape-validated by Unmarshal |
Source |
source |
string |
yes | the emitting service |
Type |
type |
string |
yes | the event type |
AgentID |
agent_id |
string |
yes | agent:// URI of the acting agent |
Severity |
severity |
string |
no | info · low · medium · high · critical |
RunID |
run_id |
string |
no | correlates events within one run |
OnBehalfOf |
on_behalf_of |
[]string |
no | the delegation chain (see package chain) |
Data |
data |
map[string]any |
no | the event payload |
PrevHash |
prev_hash |
string |
no | the SPEC §6.5 integrity-chain link; stamped by ChainedWriter, verified by VerifyChain |
Unmarshal returns a sentinel error (ErrMissingSchema, ErrMissingTS,
ErrMissingSource, ErrMissingType, ErrMissingAgentID) for any missing
required field, checkable with errors.Is. Struct fields are declared in
wire order, so json.Marshal's output matches the Rust
(tokenfuse-core::agent_event) and Python (engram.events) exporters
shipping elsewhere in the stack.
ChainedWriter is Writer plus the chain: every appended event carries
sha256: + hex(sha256(C)) of the PREVIOUS event, where C is its RFC 8785
(JCS) canonical serialization with the prev_hash field removed. One
file is one chain, whoever writes it: every Write takes an exclusive
flock(2) on the file, reads the last line actually on disk, stamps
prev_hash from that line, appends and only then unlocks. So a process
restart resumes the chain, and so do writers that share one file at the
same time: a pod and its replacement during a rollout, a service and its
CronJob, several goroutines. A writer killed mid-line leaves a fragment
the next event is not glued onto (the fragment is one malformed line and
the event starts a fresh chain, a restart and not a break), and an
unreadable or malformed tail starts fresh, fail-open, with a verifier
showing the restart honestly. On a Linux NFS share (a Kubernetes RWX
volume) the kernel arbitrates the lock at the server, so it holds across
nodes; on Windows there is no cross-process lock, only the re-read. Canonicalize/ChainHash are
the exported primitives; VerifyChain walks a stream and reports
genuine breaks separately from legal restarts and unverifiable links (a
rotated segment's first line, or the line after a malformed one).
agent-conform -chain <file> runs the same verification from the CLI,
alongside the on_behalf_of delegation check described under
agent-conform: two different chains,
reported apart.
The chain is tamper-EVIDENCE, not tamper-proof: whole-file rewrites can
re-chain; partial edits, truncation and reordering no longer pass
silently. Cross-language pinned vectors live in
event/testdata/chain-vectors.json; the Rust (tokenfuse) and Python
(engram, verdryx) emitters pin the same bytes.
| Field | JSON key | Type | Required | Notes |
|---|---|---|---|---|
Schema |
schema |
string |
yes | must equal RequiredSchema |
ID |
id |
string |
yes | agent:// URI, checked by ValidateAgentURI |
Owner |
owner |
string |
yes | the owning team or human |
DisplayName |
display_name |
string |
no | |
Runtime |
runtime |
string |
no | |
Parent |
parent |
string |
no | static provisioning parent |
Attestation |
attestation |
*Attestation |
no | how the id is bound to a workload |
Labels |
labels |
map[string]string |
no | |
CreatedAt |
created_at |
string |
no |
Attestation.Method is one of none · oidc · spiffe-svid ·
enclave-key · mtls-cert · dpop-key; Attestation.Detail is a
method-specific reference (a SPIFFE ID, an issuer URL, an RFC 7638 key
thumbprint, …). The enum itself lives in the vendored schema, which
scripts/schemas-in-sync.sh holds byte-identical to agent-passport's copy;
this line is a mirror of it and the schema is what validates.
| Function | Signature | Behavior |
|---|---|---|
LoadDir |
LoadDir[T any](dirOrGlob string, parse func([]byte) (T, error), id func(T) string) ([]T, Report, error) |
reads every file under a directory/glob/literal path in sorted order, decoding each with parse; a file that fails parse is counted in Report.Malformed and skipped, never fatal; duplicate id keys keep the first occurrence in sorted-path order |
LoadDir is generic over the parsed type, not tied to Passport: pass
passport.Parse and an ID-extractor for a batch of Passport documents
(the shape Wardryx's internal/passports and Idryx's
internal/ingest/passport both need), or any other func([]byte) (T, error)
for a different kind of file batch.
| Function | Signature | Behavior |
|---|---|---|
Append |
Append(chain []string, principal string) ([]string, error) |
returns a new chain with principal appended; never mutates the input; ErrCycle if already present, ErrTooDeep past MaxDepth |
Validate |
Validate(chain []string) error |
checks acyclic, ≤ MaxDepth (32), every entry an agent:// or user:// URI |
A nil or empty chain is valid: per the spec, it means the agent acts autonomously.
The cap counts entries, and the root is one of them. SPEC 5.1 reads
"Maximum chain depth is 32 entries", so a chain that starts with a human has
room for 31 agents after them. Package delegation builds the chain out of an
RFC 8693 token, where the subject is deliberately not an actor, so it holds the
same number as delegation.MaxActorsWithSubject and derives it rather than
retyping it. A producer that applies the 32 to the actor list alone emits a
33-entry chain that Validate, both envelope schemas and agent-conform all
refuse.
go get github.com/TAIPANBOX/agent-stack-go/v2@v2.0.0Pin to a tagged release, not to @latest and never to a local replace
(see Versioning).
That line above serves anybody already writing Go. It served nobody who simply
wanted to check a payload once, which is the more common reason to touch this
repository at all: agent-conform decides whether a passport or an event stream
conforms, and needing to install a compiler to run a checker is backwards.
Prebuilt binaries for Linux and macOS on x86_64 and arm64, and for Windows on x86_64, are
published on the Releases page
for every v* tag, with a SHA256SUMS beside them.
The asset names carry no version, so releases/latest/download/<name> is a
permanent address for the current build. You never look up a version number,
and a link to one of these does not rot.
P=$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
U=https://github.com/TAIPANBOX/agent-stack-go/releases/latest/download
curl -fsSLO $U/agent-conform_$P.tar.gz
curl -fsSLO $U/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing
tar -xzf agent-conform_$P.tar.gz
./agent-conform_$P/agent-conform -version
./agent-conform_$P/agent-conform passport.json
./agent-conform_$P/agent-conform -chain events.ndjsonThe version is still there, in the binary rather than in the filename:
-version prints the tag it was built from. That is the harder of the two
places to fake, since anything between us and you can rename a file.
Downloading the binary and building it yourself are not a choice between trust and effort. They produce an identical file, so you can take the fast path and still have somebody verify it afterwards. That matters more here than in most places: this tool's output is a verdict about your system, and a verdict is worth what its checker is worth.
# ONE tag on both sides. `agent-conform -version` prints the one you have.
V=v0.5.0
# ours, unpacked ANYWHERE EXCEPT the checkout (see the warning below)
mkdir -p /tmp/verify && cd /tmp/verify
curl -fsSLO https://github.com/TAIPANBOX/agent-stack-go/releases/download/$V/agent-conform_darwin_arm64.tar.gz
tar -xzf agent-conform_darwin_arm64.tar.gz
# yours, from a clean tree
cd /path/to/your/agent-stack-go
git checkout $V
git status --porcelain # must print nothing at all
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath \
-ldflags "-s -w -X main.version=$V" -o /tmp/verify/mine ./cmd/agent-conform
sha256sum /tmp/verify/mine \
/tmp/verify/agent-conform_darwin_arm64/agent-conform
# macOS ships shasum -a 256 rather than sha256sum, and this example builds for
# darwin, so that is probably the one you wantTwo identical digests. Measured on 5 August 2026 from a fresh clone against the
real v0.5.0 assets, cross-compiled on an Ubuntu runner and rebuilt on macOS,
and rebuilt again on 6 August 2026 with Go 1.26.5 to the same value:
fe8ccb5e1606129818981bfd6a7369a5a9dbc640bbec40640762d8d01c888ff5.
Both sides name one tag, and that is why $V is in the recipe rather than
latest. The download line used to say releases/latest/download, which was
true for about an hour: v0.5.0 and v0.5.1 were tagged the same day, latest
moved, and the recipe went on telling you to rebuild v0.5.0 and compare it
against the v0.5.1 archive. Those two binaries differ by construction, since
-X main.version stamps the tag into the bytes, so anybody following it got a
mismatch and a good reason to think we were lying. latest is the right address
for INSTALLING, where you want whatever is current; it is the wrong one for
VERIFYING, where the whole job is comparing like with like. The digest above is
v0.5.0 because that is the pair that was measured end to end. Set V to the
tag you actually installed and the recipe holds; the number it prints is then
yours to compare, not one to look up here.
Build from a clean tree, and do not unpack our archive inside it. Go stamps
vcs.modified into the binary, and one untracked file anywhere in the
checkout flips it to true, which changes the bytes. The release is built from
a clean CI checkout, so it carries vcs.modified=false. Unpacking the download
into the repository before building is enough to break the comparison on its
own: measured, fe8ccb5e… clean versus ae64cf9b… with a single untracked
file beside it. git status --porcelain is in the recipe for that reason and
is not decoration.
The same trap has a second door. Building from a git archive extraction or a
detached git worktree leaves Go unable to read the VCS at all, so it stamps no
revision and records the module as (devel). Both doors lead to a binary that
legitimately differs from the release, and the difference looks enormous because
a version string one byte shorter shifts everything after it. It is one field,
not a different program.
Compare the binaries, not the archives. SHA256SUMS on the release page
lists the .tar.gz and .zip files, and it answers a different question: did
your download arrive intact. It cannot answer this one, because tar and
gzip stamp times into the archive, so the archive is not reproducible even
when every byte of the binary inside it is. An earlier version of this recipe
said to compare mine against SHA256SUMS directly. Anybody who followed it
got two digests that could never match and a good reason to think we were
lying.
Three flags make that work, CGO_ENABLED=0, -trimpath and -s -w, and losing
any one would break it silently: the build would still succeed and only
somebody trying to verify us would find out. So on every push CI does two things
(scripts/reproducible-build.sh): it reads the build command out of the release
workflow and refuses if any of the three flags is missing there, and it builds
the same source in two directories of different lengths and refuses if a byte
differs. The first of those exists because the flags have to agree in two files,
and until 5 August 2026 only one of the two was ever checked. The same three
flags were measured
against real published artifacts in the sibling repositories qryx and idryx on
5 August 2026, each rebuilding to its release byte for byte from a different
host OS.
A different Go version will not reproduce these bytes either. go.mod pins the
toolchain, and a digest is only meaningful beside the compiler that made it.
Every release is signed keyless with Sigstore and carries a build-provenance
attestation and an SBOM. With cosign and gh installed:
tag=<tag>
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
--certificate-identity "/TAIPANBOX/agent-stack-go/.github/workflows/release.yml@refs/tags/${tag}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
sha256sum -c SHA256SUMS
gh attestation verify agent-conform_darwin_arm64.tar.gz -R TAIPANBOX/agent-stack-goReleases through v1.0.0 have none of this; the tags say so.
package main
import (
"fmt"
"github.com/TAIPANBOX/agent-stack-go/v2/event"
"github.com/TAIPANBOX/agent-stack-go/v2/passport"
)
func main() {
e := event.Event{
Schema: event.SchemaV02,
TS: "2026-07-09T03:12:44.100Z",
Source: "wardryx",
Type: "policy_deny",
AgentID: "agent://acme-bank.example/support/tier1-bot",
Severity: event.SeverityHigh,
RunID: "run-8842",
Data: map[string]any{"reason": "budget_exceeded"},
}
line, err := event.Marshal(e)
if err != nil {
panic(err)
}
fmt.Println(string(line))
p, err := passport.Parse([]byte(`{
"schema": "taipanbox.dev/agent-passport/v0.1",
"id": "agent://acme-bank.example/support/tier1-bot",
"owner": "user://acme-bank.example/support-team"
}`))
if err != nil {
panic(err)
}
fmt.Println(p.ID, p.Owner)
}go install github.com/TAIPANBOX/agent-stack-go/v2/cmd/agent-conform@v2.0.0
agent-conform passport.json events.ndjson(It landed after v0.2.0 and shipped in v0.4.0, so a tag is the right way
to install it now, per Versioning.)
The standalone conformance checker: validates Passport documents and
agent-event NDJSON streams against the canonical JSON Schemas, the check
agent-passport's own README names as not existing yet ("conformance is
verified per-repo... by hand"). Stricter than passport.Parse/
event.Unmarshal (which only enforce required-field presence) -- full
schema validation, including patterns like the agent:// URI grammar and
prev_hash's exact 64-hex-char form. Each file is classified by its own
schema field, not extension, mirroring the same convention every
connector in the stack already uses. Exit code 0 means every file (and
every line within an event stream) conforms; 1 means at least one did not.
-chain checks the two things "chain" means in this spec, and reports them
apart because they answer different questions:
| Check | Rule | What a failure means |
|---|---|---|
prev_hash integrity chain |
SPEC 6.5, via event.VerifyChain |
the file was altered, truncated or reordered after it was written |
on_behalf_of delegation chain |
SPEC 5.1, via chain.Validate: acyclic, at most 32 entries, every entry an agent:// or user:// URI |
the identity claim inside the event never made sense, which points at the emitter rather than at whoever has held the file since |
A stream can pass either one and fail the other, which is the reason for two verdicts rather than one. Root-first ordering is deliberately not claimed: it is a property of how a chain was built, one entry appended per hop, and a list of URIs after the fact carries nothing that could distinguish a root-first chain from a reversed one.
agent-conform -chain <file> names a broken prev_hash chain, but only to
whoever ran it. On the 2026-09-17 appliance run one byte flipped on a sealed
line of the shared events bus was seen by nothing, because the tool existed as
source and archives and nothing on the box ran it. watch-dir is the same
verification (event.VerifyChain, the library's own) as a mode that runs on a
schedule and alerts: a break becomes an event on the bus, where the
notifier and the console already look.
agent-conform watch-dir [-out PATH] [-state PATH] [-every 60s] [-checkpoints DIR -checkpoint-keys FILE] <dir>It walks every *.ndjson in ONE flat directory (the bus is flat; a
subdirectory is not part of it, and a symlink is skipped, never followed) and
verifies each file's chain from its first line. Per file it reports at most one
thing, as one agent-event (schema v1.0, source agent-conform) appended to
its own stream with the library's own ChainedWriter:
| Event | Severity | When | data |
|---|---|---|---|
chain_broken |
high | a prev_hash does not match the line before it |
file, line (the first such line), kind (prev_hash_mismatch), breaks (how many), restarts, malformed_lines, unverifiable_links, expected/found (clipped), verifier |
chain_unchained |
low | two or more events and not one carries a prev_hash |
file, kind (no_prev_hash), events, malformed_lines, unverifiable_links, verifier |
chain_restarted |
medium, or high when no resume checkpoint explains it | a head line after the first: the chain began again | file, kind (restart), line, after_line (the last well-formed event before it, 0 for none), explained, malformed_lines, unverifiable_links, verifier |
A restart is not a break (SPEC 6.5 and invariant 7 of the library): the chain began again, legally, after a process that could not resume. It is medium when a valid resume checkpoint names the line before it, and high when none does, because then nothing says why the chain began again.
With -checkpoints DIR -checkpoint-keys FILE the verifier also checks each
stream's checkpoint sidecar (SPEC 6.5.1, <stream>.ckpt.ndjson in DIR). A
record that does not hold is a refusal: the stream was rewritten before its
count (its prefix no longer matches), or it was truncated below its count. A
refusal is printed, makes the run exit 1 the first time it is seen, and is
remembered in the state file. It is not put on the bus: SPEC 6.2 names no event
type for it, and the keyryx witness types are where that is designed. FILE
holds the public keys that verify the signatures, each kid its RFC 7638
thumbprint. -checkpoints without -checkpoint-keys, or the reverse, is a
usage error, because a signature nobody can check is not a pass.
An unchained stream is not a broken one: the field is optional by SPEC 6.5. It
is reported once so the operator knows which streams nobody can verify. A
stream with a single event is not judged, since the first line of every chain
has no prev_hash by design.
Each finding is written once. The (file, kind, line) is remembered in a
small state file (-state, default agent-conform.state.json beside the
output), so a schedule that runs every minute does not repeat an alert every
minute. A state file that exists and cannot be read is an error, never a fresh
start. The event is written before the state is saved, so a crash costs one
duplicate at worst and never a lost alert.
One pass, then exit, by default; a CronJob or a loop is the schedule.
-every <duration> (at least 10s) repeats the pass inside one process, for an
image with no shell to loop in and for a compose service: it exits 0 when told
to stop, and exits 2 at once when a pass cannot do its job, so a supervisor's
restart count shows a verifier that is not verifying.
| Exit | Meaning |
|---|---|
| 0 | every stream verified, nothing NEW to report (an unchained stream, or a break already reported, does not change that) |
| 1 | at least one NEW chain_broken was reported, or a NEW checkpoint refusal |
| 2 | usage error, or something could not be done: the directory is missing or holds no stream, a file could not be read to its end (over a cap, an oversize line), the output or state could not be written. 2 wins over 1: the alerts are on the bus either way, and a run that could not finish must not look like one that did |
What it will not do:
- It writes nothing but its own output and its own state. Streams are
opened read-only,
-outmust be a file namedagent-conform.ndjson(its name is the source its events claim, so it cannot be pointed at another writer's stream), and-statemay not look like a stream. Mount the bus read-only and give-outa path on a writable volume of its own. - It never reports a bus it did not read as clean. A missing or empty
directory is exit 2, and so is a file larger than
-max-file-bytes(default 256 MiB) or holding a line longer than-max-line-bytes(default 1 MiB, at most 4 MiB). A break found before the oversize line is still alerted. - Nothing from inside a file is copied into an alert but two hash strings,
clipped to 96 bytes, so a forged
prev_hashdoes not choose how big the next line on the bus is. - Its own output is verified like any other stream.
The image is ghcr.io/taipanbox/agent-conform:<tag> (amd64 and arm64, static,
distroless, non-root 65532, signed by digest and attested; there is no
latest, pin a released tag). Verify it the way the archives are verified:
cosign verify ghcr.io/taipanbox/agent-conform:<tag> \
--certificate-identity-regexp '^https://github.com/TAIPANBOX/agent-stack-go/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/taipanbox/agent-conform:<tag> -R TAIPANBOX/agent-stack-goA compose service, with the bus mounted read-only and the verifier's own stream and state on a writable volume. Run it as the uid that owns that volume's files and give it read access to the bus through the bus's group:
services:
agent-conform:
image: ghcr.io/taipanbox/agent-conform:<tag> # a released tag, never a moving one
command: ["watch-dir", "-every", "60s", "-out", "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/out/agent-conform.ndjson", "/bus"]
user: "10002:10002"
group_add: ["10001"] # the group that can read the bus
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
volumes:
- events:/bus:ro # every writer's stream, read-only
- conform-out:/out # its own stream and state: the only writable pathA Kubernetes CronJob. A new break fails the Job (exit 1) on purpose, a second signal beside the event; the next run exits 0 because the finding is remembered, which is why the output volume must persist:
apiVersion: batch/v1
kind: CronJob
metadata:
name: agent-conform
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 10002
runAsGroup: 10002
supplementalGroups: [10001] # the group that can read the bus
containers:
- name: agent-conform
image: ghcr.io/taipanbox/agent-conform:<tag>
args: ["watch-dir", "-out", "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/out/agent-conform.ndjson", "/bus"]
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: {drop: [ALL]}
volumeMounts:
- {name: events, mountPath: /bus, readOnly: true}
- {name: out, mountPath: /out}
volumes:
- name: events
persistentVolumeClaim: {claimName: events, readOnly: true}
- name: out
persistentVolumeClaim: {claimName: agent-conform-out}These manifests are starting points: the image was built and run against a read-only bus with a separate output, and the snippets were not applied to a cluster in the change that wrote them. Wiring the verifier into a stack's own launcher (its uid, the file it owns on the bus) is the launcher's change.
Limits, stated so nobody reads more into an alert or its absence than is there:
prev_hashis tamper-evidence, not tamper-proof. A writer compromised in its own uid can forge its own stream with a valid chain, and so can anybody who can rewrite a whole file. This catches a partial edit, a dropped line, a reordered shipment.- Only a genuine mismatch is a break. A line whose
prev_hashwas stripped reads as a chain restart, and a line replaced by garbage makes the next one unverifiable; both are counted in the alert'sdataand neither raises one, because a crash in the middle of a write produces exactly the second shape. - Only the first break per file is announced; a later one is not until the first is gone (the count rides in the alert).
- Cutting a stream short from the end changes no hash in what remains.
keyclient is the consumer side of Keyryx, the estate's key custody service. It
is a separate package that imports only the standard library and delegation,
and nothing in delegation imports it, so a token is still checked with the keys
the process already holds. It does three things, none of them on a request path:
- Delegated keys.
Client.Delegateddeclares an ES256 or ES384 key with a life of at least ten minutes.RenewIfDuegenerates the key in memory, shows custody that it holds it, and asks for the next statement at half the life.Signsigns locally with the key whose statement is valid at the clock's time, and returnsErrNoValidKeyonce no statement is valid, so an expired key is never used. - Held keys.
Client.Signasks custody to sign with a key it holds, naming the purpose the signature serves in custody's use log. - Key sets.
Client.JWKSpolls a name's published keys into a cache.Setfeedsdelegation.Options.Keys, and the cache keeps verifying when custody is unreachable. A set with one malformed key is refused whole, and a key id the cache never held is refused, whether custody is up or down.
A consumer proves which it is by one of three credentials, named in its configuration and never guessed between: a unix socket of its own (no secret is sent, custody names the consumer by the socket), a key file, or a Kubernetes projected service-account token. The file is read on every request, so a rotated token is used at once. A redirect is not followed, so the credential never reaches a host that was not named.
NOT PROVEN by this package: it does not check the delegation root's signatures on a statement. Trust in a statement and in a key set comes from the channel to custody, so the socket's permissions, or the cluster network with its policies, are the control. A cached key is valid until the cache is refreshed; a revoked key is dropped at the next successful poll, not at the moment of revocation. Over plain HTTP inside a cluster the bearer credential is readable by anything on that network.
- Stdlib only at runtime for
passportandchain: no third-party dependency is ever required to import and use them.eventadditionally requiresgithub.com/gowebpki/jcs(RFC 8785/JCS canonicalization) at runtime, for theprev_hashintegrity chain (ChainedWriter/VerifyChain, see above).github.com/santhosh-tekuri/jsonschema/v6is used byevent's conformance test and, as a real (non-test) dependency, bycmd/agent-conform-- a consumer importing only the library packages never pulls in jsonschema; only building the standalone tool does. The same holds forpassport's conformance test, which is whyscripts/deps-layering.shreads non-test imports only. - Each package mirrors an existing internal implementation elsewhere in the
stack (Idryx's
internal/ingest/passportandinternal/ingest/tokenfuse, TokenFuse'stokenfuse-core::agent_event, Engram'sengram.events) rather than inventing new semantics, so adopting it is a rename, not a rewrite. - Errors are sentinel values, checkable with
errors.Is, not opaque strings, so callers can branch on failure kind without string matching. - Malformed input is tolerated the same way the existing Idryx connectors tolerate it: a bad NDJSON line or passport document is skipped and counted, never fatal to the rest of a batch.
The canonical JSON Schemas live in the TAIPANBOX/agent-passport repo.
event/testdata/agent-event.v0.2.schema.json and
passport/testdata/schema/agent-passport.schema.json are local copies used only by
those packages' conformance tests, so the Go bindings can never silently drift
out of lockstep with the schemas that define the wire contract.
cmd/agent-conform/schemas/*.json are separate local copies of all six
embedded schemas (Passport v0.1 and v1.0, event v0.1, v0.2, v0.3 and v1.0), embedded into that tool via
go:embed for the same reason.
Copies drift, so on every push CI checks out agent-passport beside this repo
and compares every one of them against the file it was copied from, byte for
byte (scripts/schemas-in-sync.sh). That check exists because the vendored
Passport schema had drifted: it was missing the filesystem (SPEC 4.4) and
models (SPEC 4.5) declarations, and a Passport document allows
additionalProperties, so a property the schema does not declare is not
checked loosely, it is not looked at at all. A passport whose filesystem entry
said "mode": "delete", a mode the spec does not have, passed with OK. The
schema is synced and both fields are now enforced; the gate is there so the
next divergence is caught by CI rather than by a reader.
This module follows SemVer, starting at v0.1.0. Breaking the wire contract
(the passport or event schema) is a spec version bump, never a silent
change; the Go types version alongside the module itself. Consumers pin it
by tag (go get github.com/TAIPANBOX/agent-stack-go/v2@v2.0.0), never a local
replace.
-
passport:Parse,ValidateAgentURI,ValidateUserURI, sentinel errors -
event:Marshal,Unmarshal, append-onlyWriter,Scan/ReadFileNDJSON readers,ChainedWriter/VerifyChainSPEC 6.5prev_hashintegrity chain,Canonicalize/ChainHash -
chain:Append,Validate,MaxDepth= 32, acyclic + root-first - conformance tests against the canonical JSON Schemas, one per bound type:
eventagainstagent-eventv0.2 and v1.0,passportagainstagent-passportv0.1 and v1.0, including a both-directions check that the struct's json tags and the schema's properties name the same set (a mistyped tag validates fine, sinceadditionalPropertiesis true, and declares nothing) -
passport.LoadDir: shared batch loader (resolve dir/glob/file, sorted, tolerant, first-seen-id dedup), extracted out of Wardryx's and Idryx's independent copies -
v0.5.0tagged, the first release to publishagent-conformas a binary, thenv0.5.1the same day: it moved the version out of the release asset names and into the binary, soreleases/latest/download/<name>is a permanent address; CI green ongofmt,go vet,staticcheck,go test -race,go build,govulncheck -
v0.6.0tagged, which the install lines above pinned to at the time (they now pin the newest tag, see Versioning). The library packages are byte-identical tov0.5.1, so an importer gets no behaviour change; the checker does.-chainsaid it checked delegation chains and never looked, the vendored Passport schema had stopped being the Passport schema, and the event schemas gained SPEC 5.1's depth bound, so a stream with a chain over 32 entries now fails where it used to pass. That is a minor rather than a patch for exactly that reason. It also drops adarwin/arm64build ofagent-conformthat had been committed at the module root and shipped inside thev0.5.1module zip, against this repository's own.gitignore -
cmd/agent-conform: standalone conformance-check CLI, full JSON Schema validation (Passport documents v0.1 and v1.0, events v0.1/v0.2/v0.3/v1.0, each against its own version's schema and never another) against embedded copies of the canonical schemas; live-verified against real fixtures elsewhere in the stack, catching a real 63-vs-64-hex-charprev_hashdefect - 1.0 (2026-09-12):
passport.SchemaV10,event.SchemaV10andpassport.AcceptedSchemas;Parserefuses a v1.0 document carrying a key the schema never named (ErrUnknownField) and keeps tolerating it under v0.1; the exported surface ofchain,delegation,eventandpassportis a file,api/surface.txt, andscripts/api-surface.shfails when it loses or changes a line (a new major is cut with--major); eleven scenarios infeatures/contract-1.0.feature, each bound to its test -
ChainedWriterlocks the file and chains from the line on disk, so writers sharing one stream keep one chain (scenarios infeatures/chained-writer-lock.feature) -
agent-conform watch-dir(agent-stack-go#64): the chain verifier for a box, with its scenarios infeatures/watch-dir.feature, and theghcr.io/taipanbox/agent-conform:<tag>image built byrelease.ymlon av*tag (see Running the chain verifier on a box)
This module's package set (passport, event, chain, delegation) covers everything the
stack's current Go consumers need; it is not a fixed, closed list, and grows
opportunistically the same way passport.LoadDir did (extracted once two
independent copies existed, not speculatively).
What has actually been checked about this contract, what a real cluster run
confirmed, and what is explicitly NOT validated: VALIDATION.md.
The short version is that agent-conform caught a real 63-versus-64 hex
character prev_hash defect in another service's output, which is the whole
reason this tooling exists.
