Signed corpus bundle format (EXP-15)
Status: implemented — sprout corpus verify|install. See
ADR-0022 for the trust-model decisions
behind this design, and docs/ideation/03-expansions.md (EXP-15) for the original pitch.
This lets a third-party publisher (an extension service, a subject-matter expert) ship a horticulture corpus Sprout can verify and install without a fork — while making it structurally impossible for that corpus to alter Sprout's own routing or never-certify- "safe" deny-list strings.
The bundle
A .sproutcorpus file is a gzip-compressed tar archive:
manifest.yaml # the ONLY file a signature covers; source of truth for everything
signature/
dev.sig # raw Ed25519 signature (dev-ed25519 scheme, test/dev only), or
sigstore.json # a real Sigstore bundle (sigstore-keyless scheme, production)
processed/*.md # passages, same shape as this repo's corpus/processed/*.md
toxicity.yaml # optional toxicity table
suites/*.yaml # optional eval cases
manifest.yaml carries a file_hashes map (relative path → sha256) covering every other
file in the archive, so the one signature over the manifest transitively covers the whole
bundle. Nothing outside manifest.yaml, signature/, processed/, toxicity.yaml, or
suites/ is a legal archive member — anything else (including an attempted
config/sprout.yaml) is rejected before extraction.
Trust model, in one paragraph
The bundle's own claim of who signed it is never authoritative. sprout corpus
verify|install looks the bundle's claimed publisher.id up in your config
(corpus_registry.trusted_publishers) and verifies the signature against that entry's
scheme/identity/issuer. An empty trusted_publishers list (the default) trusts nobody.
See ADR-0022 for why.
# config/sprout.yaml
corpus_registry:
registry_path: corpus/registry
license_allowlist: [CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0, MIT, Apache-2.0, Public Domain]
trusted_publishers:
- id: acme-botanicals
name: Acme Botanicals
signing_scheme: sigstore-keyless
identity: publishing@acme-botanicals.example # the signer's Sigstore SAN
issuer: https://accounts.google.com # the OIDC issuer that vouched for it
Using it
$ sprout corpus verify acme-tropicals-1.0.0.sproutcorpus
OK acme-tropicals 1.0.0 by Acme Botanicals (acme-botanicals)
signed: sigstore-keyless / publishing@acme-botanicals.example
license: CC0-1.0 documents: 42
$ sprout corpus install acme-tropicals-1.0.0.sproutcorpus
Installed acme-tropicals 1.0.0 from publisher acme-botanicals -> corpus/registry/acme-botanicals/acme-tropicals/1.0.0
Provenance recorded at corpus/registry/acme-botanicals/acme-tropicals/1.0.0/PROVENANCE.json
verify never writes anything. install verifies first, then extracts under
corpus_registry.registry_path/<publisher>/<name>/<version>/ and writes a
PROVENANCE.json sidecar recording the publisher, the bundle version, and the exact
signature that verified. Neither command ever touches corpus.path, corpus.manifest,
or config/ — an installed bundle lives in its own namespace by construction, so it
cannot alter guards.forbidden_safe_phrases or any other routing/deny-list string (those
are hardcoded/config-loaded in config.py, with no field on BundleManifest that could
carry an override — see corpus_bundle.py).
sigstore-keyless verification needs the corpus extra (uv sync --extra corpus from a
source checkout — there is no PyPI release, and the name sprout there is an unrelated
package, so this project distributes as sprout-plantcare; see the README's Install
section) and network access to Sigstore's public-good infrastructure. Every other Sprout command,
including dev-ed25519 bundle verification (development/CI test fixture only — not a
substitute for Sigstore's transparency log; see corpus_signing.py), stays offline.
What this does not do yet
An installed bundle sits in corpus/registry/ but is not yet wired into live retrieval:
its passages are not queryable by sprout ask, and its citations do not yet render a
publisher-provenance banner. That wiring — teaching ingest.py/retrieve.py/answer.py
about a second, registry-sourced corpus, and extending the citation guard's provenance
tag (corpus / household, see models.py) with a third registry:<publisher> case
that always renders a "from <publisher>, not reviewed by Sprout" banner — is a
scoped follow-up, not done in this change. The part landed here is the trust boundary
itself: the format, the signature/license/manifest/integrity enforcement, and the
structural guarantee that a third-party bundle cannot touch Sprout's own config.
Also not done here: a sprout corpus sign command. Real publishers sign with the
standard sigstore sign --bundle (or cosign sign-blob --bundle) CLI directly against
their own OIDC identity — Sprout only needs to verify, and shipping our own signer
would misleadingly suggest Sprout is a certificate authority rather than a verifier.