by Winch Labs

Security

Signed receipts

A review row says what happened. A receipt answers a different question, and it is the one an auditor is actually asking: has this record been altered since it was written?

“Trust our database” is not an answer to that. So receipts are append-only and hash-chained, and on an instance that signs, ed25519-signed.

What a receipt records

Each receipt pins one gate decision to one commit:

  • What was judged: the repository, the PR, the head SHA, and — the part most tools skip — what it was judged against: the convention document version (rcd_version) and a hash of the rule set (rule_set_hash). Honestly stated: today that hash covers the rules that fired, not the full enabled set, so it cannot yet distinguish “clean under 15 rules” from “clean under 120”. That gap is named rather than papered over.
  • What was found: every finding with its rule id, severity, file and line — including findings a PR author never saw. A finding’s state says whether it was shown, muted, or running in shadow, so a reader can never conclude “the team was warned” about a silenced finding.
  • What humans did: overrides (break-glass, dismissals) with actor and reason, and PR approvals — each approval flagged if it came from a bot, because an approval from a bot is not evidence a person looked.
  • Who — or what — wrote the change: the authorship provenance class.
  • The chain fields: prev_hash, this receipt’s hash, and on a signing instance, signature and key_id.

When one is written

A reviewed receipt is appended after every gate decision — clean pull requests included. The PR comment stays silent on a clean change because silence toward an author is a courtesy; the receipt exists either way, because “we reviewed every change” cannot be shown without a record for the ones that passed.

A merge appends a second receipt rather than updating the first: review time and merge time are two separately attested moments. If the merged head was never reviewed, the merge receipt says exactly that instead of borrowing an older review’s findings.

Receipt identity is keyed by the PR and the head SHA — each review is a claim about one commit. Two consequences:

  • Re-reviewing an unchanged head is refused as a duplicate. That is ledger behaviour, not an error: the claim already exists and is never rewritten.
  • A push creates a new head and therefore a new receipt.

A receipt write that fails is logged and never costs the author the review itself — and the resulting hole is visible in an export rather than silently closed.

The chain

Each organisation has exactly one chain; chains never cross tenants. A receipt’s hash is the SHA-256 of its canonical JSON — object keys sorted at every depth, the hash and signature fields excluded — and the previous receipt’s hash is part of that digest. Altering any historical receipt therefore breaks every receipt after it. Appends are serialised against the chain head inside a transaction, so two PRs merging in the same instant cannot fork the chain.

Every surface that verifies — the UI, the export, the CLI — speaks the same four-state vocabulary:

Verdict Meaning
verified The chain is intact and every receipt in it is validly signed by a published key.
unsigned Internally consistent; nothing (or not everything) is signed. The normal, healthy state of an instance without a signing key.
chain_broken A hash or link failed — including a signature that does not verify or names an unpublished key. The tamper signal.
no_records The selection is empty. Deliberately distinct: no records is not a pass, and never renders like a clean year.

Signing, and why a signed instance can still say “unsigned”

Signing turns consistency into authenticity — the difference is its own page. The operational facts:

  • An operator sets GARBOARD_RECEIPT_KEY (an ed25519 seed); the instance publishes its public keys, unauthenticated, at /.well-known/garboard-receipts.json — an auditor verifying your receipts must not need your permission to do it.
  • Partial signing never upgrades the verdict. If any receipt in the assessed range is unsigned, the range reads unsigned as a whole. An organisation whose chain predates its signing key will read “consistent, not signed” for any period that includes those early receipts — permanently, and by design: receipts are never re-signed after the fact, because backdating signatures is exactly what a signature exists to prevent. Assess a period that starts after the key did, and it reads verified.
  • A wrong signature is not softened to unsigned — it is chain_broken. Absence of proof and forged proof are different events.

What is in a bundle

An exported evidence bundle contains exactly six things — what the gate decided and what it chose not to say:

  • SUMMARY.md — the period, the numbers, and the limits stated before any number, plus a mapping of SOC 2 and ISO/IEC 27001 controls to what the bundle does and does not show. Its own opening line: this bundle is evidence, not an attestation.
  • public-key.json — the signing state and published key set, or an explicit machine-readable statement that no key exists. Its signed field is true only when the whole bundle verifies as signed — never merely because a key exists. The file works verbatim as the --keys input to offline verification.
  • chain.txt — the chain order (id, prev_hash, hash) with instructions for checking it by hand.
  • pr_events.csv — the coverage denominator; see below.
  • suppressions.csv — findings that were recorded and shown to nobody: muted rules, shadow rules, with actor and timestamp.
  • receipts/<id>.json — every receipt exactly as stored.

Export is an admin-only action that mints a short-lived signed link; both the export and the download are audited, attributed to whoever pulled the bytes. An empty selection is refused rather than producing a bundle that reads like a clean quarter. The data files are byte-reproducible: two exports of the same selection can be diffed.

Coverage: “did you look at everything?”

pr_events.csv records one row per webhook delivery with a closed set of eight dispositions — reviewed, duplicate, draft, no-infra, disconnected, unbound, stale-dropped, error. That is the denominator that lets an auditor ask “did you look at everything?” rather than only “what did you find?”, and it is what the Reports screen’s “N seen · N gated” strip counts.

Honesty at the edges is explicit: a period that starts before coverage recording began is labelled Partial, an organisation with no coverage rows at all is labelled not tracked (distinct from a real zero-delivery period), and a truncated read labels every figure a floor. Offline garboard gate and garboard scan runs produce neither a receipt nor a coverage row — they are your CI’s record, not this ledger’s.

Suppression is not deletion

A muted or shadowed finding is suppressed from the comment, not erased. It is recorded, it is counted, and it is in the bundle. Silence towards a pull request author is not silence towards an auditor — a tool where “mute” meant “delete” would let an organisation quietly launder its own findings, and the evidence bundle would be worth nothing.

Retention

No code path deletes a receipt; the store has no delete to call. Opt-in retention (GARBOARD_AUDIT_RETENTION_DAYS) prunes the audit log and coverage rows on the same window — never receipts. A removal forced from outside the application cannot be quiet: the next whole-chain verification reports chain_broken at the first affected row.

To verify a bundle without trusting us — or your own instance — see verifying receipts offline.