SPT-Txn — enforcement points
Zero dependencies. Chain-agnostic. One Wrap().
Per-transaction authorization and a tamper-evident audit trail for any HTTP
resource server or MCP tool server. go.mod has no require block: this module
depends on nothing but the Go standard library, and it is going to stay that way.
pep, err := gateway.NewPEP(gateway.PEP{
Name: "my-resource-server", // identifies this PEP in every receipt
Evidence: myEmitter, // or evidence.None{} to opt out, explicitly
/* Allowlist, Policy, Spend, Log, RKey, Requirements */
})
if err != nil {
return err // refuses to build without an evidence emitter
}
http.ListenAndServe(":8080", pep.Wrap(yourHandler))
Construct through NewPEP, never as a bare struct literal: the literal skips
the checks and leaves Evidence nil, and the first authorized request then
panics. The constructor is where "a PEP without evidence is not a PEP" is
enforced.
Every request is authorized against a declared transaction, a signed Transaction
Receipt and a transparency-log entry are recorded, and the request reaches the
protected resource only on ALLOW.
Two artifacts, two questions
Every decision — including a denial — produces both:
- A Transaction Receipt (
evidence) records what authority governed this
decision: PEP identity, decision class, rule path, policy hash, jurisdiction.
- A transparency-log entry (
translog) records where that decision sits in
a hash-chained, inclusion-provable Merkle log (RFC 6962 tree hash; the
published root additionally binds the record count).
They compose. Neither substitutes for the other.
The receipt is a seam rather than an implementation: evidence is an interface,
a struct of strings and an explicit no-op — no crypto, no canonicalization, no
imports at all. The reference implementation lives in
spt-txn-poc/pkg/receipt, next to
the verifier, because the canonicalizer is the primary bypass surface and issuer
and verifier must be the same version by construction, not by convention.
Depending on it from here would also drag a zero-knowledge-proof stack into the
dependency graph of anyone who only wanted middleware. So the dependency points
the other way: this module defines the interface, the engine implements it, and
a deployment may supply its own.
Evidence is a precondition of the decision, not a side effect. An Emit error
means "not durably recorded", and the PEP must answer DENY/unavailable
rather than proceed. A PEP constructed without an Emitter is refused outright,
and evidence.None — the deliberate "no receipts" choice — is detectable at
runtime, so a deployment cannot quietly drift into non-conformance.
What's here
| Package |
What it does |
gate |
The decision core: offline intent binding, ALLOW / DENY_VIOLATION / DENY_UNAVAILABLE |
evidence |
The Transaction Receipt seam — interface, draft-03 field set, detectable no-op. Zero imports. |
translog |
Hash-chained records + Merkle transparency log (RFC 6962 tree hash, published root bound to the record count); concurrency-safe under per-request goroutines |
gateway |
HTTP PEP — PEP.Wrap(http.Handler) middleware, plus a transparency-log HTTP service |
mcpgate |
MCP PEP — the same decision core enforcing agent tool calls |
Both enforcement points call the same gate. Neither holds policy logic of its own.
What the edge checks, and what it doesn't:
docs/PRESENTATION-SEAM.md states plainly which
properties this module enforces (allowlist, binding, single use, freshness,
evidence) and which belong to the engine's eight-step verifier (issuer
signature, delegation chain, holder binding, revocation) — plus exactly what
connecting the two would require. Read it before assuming a presented token is
cryptographically verified here; today it is not.
Gating tools/call is not gating MCP. mcpgate authorizes one tool-call
you hand it. It does not see your JSON-RPC transport and cannot police it. If
you gate tools/call with the Enforcer and forward every other method
untouched, you have protected one method, not the server: resources/read
returns resource contents, prompts/get returns prompts, and both go straight
through while the receipts show a clean record of the method you did check.
Make the pass-through an allowlist — forward only the methods you have
decided are reads, and deny everything else, including methods that did not
exist when you wrote the list. A denylist is wrong by default for every method
its author did not think of.
This is not hypothetical: a middleware built on this model shipped for eight
weeks with exactly that shape before it was found and inverted on 2026-09-05.
Why this is a separate module
These packages were extracted from spt-txn-x402-solana, where adopting the
middleware meant pulling solana-go and ~28 indirect dependencies — a blockchain
SDK and a Mongo driver — into the dependency graph of anyone who only wanted
per-transaction authorization.
SPT-Txn is chain-agnostic. This module makes that structural rather than
asserted: the dependency direction runs one way, from chain-specific consumers
to this module, never back. If anything here ever needs a chain, the claim is
gone.
Licence and scope
Apache-2.0. Enforcement points are free — running one costs nothing, and it
always will. What is commercial is operating a fleet of them and proving
compliance with them: the control plane (issuer registry, revocation
distribution, trust-snapshot hosting), receipt aggregation and retention, control
mappings and compliance packs.
Security features are never gated. Verifying a status list and pinning a trust
snapshot are free; hosting and distributing them at fleet scale is not.
Specification
draft-coetzee-oauth-spt-txn-tokens-03 —
https://datatracker.ietf.org/doc/draft-coetzee-oauth-spt-txn-tokens/
Datatracker is the single source of truth for the draft version.
Reference implementation: https://github.com/rudizee007/spt-txn-poc
translog writes spt-txn/translog/v2. The published root is the RFC 6962
tree hash over the records, wrapped with the record count:
root = SHA-256( 0x02 ‖ uint64be(n) ‖ MTH(records) )
0x00 and 0x01 are RFC 6962's leaf and node tags; 0x02 is the wrapper's. The
empty log is wrapped the same way (n = 0). The tree itself is RFC 6962's
unbalanced split with no leaf padding. The count wrapper follows the construction
in BIS Working Paper No. 1374, Verifiable official statistics: a blockchain-based
approach (September 2026), §4.1, adapted to SHA-256 and a 64-bit count.
Inclusion proofs are ordinary RFC 6962 audit paths; the wrapper is applied once,
at the top. A verifier recomputes the tree hash from the leaf and its path, wraps
it with the tree size, and compares — translog.VerifyInclusion does exactly
that. The wrapper binds the size only relative to a root you already trust (an
on-chain anchor, or another authenticated source). A root and a size accepted from
the same untrusted party bind nothing.
Log files written as spt-txn/translog/v1 (this module up to v0.5.x) are refused
with ErrFormat. A v1 root is the bare tree hash, which
translog/kat/logentry_ref.py still prints as MERKLE_INTERNAL, so a root
anchored from a v1 log stays reproducible.
Testing
go test -race ./...
scripts/mutate-merkle-size-binding.sh # every mutation must be killed
Known-answer values come from an independent Python reference,
translog/kat/logentry_ref.py — never from the Go code under test.
Each fuzz target's seed corpus runs under plain go test. To explore one, pass
-fuzz, for example
go test ./translog/ -run '^$' -fuzz '^FuzzReadLog_AcceptsOnlySoundLogs$' -fuzztime 5m.
| Package |
Fuzz target |
Property |
translog |
FuzzVerifyInclusion_BindsTheClaimedTreeSize |
A genuine proof verifies at the tree's size and at no other |
translog |
FuzzVerifyInclusion_ReturnsForEveryClaimedSize |
Verification returns, and refuses, for any index, size and path length |
translog |
FuzzVerifyInclusion_AcceptsOnlyProofsTheTreeGenerated |
Any proof that verifies is one the tree generates: the tree's size, the leaf at that index, that index's audit path |
translog |
FuzzInclusionProof_EveryGeneratedProofVerifies |
Every generated proof verifies at the tree's size |
translog |
FuzzReadLog_AcceptsOnlySoundLogs |
A file that loads verifies, proves every entry, and round-trips unchanged |
gateway |
FuzzTransparencyEntry_ServesOnlyVerifiableProofs |
The entry endpoint never answers with a server error, and every proof it serves verifies independently |
Status — read this before relying on it
Proof of concept. Not externally audited. Not for production use.
On the project's assurance ladder these packages sit at tested and adversarially
reviewed; independent audit and production hardening are the next steps, and
neither has happened. "Adversarially reviewed" here means AI-agent review plus the
maintainer's own analysis — not an assessment by an independent security firm.
Contributing
Trust-boundary changes (the decision path, intent binding, receipt signing) are
reviewed spec-first and adversarially before merge. Please open an issue
describing the gap before sending a PR that touches gate, evidence or
translog.
Do not add a third-party dependency to this module without discussion. The
absence of one is a feature, not an oversight.