Know who signed a contribution — and which AI they said they used.
A single envelope. Multiple specifications.
Merge code, not claims.
augbastos.github.io/scpe — what it is, in one page
An open protocol for two questions a pull request cannot answer today: who signed this
change, and what did they declare about AI use — with proof that the diff is
byte-for-byte what they signed. No SCPE server, no new accounts, no new keys: the contributor
signs with the SSH key already on their git host, and the owner re-derives everything with
ssh-keygen and git.
The AI-use disclosure is signed, not typed into a form. That is the whole difference. A signature does not make a claim true — it makes it attributable and tamper-evident: bound to one identity and to this exact diff, so it cannot be edited afterwards or quietly attached to different code. Whether someone told the truth about their tools stays a human judgement; SCPE makes sure the claim, the author, and the change cannot be separated.
When a PR arrives from someone you don't know — a person, or increasingly an AI agent — trust today rests on a username, the platform, and reading the diff by eye. SCPE replaces the first two with something the owner can check locally, and leaves the third where it belongs. There is no SCPE server, so there is nothing to trust and nothing to shut down.
This project is frozen, has zero adopters, and the argument it was built on did not survive contact with the maintainers it was built for. That is the finding, and it is more useful than the protocol, so it goes above the protocol.
On 2026-07-28 I asked two of the projects whose AI policies this repository cites how their own policy is actually checked. I expected "nobody checks." Three answers came back in hours.
OpenSSL already automates it. A project member described their CLA service: it reads every
non-trivial commit for the Assisted-by: trailer, with Co-authored-by: naming a known AI
tool as a backstop, and posts a cla-check status. An AI-assisted commit from a contributor
still on CLA 1.0 fails and is held until they re-sign 1.1. Asked whether presence of the
trailer is checked for someone already on 1.1:
Obviously not and we don't enforce it.
They built automation the moment the disclosure carried a legal consequence, and deliberately built none for transparency. That is not a project failing to enforce its policy. It is a project enforcing the branch that routes somewhere.
MicroPython checks by eye and reports it works. A collaborator there:
I look at more than the checkbox and canned text phrases as some authors add more context.
I seldom find it is omitted, and I can only recall a few cases, most of them when the template was circumvented.
Most authors quickly correct when reminded by a human, less so when CI is showing ❌
Three things in that. Omission is rare. The real failure is people going around the template, which a lint on the checkbox would not catch. And the last line inverts an assumption built into this design: an automated red check may buy less compliance than a person asking.
A third contributor said the question read as an advert. Fair — the post carried a link.
- The premise. "Policies exist and nothing enforces them" is false where it mattered most.
- The framing. Level 1 was described as enforcement and a gate. If a red check produces
less compliance than a human reminder, "gate" is not merely unnecessary, it is wrong. It is
a reviewer aid,
require: "false"is the default, and that is now what the docs say. - The direction. Maintainers who are drowning chose to reduce input, not to verify it better: the Jazzband collective shut down over AI PR volume, tldraw auto-closes all external pull requests, GitHub shipped a switch to disable PRs outright. And curl — the loudest AI-slop casualty of the year — had a problem this protocol does not touch. They did not need to know AI was used. They needed the reports to be good.
The half nobody attacked: you can check it yourself, offline, in one file. Recompute the diff, compare the hash, resolve the key. That is a property you can verify in an afternoon, not a promise. Whether anyone needs it is exactly what has not been demonstrated.
Not building Level 3. Not sending more cold outreach — the channel visibly burns. Not
presenting this as a solution looking for its first user. It is a finished, frozen artifact at
v0.2.3 with a written spec, a threat model, three independent verifiers held to identical
verdicts, and an honest record of being wrong in public.
If you maintain something and you think the premise is wrong in the other direction — that you do have this problem — that is the one thing worth telling me.
SCPE standardizes evidence, not content. It never says an artifact is good, true, or safe — it standardizes how verifiable evidence (who produced it, that it's untampered, and any signed attestations) travels with a hashed artifact and is checked offline.
One core, many specifications. SCPE Core — the envelope, identity, and verification — is
shared by every domain. Each SCPE Specification adds only its domain's conventions on top; the
profile label is surfaced but never changes the verify decision.
| Specification | For | Seals | Example |
|---|---|---|---|
| SCPE-C | Code | a diff (code-change) |
a pull request |
| SCPE-I | Images | the file's bytes (artifact) |
.png, .jpg |
| SCPE-V | Video | the file's bytes | .mp4, .mov |
| SCPE-A | Audio | the file's bytes | .wav, .mp3 |
| SCPE-M | Models | the file's bytes | .safetensors, .gguf |
| SCPE-DATA | Datasets | the file's bytes | .csv, .parquet |
| SCPE-D | Documents | the file's bytes | .pdf |
| SCPE-AR | Any artifact | the file's bytes | any file |
Identity is a (provider, subject) pair checked against keys from a fixed host table (github,
gitlab, codeberg) or from a keys file — one the verifier's owner supplied, or one bundled
inside the submission. The manifest never carries a hostname, so a contribution can't steer the
verifier at an attacker's host; it can enclose its own keys, which is why the verifier reports
the anchor that answered as key_source.
Open, and meant to stay that way. SCPE is — and will always be — open source: the specification and every reference implementation are free to read, implement, and fork. The goal is a single, open, universal standard for artifact provenance, and a standard only becomes universal if anyone can implement it without asking permission. So being open isn't a license choice here; it's the whole point.
Attribution tells you what. Provenance proves who — and that nothing was tampered with.
| SCPE is | SCPE is not |
|---|---|
| a minimal, transportable, offline-verifiable evidence format for a contribution or artifact — who produced it, proof it's untampered, and any signed attestations — with one core and thin per-domain profiles. | a code reviewer, a malware scanner, an artifact registry, a CI/CD security system, a compliance framework, or a hosted service. It doesn't judge whether the artifact is good or safe — only that the evidence checks out. |
For how SCPE relates to code review, build provenance, and attribution records, see docs/comparison.md.
A protocol, and the smallest set of things needed to prove it is one. Nothing here writes, reviews, or generates code — SCPE never looks at what a change does, only at who signed it and whether it still matches.
| What it is | |
|---|---|
spec/ |
The normative protocol: SPEC.md, the threat model, the manifest schema, and 18 test vectors that are the conformance contract. |
reference/standalone/verify_envelope.py |
The verifier. One stdlib-only file that imports nothing else in this repo. |
reference/producer.py |
The producer (scpe-envelope pack / pack-artifact / attest / verify / submit) — signs an envelope with a key the contributor already owns. |
impl/go/, impl/rust/ |
Two independent ports of the verifier, held to the same verdict by a differential test. |
action.yml |
The maintainer-side GitHub Action. It runs the verifier above, on the same format, out of its own checkout. |
scpe/ |
The scpe-protocol package: a stdlib-only CLI over that same verifier, plus the seal the Action renders and the opt-in badge. |
The package is a distribution of the protocol, not a second implementation of it: scpe verify
is a passthrough whose JSON and exit code are byte-identical to running the single file directly.
There is one envelope format, one verification algorithm, and one verdict — everything above is a
different way of reaching the same one.
Adopt at the level that fits your project, and upgrade later without changing the format.
| Level | What the repo requires | Contributor cost |
|---|---|---|
| L1 — Disclosure | An AI-use disclosure is present (an Assisted-by: trailer or a PR-template checkbox). |
Zero — it's the policy you may already have written, now enforced. |
| L2 — Signed envelope | A valid signed SCPE envelope: verifiable identity + an untampered diff. | One command to sign. |
| L3 — Countersignature (roadmap) | A third party (a reviewer, or the agent platform) co-signs. | — |
Higher levels include the lower ones. Most projects that already require AI disclosure need L1 today; the signature is the mechanism, the policy is the product — the same shape SLSA uses to sell levels. See docs/LEVELS.md.
-
Contributor (human or agent) packs the change into a signed envelope: a manifest (target repo, base commit, a SHA-256 of the exact diff, an AI-use disclosure, and an optional attribution record) signed with the SSH key already on their GitHub profile (
ssh-keygen -Y sign -n scpe/0.1). No new account.The key has to be published as an authentication key.
forgeverification readsgithub.com/<login>.keys, and GitHub serves only authentication keys there — a key added under Signing keys is real, is used bygit, and never appears at that URL, so a contribution signed with it can only ever reachkey_source: bundled. Add it withgh ssh-key add <key>.pub --title scpe(no--type signing). GitHub does publish signing keys, atapi.github.com/users/<login>/ssh_signing_keys, but<host>/<login>.keysis the shape the fixed provider table is built on; reading the signing-key endpoint per provider is open, not decided. Filing the key in the obviously-correct place and silently losing forge verification is the first thing that went wrong for the protocol's own author. -
It travels inside a normal pull request — the diff in the branch, the ~1–2 KB signed attestation embedded in the PR body. Merging leaves the repo history clean.
-
The owner's side re-derives everything itself, with no SCPE server involved: the diff's SHA-256 is recomputed from the PR and compared, and a seal is posted (or, in require mode, an unverifiable PR is rejected). There is one verifier, not two — the Action runs the same single-file verifier described below, out of its own checkout at the tag you pinned, so there is no second implementation to drift. What can differ between runs is only which key anchor answers, and the result always names it:
- Through the Action, a contribution cannot substitute the keys that judge it. The
transport carries
manifest.jsonandmanifest.sigand nothing else (SPEC §9), so there is no enclosed key set to find and the keys come fromgithub.com/<login>.keys, live, atkey_source: forge. A maintainer running air-gapped can hand it a keys file of their own instead — that reportsflag, and the seal says so, because "the keys this repo supplied" is a different claim from "the keys GitHub serves for that account". - Run directly, the same file is the general tool, auditable in ten minutes: it resolves a
keys file you pass it first, then any
keysfile bundled in the input, and only if neither answers, one HTTPS GET to the contributor's host. It reports which one answered askey_source, so an offline conformance run is never mistaken for a forge check.
- Through the Action, a contribution cannot substitute the keys that judge it. The
transport carries
What a verified result means depends on where the verifier got the keys — which it reports
as key_source. At forge it means exactly: a key the contributor's git host publishes for
this account signed exactly this change and this disclosure, and the diff you're looking at
matches, byte-for-byte after normalizing line endings, what they signed. At flag it means the
same, with the verifier owner's own key set standing in for the host. At bundled — keys
carried inside the submission, chosen by whoever sent it — it means only that these exact bytes
were signed by a key that arrived with them, and nothing about the named account. A consumer
that needs forge-backed identity MUST require key_source == "forge", or supply the key set
itself and require "flag".
Under every anchor it does not prove the code is safe or good (SCPE is not review), that the disclosure is honest (a signature proves who claimed, not that the claim is true), or anything if the key set that answered is compromised or attacker-chosen — that key set is the trust root. Read spec/THREAT_MODEL.md before relying on it: §2.1 lays out the three anchors and what a verdict is worth under each.
Add a workflow that verifies every PR and posts a seal. Set require to gate merges.
# .github/workflows/scpe.yml — the step. Copy BOTH files from docs/workflows/ for the
# fork-safe version: scpe.yml (verify) and its companion scpe-seal.yml (post the seal).
- uses: augbastos/scpe@v0.2.3
with:
level: "1" # 1 = disclosure lint · 2 = signed envelope required
require: "true" # fail the check on anything not verifiablePin the exact tag while the protocol is pre-1.0.
- Every tag is an immutable alias. It points at one commit and is never moved. A fix
arrives as a new tag you adopt by editing the pin, never as a silent change under the one
you already wrote. Both
v0.2.1andv0.2.2exist for that reason rather than becausev0.2was re-pointed. - The cost is that fixes do not reach you on their own — including security fixes. Watch
the releases, or let Dependabot's
github-actionsecosystem open the bump as a pull request you can read before merging. - A patch tag can change behaviour when the old behaviour was wrong.
v0.2.2refuses contributions thatv0.2.1accepted, becausev0.2.1accepted some it should not have. Read the CHANGELOG before moving a pin; "patch" here means the protocol did not change, not that nothing did. spec_versionis a separate number from the tag. The protocol isscpe/0.1across everyv0.2.xrelease. See CHANGELOG.md for the three axes and why reading one as the other is the easiest mistake to make here.- The
v0.1.xline is legacy, not merely old. It verifies an envelope format that is not inspec/, so upgrading from it is a behaviour change and not a patch — docs/MIGRATION.md has the steps.
Which tag to be on: v0.2.3. v0.2.1 and v0.2 accept a valid envelope replayed from
another repository; v0.2.2 and earlier write an unregistered domain into your README if you
run scpe init. Both are in SECURITY.md. The second one does not touch the
Action, so a workflow pinned at v0.2.2 is not exposed to it — but if you ever ran scpe init,
open your README and delete the badge block.
The Action uses a fork-safe two-job split, and the two jobs live in two files: the untrusted
job in scpe.yml (which runs contributor code) holds no secrets, and only the trusted follow-up
job in scpe-seal.yml posts the comment. Two files is a GitHub constraint, not a preference — a
workflow that names itself in its own workflow_run trigger fails to register at all. Neither
level installs anything in the
runner — both run stdlib-only Python straight out of the Action's own checkout, so the bytes that
decide a merge are the bytes of the tag you pinned, not whatever a package index serves that day.
Check out with fetch-depth: 0: level 2 recomputes the diff as git diff <base>...<head>, and the
default shallow checkout has no base commit to compare against.
The seal it posts carries more than the verdict — a risk band, a file/line count, an optional test
run. Those are the Action's own reporting layer, not part of the protocol: no status, no
verified, and nothing in spec/ depends on them. The verdict is the verifier's; the rest is a
report.
The reference verifier is one stdlib-only file — read it top to bottom and you know exactly what a seal means:
python reference/standalone/verify_envelope.py <envelope.zip> --keys <login.keys>
# → [OK] verified (attestations: none) [keys: flag]
# (or a precise reject status: tampered, signature-invalid, …)[keys: …] names the anchor that answered — flag here, because --keys supplied the key set.
Without that flag, a keys file sitting beside manifest.json in the input answers as
bundled, and only if neither is present does the verifier fetch from the contributor's host as
forge.
The 18 normative test vectors are the conformance contract for status:
an implementation that produces their expected statuses conforms to the spec's status behaviour.
They don't pin every normative requirement — no vector carries an expected key_source, so
passing all eighteen does not by itself show that the key_source MUST is honoured; that one is
checked by inspection. Every vector ships its own keys file so the suite runs offline, and no
vector reaches the forge anchor.
Cost
| Measured | |
|---|---|
| PR-body attestation (manifest + sig, base64) | 1.1–1.5 KB |
| Standalone envelope (3-file / 27-line PR, zipped) | ~1.5 KB |
| Verify wall-time | ~200 ms cold process · ~31 ms warm |
An artifact subject adds its payload size on top of ~800 B fixed overhead. Order-of-magnitude,
single machine — not a formal benchmark suite. Re-run it yourself rather than taking the number:
python tests/bench_verify.py <envelope.zip> --keys <login.keys> prints the median of 15 runs
for both paths. The cold figure is a fresh interpreter per run; the warm one re-verifies
in-process, which is what a batch consumer sees after import cost is paid once. Both use the
flag anchor, so no network is in the measurement.
- Not code review. Copilot / CodeRabbit judge whether the code is good. SCPE proves who and integrity.
- Complements attribution, doesn't compete. Agent Trace and git-ai record who/what wrote which lines, self-reported; SCPE carries that record inside the signed manifest, making it verifiable.
- A different layer from build provenance. Sigstore / SLSA / in-toto attest artifacts and builds; SCPE attests a contribution, at the pull-request boundary.
- Direct prior art:
patatt+b4(kernel.org) have run this shape — the contributor self-signs a patch, the recipient verifies independently, no CA, no server — on the Linux kernel's mailing list for years. They differ from SCPE in where the key comes from:patattkeeps a keyring in the repository, so the project curates who may sign; SCPE reads the keys the forge already publishes for the account, so there is nothing to curate and nothing to depend on but the forge. Same shape, different trust root, applied to the pull-request boundary instead of a mailing list.
v0.2.3 — early. This is a specification plus a reference implementation (a single-file
verifier, a producer, and a maintainer-side Action). The full test suite — including a 100-PR
stress proof and a local end-to-end — runs on every push; the CI badge above is its live
result. Two more independent verifiers, in Go and Rust, reach the same verdict as the Python
reference on every one of the 18 normative vectors, and a differential test runs mutated
manifests through all three and confirms they never disagree. That three-way agreement covers
the path those vectors exercise — a directory input checked offline against a supplied keys
file, the flag anchor; no vector exercises the network fetch. The Rust port goes no further: it has no network key fetch (--keys is required) and
does not parse the zip-envelope or in-PR-body attestation input shapes that Python and Go both
accept. There is no external adoption yet. It is not a hosted service and never will be.
v0.1.x is legacy: its signed-envelope path verifies a format that is not in this spec. The
Action at those tags checked a second envelope format that shipped inside the package and was
deleted in 0.2.0. A workflow still pinned there does not fail — it keeps posting seals for a
format nothing here produces — and now that 0.2.x is on PyPI it breaks outright instead, because that
Action resolved its verifier from the package index at run time rather than from its own
checkout. CHANGELOG.md has what moved; docs/MIGRATION.md
has what to do about it.
- spec/SPEC.md — the protocol (
scpe/0.1) - spec/THREAT_MODEL.md — what it does and does not defend against
- spec/FAQ.md — why SSH, why the PR body, relation to Agent Trace / Sigstore / patatt
- docs/LEVELS.md — the L1 / L2 / L3 assurance ladder
- CHANGELOG.md — what changed per release, and which version axis it moved
- docs/MIGRATION.md — upgrading a
v0.1.xpin, and why it doesn't warn you
Code is Apache-2.0; the specification (everything under spec/) is
CC-BY-4.0. © 2026 Augusto Bastos.