Skip to content

About

The open reference gateway for Judgment Packs — hosted attestation with a sealed session registry, closing whole-session replay and tail rollback. Open on purpose; verifiability belongs to the ecosystem, not one operator.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

judgment-pack-gateway

The open-source gateway for judgment-pack: a small service that attests the inputs a pack is judged on, under one protected signing identity, and seals each session so a verifier can catch replay and rollback.

It implements trustworthy input acquisition line (judgment-pack-evaluator-experiments, ADR-0002). That line asked a narrow question — where do a pack's inputs come from, and what makes them trustworthy? — and answered it with a ceiling stated up front: you can prove byte-lineage, not truth. A judgment is only as trustworthy as the bytes it was computed over, so the honest goal is to put those bytes on a proof path the model cannot forge, and to say exactly where that proof stops.

What it does

  • Acquire. A caller asks the gateway to acquire a result from a configured source. The gateway runs the source, content-addresses the result, and signs its canonical form under the gateway's Ed25519 identity, chained to the session's prior receipts. The caller cannot supply a receipt — the gateway produces every one. This is what takes the model out of the proof path: an agent can assert anything, but it cannot manufacture the gateway's signature.
  • Seal. When a session closes, the gateway seals its final receipt count in an append-only registry under the same key.
  • Verify. A verifier fetches the registry from the gateway (not from the store) and checks the store against it using only the public key — checking grants no power to forge. Per-receipt integrity comes from the signatures; the seal adds what a store cannot attest about itself — that a whole session was replayed into it, or that a session's tail was rolled back.
flowchart TD
  subgraph Producer["Producer side"]
    S["Configured source"] -->|result bytes| A["Gateway acquire and sign"]
    A -->|receipt / artifact| ST["Retain receipt/artifact"]
    ST -->|final count| SE["Seal final count"]
    SE -->|seal| R["Registry"]
  end

  subgraph Consumer["Consumer side"]
    OB["Obtain snapshot"] --> FR["Fetch registry from key holder"]
    K["Out-of-band pinned key"] --> V["Verify locally"]
    FR --> V
    V --> B["Bind accepted receipt"]
    B --> D["Re-digest artifact"]
    D --> U["Use result"]
  end

  ST -.->|snapshot| OB
  R -.-> FR
  GW["/verify: diagnostic convenience"] -. not the consumer boundary .-> V
  PK["/publickey: consistency only"] -. not trusted key distribution .-> V
Loading

The executable form of the consumer flow is SPEC.md §5a and go/ceremony_test.go.

The registry is the reason the gateway exists. The inline attestation core proved each receipt; it left two residuals it structurally could not catch on its own, because both need an anchor outside the store. The gateway is that anchor. See SPEC.md for the seal contract and the exact findings, and go/service_test.go for the demonstration — including the contrast that per-receipt verification passes the same replayed and truncated stores the registry-anchored verification rejects.

Configurable web search

Named Tavily and Google Cloud Search grounding connections are available through Gateway's private connection service and the web-search acquisition source. Desk can switch providers without changing its assistant model. Credentials stay in Gateway; search hits remain distinct from fetched page evidence. See configuration, limits and the provider contract.

Run it

One Go binary, standard library only, binds localhost. Build it as below, or take it built: a release made by the release workflow carries the gateway and the adapters for Linux, macOS and Windows, with checksums and a provenance attestation (docs/releasing.md). Releases up to v0.4.0 carry notes only.

cd go && go build -o gateway .

./gateway keygen gateway.seed      # prints the public key and key id to pin

The serve command below references ./my_source. Create it first in a POSIX/WSL shell:

cat > my_source <<'EOF'
#!/bin/sh
# Consume the canonical arguments supplied on stdin, then emit one JSON result.
cat >/dev/null
printf '%s\n' '{"synthetic":true,"subject":"acme"}'
EOF
chmod +x my_source

The fixture is synthetic: it demonstrates the source-command interface only, and its output is not evidence that a real screening occurred. On native Windows, replace it with any executable that reads the canonical arguments on stdin and writes one JSON result object to stdout.

./gateway serve ./store gateway.seed gateway:demo ./registry.jsonl \
    --source screening='./my_source' --port 8787

keygen prints the public key precisely because it has to be pinned out of band — see Honest bounds.

The key can be rotated, with the gateway stopped: ./gateway rotate-key --seed gateway.seed --registry ./registry.jsonl makes the next seed, keeps the record it is about to append beside the seed, appends to the registry a statement the current key signs naming the next key and one the next key signs naming the current one (SPEC.md §3b), and only then switches gateway.seed to the next seed, keeping the one rotated from beside it for you to destroy. On Unix a running gateway holds a lock on its registry, and rotate-key refuses while it is held; elsewhere, stop the gateway first. A rotation that stopped part way — the append torn, or the switch not made — leaves the gateway refusing to start under either key until ./gateway rotate-key --resume --seed gateway.seed --registry ./registry.jsonl finishes it; do not move the seeds by hand. Nothing new is pinned: a verifier follows the rotations from the key it pinned from keygen, and the report names how many it followed and the key it ended at. The gateway refuses to start under a key it rotated from.

curl -s localhost:8787/acquire -d '{"session":"s1","source":"screening","arguments":{"subject":"acme"}}'
curl -s localhost:8787/seal    -d '{"session":"s1"}'
curl -s localhost:8787/verify              # the gateway grading itself -- a diagnostic, not evidence (SPEC.md §5a.3)
curl -s localhost:8787/registry            # the anchor a verifier fetches from the key holder
curl -s localhost:8787/publickey           # consistency only: a real verifier uses the key pinned from keygen, never this
curl -s localhost:8787/posture             # which isolation protections this host enforces, as gateway posture --json

./gateway posture says which of the isolation protections in SECURITY.md this host enforces — the user switch for sources, the reaping of a source's descendants, the engine image's custody, the seed file's owner and mode, the confinement of the programs the gateway starts — one line each, enforced, not-enforced or not-applicable, with the reason. Give it serve's own command line, with or without the word serve, or --config engine.json, to judge that configuration's seed and sources as well; --json prints the report in canonical JSON. It exits 0 whatever it reports, and a not-enforced line's reason says what is missing: a user of its own for each source (--source-user), a seed at mode 0600 owned by the gateway's user, the engine image.

The gateway mints version 3 receipts (SPEC.md §1.2a): /acquire answers {result, receipt, salts}, where salts.args is the 32-byte salt behind the receipt's arguments commitment, returned here and retained nowhere. Revealing the arguments to an auditor takes the salt, so whoever holds it can, and nobody else; this reference hands it over plaintext localhost HTTP, so the holder is whoever can read that exchange. A bare --source command is the "command" shape: its acquisition record names the command and the digest of the file its first word resolved to, read just before the source is started, and says nothing else was known — a directly executed script is digested as the script, while python3 fetch.py is digested as the interpreter and the script is an argument the record does not repeat; observedAt is the gateway's own stamp of when it had read the source's output in full, since a command records nothing. --receipt-version 2 keeps the version 2 form for a consumer not yet updated.

An engine-governed write goes through the same boundary as a read, as an action (docs/design/executor.md): POST /act names a platform, a tool the platform's write binding offers, the arguments, the decision record the write relies on and the receipts that record relied on. The engine refuses the request before any executor runs unless the requester is authenticated, the platform sets write: true, the tool is the binding's, every cited receipt is in this engine's own store and verifies under its own key, and a decision record with the stated digest is under the configured decision-record directory — and then runs adapter-mcp on the write binding, retains the target's response, and mints one action receipt in the session chain naming the requester, the decision, the citations and the tool. The receipt is lineage of a request and a response: it says who asked, never that the write was right or that anyone approved it.

For a write tool the operator holds to a decision policy in the configuration (ADR-0011), the engine also reads the record before anything is sent: it must be shaped as the runtime's evaluation record (recordVersion 1, kind evaluation, a pack digest), the request's pack digest and citations must be the record's, and the record must state an outcome the policy allows, no handoff, a pack and reviewed law where the policy asks for them, and facts equal to the write's arguments where the policy binds them. From engine configuration version 9 a policy may also fix an argument to a value of its own (fix) and refuse any argument it neither binds nor fixes (closed, ADR-0016), and from version 10 hold the value at a pointer to an array or an object, an array to a length (shape, ADR-0018); the engine compares and never fills a value in. reviewed alone holds a write to whatever reviewed-set lock is current, so an edited pack locked again passes it; list packs to hold a write to particular reviewed bytes. The receipt then names the policy by its digest. For a tool held to none, nothing in the record is read. gateway verify compares an action's pack digest and citations with a runtime evaluation record it names, and says of any other record that it did not compare it. Without a policy, decision on a receipt means only that the record existed; SPEC.md §1.2a says what it does and does not establish.

A record is found by its shape, so a policy holds a write to whatever record a writer of the decision-record directory put there, one written by hand included. A policy that sets requireSignedRecord to the runtime's public keys (engine configuration version 5, ADR-0012) also requires the record to be signed by one of them, in its exact bytes, in the runtime's sidecar beside it, signatures.jsonl; the receipt then says, in decision.recordBytes, that the record was found by its exact bytes, and gateway verify finds it the same way. A writer who cannot use one of those keys then cannot satisfy the policy with a record of their own. It establishes nothing against whoever holds a named key, the operator included, who can sign any record. The gateway trusts exactly the keys the policy names and follows no key rotation: when the runtime's key changes, add the next key to the policy before the project signs with it, and remove the old one once no record it signed is still to be acted on.

So that only the user the runtime writes as, the signer and root can put a record where the engine looks for one, the engine refuses to start, and gateway connect to add a platform, while anyone else could write a record in the decision-record directory: the directory, and every directory and regular file beneath it that the walk reads, must be owned by one of those three and writable by nobody else, the sticky bit excusing nothing there; every directory on the way to it must be owned by one of them and writable by nobody else unless it is sticky, as /tmp is; a directory not there yet must be in a directory only its owner can write, since whoever makes it first owns it; and a link at its path must be owned by root, and the directory the link is in is held the same way, since whoever may write it could put a directory of records in the link's place. The refusal names the path, its owner or mode, and why. It holds whatever policy the tools have, since without requireSignedRecord a policy trusts every writer of the directory, and without a policy decision means only that a record existed there. It establishes the state at start-up, as unix owners and permission bits show it, and no more: a mode or a file changed later, an access-control list the bits do not show (macOS's), a mount, and root are not seen; the engine runs only on unix, so there is no Windows check.

Two platforms write back after a decision (issue #248): azure-devops, through an organization's remote Azure DevOps MCP server at https://mcp.dev.azure.com/{organization}, and databricks, through the managed MCP server of one Unity Catalog function at https://{workspace}/api/2.0/mcp/functions/{catalog}/{schema}/{function} (the bindings in catalog/). Their reads are the connections', so each binding states a write operation alone. A binding's URL and tools may be a template, filled from the platform's parameters (engine configuration version 8): the binding's digest pins the template and the transport, the scheme and the host are written in it (or the host is {workspace} alone, held to a Databricks workspace's host forms), and the action receipt names the resolved URL, beside action.tool.transport "http", under which gateway verify and verify-ts hold it to the endpoint form of SPEC.md §1.2a. Each write tool is held to a decision policy: for Azure DevOps, whose write tools are dispatchers that an action argument steers, the worked policy fixes action to update and the form of its operations, binds the work item's id and revision to the record's facts, holds updates to an array of two, and is closed, so a request that names another action, adds an operation, adds a member or writes its operations as an object is refused before anything is sent (engine configuration version 10, ADR-0016, ADR-0018); for Databricks, each of the function's named parameters is bound to a fact. Raw SQL is no write tool, since a decision could then authorize an arbitrary statement, which no policy can bind argument by argument; the postgres binding, whose server takes a statement, has no write operation, and a platform that pins it with write: true is refused at start. An action receipt for these platforms establishes who asked, which judgment was cited and found verifying, what was sent and to which resolved endpoint, and what the target answered; it does not name the credential, which the configuration names, nor the TLS identity the write was answered under, and it does not establish that the target applied the write as intended, that the tool did only what its name says, or anything of the service's permissions beyond the credential's (docs/design/executor.md, "The write-back platforms").

Personal Drive, S3 and local-vault file management is a separate authenticated host control surface in gateway-connections: bounded listing/search, selected file reads, and reviewed create/update/delete plans. It issues no signed action receipt and is not available to autonomous jobs or model tool calls. See storage file management for scope, consent, conditional writes and recovery limits.

A source declared with --source-shape NAME=airbyte|mcp|http is an adapter (ADR-0002): its stdout is an envelope, {acquisition, result, page?}, and the receipt records the acquisition as the adapter reported it — endpoint, snapshot, schema, the adapter that fetched — under the shape the operator declared, with the statement committed under its own salt (salts.statement) and, for a page, the digest of every item. A defective envelope fails the acquisition; nothing is minted. What that record is, exactly, is the adapter's testimony under the gateway's signature, and SECURITY.md says so. The envelope is published as a JSON Schema with vectors, schema/adapter-envelope.v1.json, and gateway adapter-conform --shape SHAPE --vectors DIR 'ADAPTER-COMMAND' runs an adapter of your own as serve would and says, request by request, whether serve would accept its envelope, minting nothing (write an adapter).

A consumer does not stop at those endpoints: it runs gateway verify itself, over a store it holds, under the key it pinned from keygen's output, then binds the receipt and re-digests the artifact before using a byte — the normative sequence is SPEC.md §5a, executable in go/ceremony_test.go.

A store to hand to an auditor, or to back up, is exported as one consistent cut:

./gateway export --config engine.json --out /path/to/export [--observations journal.json]
./gateway export --check /path/to/export        # every file against the manifest, byte for byte
./gateway verify /path/to/export/store /path/to/export/registry <authority> \
    /path/to/export/decision-records < pinned-public-key

The export holds the receipts and artifacts, the registry, the decision records, the descriptor snapshots the platforms pin (a platform's own, or each of its operations') and any observation journal given, in the store layout gateway verify reads (SPEC.md §1.3), with manifest.json naming the authority, the signing key by its id and every file by its digest and size. The cut reads the registry before the receipts, so a session sealed while the export runs is in it with its seal and every receipt the seal counts, or without the seal, never as a rollback. The seed is in no file of it, and the public key is not either: a verifier pins it out of band, as for a live store. A link among the store's receipts or artifacts refuses the export, and no file is read through one. --out must not be there yet, nor inside the store or the decision records. --check reports {"findings":[...],"ok":...} and exits 0 when it reaches a verdict, as verify does; a manifest it cannot read is no verdict. A restore is a copy of the export's files back into place, which --check and verify then read as they read the export. The journal comes from the connection custody, which the signer never reads: gateway-connections --state-dir <dir> --principal <name> --export-observations writes it, as the custody holds it, to stdout. The export carries the artifacts and decision records as they are, so keep it as private as the store. The store contract behind it, and what a storage kind other than the filesystem must satisfy, is docs/design/store-contract.md.

gateway serve --config engine.json is the engine's form of the same command: the file names platforms — a catalog binding pinned by digest, a credentials file, the OS user the platform's adapters run as — and serve derives every source from it, refusing to start under a configuration in which an adapter could read the seed or the signer a credential, or in which, as owners and modes stand at start-up, anyone but root and the signer could replace an adapter it launches, which must be a native executable of the host (ELF on Linux, Mach-O on macOS, for its CPU), not a script; it launches each adapter by the path that adapter resolves to (docs/design/engine-config.md). gateway connect --config engine.json <platform> --binding <name> --credentials-file <operation>=<path>... --user <name> writes a platform entry: it holds the configuration that would result to the same refusals, runs each of the platform's adapters once in check mode as the platform's user, prints what the platform answered, and writes only then — nothing is acquired and no receipt is minted. The bindings the engine ships, with their pins and licences, are in catalog/. With an identity in the configuration — an issuer, the audience the engine is named as, and a file of the issuer's public keys — every request that acquires, seals or verifies carries a bearer token the issuer signed, and the receipt names the caller it proved; without one, every receipt carries caller: null.

A source is any command that reads the canonical arguments on stdin and writes a JSON result on stdout. The gateway attaches no transport of its own; it attests whatever bytes a source returns — proof of the bytes, not proof of their truth.

A source is started with the environment declared for it, plus PATH, and nothing else of the gateway's. --source-env NAME=KEY=VALUE sets a variable for source NAME; --source-env NAME=KEY copies that one variable from the gateway's environment at spawn time, which is for passing a path through by name, not a secret — a secret in the gateway's environment is in the signer's memory whatever is declared. On Windows, SYSTEMROOT is the one variable os/exec adds undeclared. --source-user NAME=USER runs a source as another OS user with that user's own groups (Unix; root may switch, and so may a process that is not root but holds CAP_SETUID, CAP_SETGID and CAP_KILL on Linux; a start that cannot switch is refused rather than run as the signer). --source-max-output BYTES bounds what a source may write on stdout, from 1 byte to 64 MiB (one mebibyte by default); past it the source is killed — on Unix its whole process group, elsewhere the direct child — and the acquisition fails, and a descendant that outlives the kill gets a bounded wait rather than the acquisition. --source-timeout NAME=SECONDS gives source NAME a timeout of its own, a whole number of seconds from 1 to 604800 (thirty by default); past it the source is killed the same way and the caller is told it did not finish within its timeout. A source's timeout starts before its adapter does — the gateway resolves the command, digests it for a version 3 receipt where the source is a bare command, and starts it, on Unix behind a process-group anchor — so keep an adapter's own --timeout, plus the cleanup waits it budgets for stopping, under its source's timeout, with further margin for that start and for the adapter's own report. That margin is what a read reaching the adapter's deadline needs to be reported by the adapter rather than killed with it (adapters). --max-request BYTES bounds an /acquire body, from 1 byte to 64 MiB (one mebibyte by default); /seal and /act keep one mebibyte, and every body must still arrive within the server's thirty-second read timeout. --source-timeout, --max-request and --source-max-output are command-line options: under an engine configuration the derived sources keep the thirty-second timeout, /acquire keeps one mebibyte, and a source's stdout keeps its own mebibyte. An /acquire body's arguments are refused past 524,288 JSON values, a count a one-mebibyte body cannot reach, and raising the bound does not raise it. On Unix, serve refuses a seed file that is not a regular file owned by the gateway's own user and readable by it alone, says which chmod to run, and marks every descriptor its launcher left open close-on-exec so none reaches a source; on Windows it checks that the seed is a regular file and says the rest is the filesystem's. What this does not give: a source that runs as the gateway's own user can read what that user can read, and a gateway that runs as root can read the files a source user holds. The separation is as strong as the identities the operator gives the two sides; SECURITY.md states it in full.

cd go && go test ./... && ./gateway conform     # the frozen corpus is the arbiter

Honest bounds

Stated plainly because the whole line is about being exact where proof stops:

  • A receipt (and a seal) is an Ed25519 signature under an operator-configured authority label — proof of byte-lineage to the key holder, not proof that a genuinely-named source produced the bytes. The recorded source/authority is a label, not an authenticated origin.
  • Verification needs only the public key, so checking grants no power to forge. But the public key must be pinned out of band: fetching it from the gateway you are auditing proves consistency, not authenticity.
  • Signing uses Go's standard library crypto/ed25519 — vetted and constant time. (An earlier revision carried hand-written pure-Python Ed25519 that was neither; retiring it removed the caveat rather than mitigating it.)
  • The registry closes replay and rollback relative to a verifier that trusts the gateway's registry over the store. It does not defend a compromised gateway: key disclosure forges everything.
  • This is a single-identity, single-operator reference, localhost, no authentication on the HTTP surface unless an identity is configured — and that decides who may call, never from where — no authorization, no HA. It is for self-hosting a trust root and demonstrating the mechanism — not a hardened public deployment.

Where this is going

The source contract above is the gateway's whole integration surface, and every source that exists is a script someone wrote by hand. docs/adr/0001 records the direction: a second module, adapters/, that reaches the catalogs that already exist — connector images for bulk and historical reads, the MCP servers vendors publish for live reads and writes — and a release that ships the gateway, the adapters and the runtime as one image with one configuration file. Inside that image the signer runs alone with the seed, each adapter runs as its own process with one platform's credentials, and neither imports the other; go/boundary_test.go and adapters/boundary_test.go make the import rule a test, and the design notes state what the tests cannot: what each process may read, and which deployment choices would undo it. The receipt format this needs, version 3, is normative in SPEC.md §1.2a with vectors under corpus/v3/, and is what serve mints; its design record is docs/design/receipt-v3.md. The adapters are in adapters/: adapter-airbyte, a pinned connector image run through the operator's container runtime, one page of one stream per acquisition; adapter-mcp, a client of a vendor's MCP server over stdio or, at a URL the operator fixes, over Streamable HTTP, one tool call per acquisition; and adapter-http, one request over TLS to an endpoint the operator fixed — a search provider's JSON API, a reader service that renders a page or a PDF as text — with a credential the adapter holds, one request per acquisition — wired to serve with --source-shape NAME=airbyte, NAME=mcp and NAME=http. That a record reached through both the connector and the MCP paths derives to the same facts is checked on a golden record (docs/design/both-paths-agreement.md). A workflow tool reaches the same surface through a client package under plugins/ — an n8n community node and an Activepieces piece, each carrying result, receipt and salts into the workflow as data and verifying nothing (docs/design/plugins.md); plugins/smoke/ runs the node inside n8n, and the piece's actions as the framework calls them, against a live engine. An MCP client reaches the engine's platforms' live tools through gateway mcp, a fifth process that forwards each call to /acquire under the caller's token and answers with the receipt (docs/design/mcp-server.md, ADR-0003). A document a person attaches in a desk — a PDF, a scanned form, a text export — is to reach the same surface as a bare source, adapter-document, whose contract is written ahead of it: the adapter establishes the document's identity by digest, extracts what it can within stated bounds, names the pages it could not read, and writes one versioned record the gateway attests under the command shape (docs/design/attachments.md, ADR-0004). Content a desk wants as a document goes the other way through a second bare source, adapter-render: it takes the content as a closed structure of blocks, writes a Word file, or has a program the operator configured make a PDF of that file, and writes one versioned record that holds the file, so the receipt covers the file's bytes (docs/design/rendering.md, ADR-0006).

go/          the core: canon, sign, seal, verify, conform, serve — standard library only
adapters/    what reaches outside, spawned by the core over the source contract, never linked
plugins/     the engine reached from workflow tools: an n8n node, an Activepieces piece
corpus/      the frozen vectors both answer to
docs/adr/    why the repository is shaped this way
docs/design/ what is designed and not yet decided

Why this repo is open

The format, the registry contract, and the verifier are all here and inspectable, on purpose: an attestation nobody can verify is worth nothing. Verifiability is a property of the ecosystem, not of one operator, so the parts a third party needs to check a receipt or a seal without trusting the operator — the receipt format, canonicalization, the seal contract, and verify_with_registry — are open and stay open. That is the mandatory-open core.

This repository contains the gateway, its receipt and seal formats, the registry, and the verifier needed to inspect and validate those records. The shipped implementation acquires configured sources, signs the resulting records, and supports independent verification of their byte lineage.

Checks and agent instructions

scripts/dev/check.sh runs every check CI runs, in order, and prints one summary block of passes, failures and skips; --setup runs the install steps first and --only NAME re-runs one check. The checks are listed in dev.json, the manifest of the Judgment Pack development kit (v0.1.0), whose runner is vendored here unchanged; the longer ones are scripts under scripts/checks/. AGENTS.md and CLAUDE.md carry the same instructions for agent sessions, shared rules first and this repository's own after them; CI checks that the two are identical (scripts/sync-agent-docs.sh writes CLAUDE.md from AGENTS.md).

License

Apache-2.0. See LICENSE.

Long source reads

Operators can set --source-timeout NAME=SECONDS up to seven days (604800 seconds). The default remains 30 seconds; adapters keep their own timeout limits. Set the adapter timeout below the Gateway timeout, allowing cleanup time.

Durable waiting belongs to the caller. Runner's independent jpack-source-worker calls the normal /acquire API and retains its response in the caller's private store. Gateway never stores the response's commitment salts or a replayable request record. See source custody and recovery.

About

The open reference gateway for Judgment Packs — hosted attestation with a sealed session registry, closing whole-session replay and tail rollback. Open on purpose; verifiability belongs to the ecosystem, not one operator.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages