Code references are against main @ 0b056f3.
The problem
The architecture page presents Arweave (Tier 3) as the layer that lets anyone verify repo history without trusting a gitlawb node. Two things stand in the way today:
- The anchored object is unsigned. On each push the node already builds a
RefCertificate (crates/gitlawb-node/src/cert.rs) that is signed with the node's Ed25519 key and carries the real old_sha, new_sha, pusher_did, and node_did. But that certificate is only stored locally. What actually goes to Arweave is a separate RefAnchor (crates/gitlawb-node/src/arweave.rs) — unsigned, with old_sha hardcoded to zeros, sent to Irys with https://arweave.net/<tx_id> as the read URL. The permanent record is a weaker, unsigned copy of facts the node has already signed.
- Nothing verifies it.
GET /api/v1/arweave/anchors reads the local Postgres mirror; it never fetches from Arweave or checks a signature. So "verify without trusting a node" isn't possible against the current implementation.
A few related facts worth noting:
- Anchoring is off by default (
GITLAWB_IRYS_URL defaults to ""), runs fire-and-forget in a tokio::spawn, and fires on every push.
- The push is cryptographically verified at ingest (
auth::require_signature: RFC 9421, Ed25519, content-digest checked), but the verified pusher signature is not persisted.
- Object IDs are SHA-1 (the node shells out to system
git), while the anchor's zero placeholder is 64 hex chars — a width mismatch to clean up alongside the old_sha fix.
This is also a good moment to act: Irys has moved to its own L1 datachain and is winding down Arweave bundling, so the current write path is on a deprecating route — but since anchoring is off by default, nothing breaks in the meantime.
What we'd like to verify, and what each requires
A verifier should be able to confirm history using only signed artifacts, a public immutable anchor, and hashes it recomputes — never by trusting a node's answer. That needs four things:
- Identity — verify the Ed25519 signature against the signer's key (
did:key is self-certifying; did:web/did:gitlawb add a resolution step).
- Authorized transition — the real
old→new, plus a pusher signature (not just a pusher_did field), plus maintainer countersignatures to the repo's threshold.
- Continuity — a per-ref chain so transitions can't be inserted, deleted, or reordered without breaking a signature.
- Non-equivocation — a public anchor the node is committed to, so a verifier can compare the history the node serves against the head it anchored and detect a fork.
Proposed changes
1. Carry the signed certificate, on Turbo. Move the write path from the raw Irys POST /upload to a Turbo upload via [permaweb/bundles-rs](https://github.com/permaweb/bundles-rs) (BundlerClient::turbo() → upload.ardrive.io). RefAnchor keeps its role — the cid and indexing tags — but its body becomes the embedded signed RefCertificate, so the anchor carries the real old_sha, the node signature, and the pusher instead of a separate unsigned copy. Embed the full certificate (not a hash) so the record is self-contained. Build it as an ANS-104 DataItem and sign the envelope with the node's Ed25519 key (Ed25519Core; the keypair is already in scope at the anchor site).
Store the data item ID as arweave_tx_id (dropping the Irys-specific name) rather than a hardcoded arweave.net URL — see change 4 for why retrieval shouldn't bake in one gateway. Also keep the Turbo receipt (deadline_height, timestamp, signature): it's a signed proof the anchor is in flight before it's on-chain, and lets a later check confirm it settled. Keep anchoring asynchronous (as all post-push work already is), retry a few times on failure, and mark the certificate row if it ultimately fails so gaps stay visible. Two prerequisites worth noting: a failed content pin shouldn't block the anchor (cid is best-effort), and the handler must issue a certificate per ref-update — today it signs only the first ref while anchoring iterates all of them. This is the foundation the rest builds on.
2. Capture the pusher signature. The push's RFC 9421 signature is verified at ingest and then discarded. Persist it — it covers @method, @path, and content-digest, so it proves the pusher authorized this push and binds them to the pushed bytes. For a stronger guarantee that binds the pusher directly to the old→new statement, have the client (git-remote-gitlawb) sign the certificate payload. Either way, source the pusher DID from the verified AuthenticatedDid extension rather than re-parsing the header.
3. Chain the certificates. Add a monotonic seq and a prev-link (hash of the previous certificate) to the certificate's signed payload. Verification then checks from == prev.to, that seq increments, and that the link matches, making rewrites detectable.
4. Verify against the anchor (gl verify + endpoint). Resolve the stored arweave_tx_id through a configurable gateway rather than a fixed URL — the ar:// gateway-agnostic pattern — since data isn't necessarily retrievable from any given gateway the instant it's uploaded (the receipt covers that gap). Fetch the data items by tag (App-Name, Repo, Ref), treat the gateway as untrusted, then:
- verify the ANS-104 envelope (
DataItem::verify()) and the embedded certificate's signature;
- check signatures against the maintainer threshold in force at that
seq;
- walk the chain for
from/to/seq/link continuity;
- compare the node's currently-served head against the anchored head (equivocation check);
- optionally re-hash commit objects against the anchored value.
The signature and chain checks are cheap; content re-hashing is opt-in. A gateway verification header, where present, is a fast-path before the full check.
Full threshold verification (step 4) additionally needs the maintainer set (.gitlawb/maintainers) to be signed and versioned and UCAN delegations to be presentable — out of scope here; the single-signature path works today.
What this proves — and doesn't
Proves: a specific pusher, by their own key, moved a ref from the real old to new at a timestamped moment; the node co-signed and publicly committed it; the records form an un-rewritten chain; the served history matches the anchored head; and content matches the signed hash when the bytes are available.
Does not prove: that content is available — a node can withhold a commit or skip anchoring; verification detects tampering and equivocation but can't compel disclosure. Non-equivocation holds only as tightly as anchoring is frequent. Identity is fully trustless only for did:key. Permanence of the proof is not permanence of the content (that remains the IPFS/Filecoin tier's job).
Suggested PR sequence
- Foundation: Turbo + carry the signed certificate (real
old_sha, pusher, cid, arweave_tx_id, receipt), anchored async with retry-on-failure. Retires Irys and lands the integrity fix.
- Capture the pusher signature.
- Chain the certificates + continuity check.
gl verify + endpoint.
Schema and config changes
These need migrations or config updates, so flagging them up front:
Config (env vars):
- Rename
GITLAWB_IRYS_URL to a provider-neutral bundler URL (e.g. GITLAWB_BUNDLER_URL, default https://upload.ardrive.io); empty still disables anchoring.
- Add a read gateway used to resolve
arweave_tx_id (e.g. GITLAWB_ARWEAVE_GATEWAY), replacing the currently hardcoded arweave.net.
ref_certificates table:
- Add
seq and prev (the chain) and pusher_sig. seq/prev are signed, so the certificate's signing payload changes — version it, don't just add columns.
arweave_anchors table:
- Rename
irys_tx_id → arweave_tx_id; drop arweave_url (resolve from the ID + gateway instead).
- Make the tx id nullable and add an anchor status — it's
NOT NULL and success-only today, but async-with-retry means a row can be pending or failed.
- Add receipt fields (
deadline_height, signature) and a link to the certificate. With the certificate now carried in the anchor, the denormalized columns here (owner_did, ref_name, old_sha, new_sha) are derivable — worth deciding whether to slim the table.
Format: the on-Arweave schema tag (gitlawb/ref-update/v1) and the certificate payload both move to v2 (see open questions on preserving v1 for existing consumers).
Open questions
- How far to take maintainer countersignatures and the signed/versioned
.gitlawb/maintainers set now vs. later?
- The anchored format changes (the body becomes the certificate) — ship it as a deliberate
v2 tag, or keep v1 for any existing consumers?
- Does Turbo's <100 KiB free tier cover per-push volume at scale, or do busy nodes need funded uploads?
- Per-push anchoring vs. the page's "merge events, major releases, on-demand" — which is intended?
- The signed record identifies the repo by an internal UUID (
repo_id); should it use a verifiable repo identity (e.g. a repo DID) so repo identity is trustless too?
Disclosure: I work in the Arweave ecosystem (AR.IO). These changes are provider-neutral — they harden the anchoring regardless of bundler, and bundles-rs is the Arweave team's own Rust library, not AR.IO-specific. We do operate turbo. Happy to implement any or all of this as PRs.
Code references are against
main@0b056f3.The problem
The architecture page presents Arweave (Tier 3) as the layer that lets anyone verify repo history without trusting a gitlawb node. Two things stand in the way today:
RefCertificate(crates/gitlawb-node/src/cert.rs) that is signed with the node's Ed25519 key and carries the realold_sha,new_sha,pusher_did, andnode_did. But that certificate is only stored locally. What actually goes to Arweave is a separateRefAnchor(crates/gitlawb-node/src/arweave.rs) — unsigned, withold_shahardcoded to zeros, sent to Irys withhttps://arweave.net/<tx_id>as the read URL. The permanent record is a weaker, unsigned copy of facts the node has already signed.GET /api/v1/arweave/anchorsreads the local Postgres mirror; it never fetches from Arweave or checks a signature. So "verify without trusting a node" isn't possible against the current implementation.A few related facts worth noting:
GITLAWB_IRYS_URLdefaults to""), runs fire-and-forget in atokio::spawn, and fires on every push.auth::require_signature: RFC 9421, Ed25519, content-digest checked), but the verified pusher signature is not persisted.git), while the anchor's zero placeholder is 64 hex chars — a width mismatch to clean up alongside theold_shafix.This is also a good moment to act: Irys has moved to its own L1 datachain and is winding down Arweave bundling, so the current write path is on a deprecating route — but since anchoring is off by default, nothing breaks in the meantime.
What we'd like to verify, and what each requires
A verifier should be able to confirm history using only signed artifacts, a public immutable anchor, and hashes it recomputes — never by trusting a node's answer. That needs four things:
did:keyis self-certifying;did:web/did:gitlawbadd a resolution step).old→new, plus a pusher signature (not just apusher_didfield), plus maintainer countersignatures to the repo's threshold.Proposed changes
1. Carry the signed certificate, on Turbo. Move the write path from the raw Irys
POST /uploadto a Turbo upload via[permaweb/bundles-rs](https://github.com/permaweb/bundles-rs)(BundlerClient::turbo()→upload.ardrive.io).RefAnchorkeeps its role — thecidand indexing tags — but its body becomes the embedded signedRefCertificate, so the anchor carries the realold_sha, the node signature, and the pusher instead of a separate unsigned copy. Embed the full certificate (not a hash) so the record is self-contained. Build it as an ANS-104DataItemand sign the envelope with the node's Ed25519 key (Ed25519Core; the keypair is already in scope at the anchor site).Store the data item ID as
arweave_tx_id(dropping the Irys-specific name) rather than a hardcodedarweave.netURL — see change 4 for why retrieval shouldn't bake in one gateway. Also keep the Turbo receipt (deadline_height, timestamp, signature): it's a signed proof the anchor is in flight before it's on-chain, and lets a later check confirm it settled. Keep anchoring asynchronous (as all post-push work already is), retry a few times on failure, and mark the certificate row if it ultimately fails so gaps stay visible. Two prerequisites worth noting: a failed content pin shouldn't block the anchor (cidis best-effort), and the handler must issue a certificate per ref-update — today it signs only the first ref while anchoring iterates all of them. This is the foundation the rest builds on.2. Capture the pusher signature. The push's RFC 9421 signature is verified at ingest and then discarded. Persist it — it covers
@method,@path, andcontent-digest, so it proves the pusher authorized this push and binds them to the pushed bytes. For a stronger guarantee that binds the pusher directly to theold→newstatement, have the client (git-remote-gitlawb) sign the certificate payload. Either way, source the pusher DID from the verifiedAuthenticatedDidextension rather than re-parsing the header.3. Chain the certificates. Add a monotonic
seqand a prev-link (hash of the previous certificate) to the certificate's signed payload. Verification then checksfrom == prev.to, thatseqincrements, and that the link matches, making rewrites detectable.4. Verify against the anchor (
gl verify+ endpoint). Resolve the storedarweave_tx_idthrough a configurable gateway rather than a fixed URL — thear://gateway-agnostic pattern — since data isn't necessarily retrievable from any given gateway the instant it's uploaded (the receipt covers that gap). Fetch the data items by tag (App-Name,Repo,Ref), treat the gateway as untrusted, then:DataItem::verify()) and the embedded certificate's signature;seq;from/to/seq/link continuity;The signature and chain checks are cheap; content re-hashing is opt-in. A gateway verification header, where present, is a fast-path before the full check.
Full threshold verification (step 4) additionally needs the maintainer set (
.gitlawb/maintainers) to be signed and versioned and UCAN delegations to be presentable — out of scope here; the single-signature path works today.What this proves — and doesn't
Proves: a specific pusher, by their own key, moved a ref from the real
oldtonewat a timestamped moment; the node co-signed and publicly committed it; the records form an un-rewritten chain; the served history matches the anchored head; and content matches the signed hash when the bytes are available.Does not prove: that content is available — a node can withhold a commit or skip anchoring; verification detects tampering and equivocation but can't compel disclosure. Non-equivocation holds only as tightly as anchoring is frequent. Identity is fully trustless only for
did:key. Permanence of the proof is not permanence of the content (that remains the IPFS/Filecoin tier's job).Suggested PR sequence
old_sha, pusher,cid,arweave_tx_id, receipt), anchored async with retry-on-failure. Retires Irys and lands the integrity fix.gl verify+ endpoint.Schema and config changes
These need migrations or config updates, so flagging them up front:
Config (env vars):
GITLAWB_IRYS_URLto a provider-neutral bundler URL (e.g.GITLAWB_BUNDLER_URL, defaulthttps://upload.ardrive.io); empty still disables anchoring.arweave_tx_id(e.g.GITLAWB_ARWEAVE_GATEWAY), replacing the currently hardcodedarweave.net.ref_certificatestable:seqandprev(the chain) andpusher_sig.seq/prevare signed, so the certificate's signing payload changes — version it, don't just add columns.arweave_anchorstable:irys_tx_id→arweave_tx_id; droparweave_url(resolve from the ID + gateway instead).NOT NULLand success-only today, but async-with-retry means a row can be pending or failed.deadline_height, signature) and a link to the certificate. With the certificate now carried in the anchor, the denormalized columns here (owner_did,ref_name,old_sha,new_sha) are derivable — worth deciding whether to slim the table.Format: the on-Arweave schema tag (
gitlawb/ref-update/v1) and the certificate payload both move to v2 (see open questions on preserving v1 for existing consumers).Open questions
.gitlawb/maintainersset now vs. later?v2tag, or keepv1for any existing consumers?repo_id); should it use a verifiable repo identity (e.g. a repo DID) so repo identity is trustless too?Disclosure: I work in the Arweave ecosystem (AR.IO). These changes are provider-neutral — they harden the anchoring regardless of bundler, and
bundles-rsis the Arweave team's own Rust library, not AR.IO-specific. We do operate turbo. Happy to implement any or all of this as PRs.