Runtime catalog
Official runtime packages are authorized by a signed catalog that binds each
package archive digest (see the
runtime package archive) to a package id, runtime
id, version, runtime API version, platforms, one attestation digest per
platform and a core version range. A release block names the core release
(version, source commit and the binary set digest of every target) the catalog
belongs to. A third-party archive is trusted only by an explicit owner-supplied
digest. Packages carry no signature
of their own. Implemented in crates/package (catalog.rs,
canonical_json.rs); the crate verifies catalogs, while installing,
the registry and the CLI are separate layers.
Trust model
Section titled “Trust model”- The trust anchor is supplied by the caller: root Ed25519 public keys with
a validity window (
not_before <= now < not_after, Unix seconds) and the key ids revoked so far. The crate embeds no production key; the daemon reads the anchor from the file the release bundle ships beside it (see Trust anchor file). - A key id is the lowercase hex SHA-256 of the 32 raw public key bytes.
- A catalog may carry a
key_chainof key records. Each record names a new key (id, public key, window) and the earlier key that endorses it (endorsed_by), with the endorser’s signature over the record. Records must appear after their endorser, so a chain starts at a root. This is how signing keys rotate without shipping a new anchor. - A catalog is accepted when at least one of its detached signatures verifies
(
verify_strict) from a key that is valid atnow, whose whole lineage up to the root is valid atnow, and none of whose lineage is revoked. An expired root therefore ends the life of every key chained from it. - Revocation: a key id is revoked by the anchor’s list or by the catalog’s
own
revoked_key_ids, which applies to the signer and its lineage (a catalog that revokes its own signer is rejected). The caller addsVerifiedCatalog::revoked_key_ids()to the anchor’s list for later checks.revoked_digestsare never authorized, and an entry whose digest is revoked makes the catalog invalid. - Anti-rollback:
sequenceis a signed, monotonically increasing counter. The caller persists the highest accepted sequence and passes it as the high-water mark (Registry::record_catalogpersists it together with the accumulated revoked key ids incatalog-state.json, andRegistry::catalog_statereads them back); a lower sequence is rejected, an equal one is accepted so an installed catalog can be re-verified.expires_atbounds how long a frozen catalog is honored.
Document format
Section titled “Document format”runtime-catalog.json is one JSON object:
{ "schema_version": 2, "catalog": { "schema_version": 2, "sequence": 7, "expires_at": 1790000000, "release": { "version": "0.35.0", "commit": "<40 hex>", "binary_sets": [ { "target": "x86_64-unknown-linux-gnu", "digest": "sha256:<64 hex>" } ] }, "revoked_key_ids": [], "revoked_digests": [], "entries": [ { "package_id": "pohunek.runtime.codex", "runtime_id": "codex", "version": "1.0.0", "digest": "sha256:<64 hex>", "runtime_api": 1, "platforms": ["x86_64-unknown-linux-gnu"], "attestations": [ { "platform": "x86_64-unknown-linux-gnu", "digest": "sha256:<64 hex>" } ], "core": ">=0.33.0, <0.40.0" } ] }, "key_chain": [], "signatures": [{ "key_id": "<64 hex>", "signature": "<128 hex>" }]}Every object rejects unknown fields. Numbers are non-negative integers only.
Hex is lowercase. core is a semver range that must constrain the version.
Only schema 2 is read: a document declaring another schema_version is refused
with UnsupportedSchemaVersion before its shape is judged, and there is no
compatibility path for schema 1.
release.versionis plainX.Y.Z(no pre-release or build part) andrelease.commitis 40 lowercase hex characters.release.binary_setslists one{target, digest}per target, at least one, targets unique, each a[a-z0-9._-]name.digestissha256:<64 hex>of the canonical set of CLI, daemon and session worker binaries for that target; the catalog treats it as opaque. The release tooling writes the list in ascending target order.runtime_apiis the package’s runtime API version, at least 1; the tooling reads it from the archive’sruntime.toml.attestationsholds one{platform, digest}per entry platform, withdigest=sha256:<64 hex>of the attestation file bytes; the tooling writes them in ascending platform order.
The daemon does not enforce the release block, runtime_api or the
attestations yet: they are verified, signed and exposed (VerifiedCatalog::release(),
VerifiedEntry::runtime_api() and VerifiedEntry::attestations()), and the
authorization decision is unchanged.
Canonical signed bytes
Section titled “Canonical signed bytes”The signature covers pohunek-runtime-catalog-v2\n followed by the canonical
JSON of the catalog member: object keys in ascending byte order, no
insignificant whitespace, strings escaped by serde_json. A key record
endorsement covers pohunek-catalog-key-record-v1\n plus the canonical JSON of
the record without its signature. The separators keep a signature from being
replayed as another kind of signature; the catalog separator carries the schema
version, so a signature over a schema 1 document never verifies a schema 2
document and the reverse. Whitespace and key order of the file do
not matter; the verifier recomputes the canonical bytes. Input with duplicate
object keys, trailing data, floats, negative numbers or more than 1 MiB is
rejected before any signature is considered.
Entry rules
Section titled “Entry rules”shellis never a validruntime_idin a catalog and is never authorized.- A catalog is the only way to authorize
codex,claudeorhermes; the daemon records such a package asofficialand lets only anofficialrecord serve the id (see the runtime packages guide):VerifiedCatalog::authorize(package_id, runtime_id, digest)returnsOfficialonly for an entry matching all three, andNotAuthorizedotherwise (including revoked digests). The package and runtime ids come from the descriptor inside the archive, so the daemon first requires an unrevoked entry with the raw archive digest (package_untrustedotherwise) and only then decompresses the archive and makes this decision. - A runtime id maps to exactly one package id and the reverse.
(package_id, version)anddigestare each unique. Platforms are a non-empty set of[a-z0-9._-]names. - Every entry platform has exactly one attestation, and every attestation
platform is an entry platform (
MissingAttestation,AttestationPlatform; an empty, oversized, repeated or badly named list isAttestations). - Every entry platform is a
release.binary_setstarget (NoBinarySet). The platform string is the core target triple the daemon reports for its host, such asx86_64-unknown-linux-gnuoraarch64-apple-darwin. - Every entry’s
corerange admitsrelease.version(CoreExcludesRelease) andruntime_apiis at least 1 (RuntimeApi). - The release block itself is refused with
CatalogError::Releasefor a bad version or commit, an empty or badly named binary set list, or a repeated target. A malformed digest (notsha256:plus 64 lowercase hex) is aSchemaerror. LocalTrust::ExplicitDigest(digest)authorizes an archive only when its digest equals the owner-supplied one and its runtime id is notshell,codex,claudeorhermes. Its result type isLocalAuthorization::Local, which cannot be confused withAuthorization::Official.
Trust anchor file
Section titled “Trust anchor file”The daemon reads its anchor once at startup from runtime-catalog-anchor.json
in the directory of its own executable (<prefix>/libexec/pohunek/<version>/
of an installed layout). No environment variable or option selects another
file, and no key is compiled in. The file holds public keys only:
{ "schema_version": 1, "roots": [ { "key_id": "<64 hex>", "public_key": "<64 hex>", "not_before": 1, "not_after": 4102444800 } ], "revoked_key_ids": []}A release lists two roots with distinct custody: an offline primary root held
by the owner, valid for the long term, and a CI secondary root that signs
catalogs from the release pipeline, valid for a shorter window and revocable
through revoked_key_ids without touching the primary. A catalog verifies when
either listed root signed it inside that root’s window; revoking the CI key id
refuses the catalogs it signed while the primary root keeps verifying:
{ "schema_version": 1, "roots": [ { "key_id": "<64 hex, CI>", "public_key": "<64 hex>", "not_before": 1790000000, "not_after": 1810000000 }, { "key_id": "<64 hex, primary>", "public_key": "<64 hex>", "not_before": 1, "not_after": 4102444800 } ], "revoked_key_ids": []}(roots appear in ascending key_id order; the example values are illustrative.)
Every object rejects unknown fields; the strict JSON subset of the catalog
applies and the file is at most 16 KiB. key_id is a cross-check: it must equal
the SHA-256 of public_key, otherwise the whole anchor is refused. Up to 8
roots and 64 revoked key ids.
The daemon distinguishes three states, reported by daemon.doctor as the
catalog_trust_anchor check:
- Absent (no file):
plugin install --cataloganswersofficial_trust_unavailable; the doctor check iswarn. Development builds and any install without a bundled anchor are here. - Invalid (the file or its location cannot be trusted):
--cataloganswersofficial_trust_anchor_invalid, the doctor check isfail, and the rest of the daemon keeps running, including installs trusted by explicit digest. An anchor is never partly used and never treated as absent. Causes: the daemon executable location is unknown, the file or directory cannot be read, a directory on the executable’s path or the file is writable by another account or owned by one (only the daemon’s user and root are accepted, and directories must not be group or world writable), the file is a symbolic link, not a regular file, hard-linked, group or world writable, carrying a macOS extended ACL entry that lets another principal change it (an ACL that cannot be read counts as unreadable), larger than the limit, malformed, has a key id that does not match its key, an empty validity window or repeated roots. - Loaded: the check is
ok.
The integrity bar equals the daemon binary’s: whoever can rewrite the anchor
can replace pohunekd. The file is public, so unlike the owner-private stores
it may be 0644 or 0444; only group or other write permission is refused,
and on macOS an allow ACL entry carrying a write, delete, append, attribute,
security or ownership permission counts as write permission (read-only allow
entries and deny entries are accepted).
The anchor is read at startup, so changing it requires a daemon restart.
Release tooling
Section titled “Release tooling”cargo xtask catalog builds, signs and verifies catalogs; it embeds no key and
the signing key never appears in a command line, an environment variable, a
log or an output:
catalog build --spec <file> --output <file>turns a spec (schema_version2,sequence,expires_at,release{version, commit, binary_sets},revoked_key_ids,revoked_digestsandpackages, each{archive, platforms, attestations, core}witharchiverelative to the spec) into the unsigned catalog body. Digest, package id, runtime id, version andruntime_apiare read from the archive and itsruntime.toml; the spec cannot supply them.coreis mandatory in the spec. The body is checked with the rules the verifier applies (package::check_catalog) before it is written, so a catalog the daemon would refuse is not produced. Entries are sorted by package id and version, and platforms, binary sets and attestations by name, so the output is deterministic.catalog sign --catalog <file> --key-file <file> --key-id <hex> --output <file>signs the body. The key file holds the 32-byte Ed25519 seed as 64 lowercase hex characters; it must be a regular file owned by the caller with no group or other permission bits, one link and no symlink, and on macOS no extended ACL beyond deny entries, judged on the opened handle, or the command refuses it (an unreadable ACL is refused too).--key-idmust equal the id derived from the key. The result is verified against an anchor of the signer’s own key before it is written, so an expired catalog or an invalid entry is refused instead of emitted. Signatures are deterministic.catalog verify --catalog <file> --anchor <file> [--high-water N]checks a catalog against an anchor file exactly as the daemon does and lists its entries.catalog anchor --root <public-key-file>:<not-before>:<not-after>... [--revoked-key-id <hex>]... --output <file>writes the anchor file for one or more roots. Each--rootkeeps one root’s three values together: the file holding its 64-hex public key, the first Unix second it may sign (inclusive) and the Unix second from which it may no longer sign (exclusive). The window is mandatory; there is no default. The window values are split off from the right, so the path may contain colons. The root count (1 to 8), repeated keys, empty windows and the revoked-id limit are judged by the same anchor validation the daemon applies, and a refused anchor writes no file. The roots are written in ascending key id order, so the argument order does not change the bytes.catalog public-key --key-file <file>prints the key id and public key.
cargo xtask release assemble builds the release catalog from the producer
artifacts with the same build, sign and verify code: the sequence is derived
from the version (major << 40 | minor << 20 | patch, each component below
2^20), expires_at is the commit time plus catalog_validity_days from
packaging/release-policy.json, every entry’s core is =<version>, an
entry’s platforms are the targets with an attested matrix row, and each
digest is recomputed from bytes. cargo xtask release verify-inventory re-verifies a finished bundle. See
release bundle.
A catalog has no per-entry expiry: expires_at covers the whole document, and a
catalog that lists a digest it also revokes is invalid as a whole. Signing-key
custody is the owner’s; the tooling holds no key. The production anchor and its
roots are fixed by the next section.
Production trust anchor
Section titled “Production trust anchor”packaging/runtime-catalog-anchor.json is the anchor every release ships. It
lists two roots and no revoked key ids:
| Role | Key id | Valid from (inclusive) | Valid until (exclusive) |
|---|---|---|---|
| CI secondary, signs routine release catalogs | e68204fe73c49ca6df7892bda5bdbb9a1694bf240e0e2d8ed80268574c8cd771 |
1791417600 (2026-10-08T00:00:00Z) | 1854576000 (2028-10-08T00:00:00Z) |
| Offline primary, held by the owner | f52cf66e089130ee817e18db5b52db21ee6b002c29615f47d80b168931ba89fe |
1791417600 (2026-10-08T00:00:00Z) | 2107036800 (2036-10-08T00:00:00Z) |
The inputs are public only: packaging/catalog-trust/primary.pub and ci.pub
(64 lowercase hex characters and a newline each) and
packaging/catalog-trust/roots.txt (one <key-file>:<not-before>:<not-after>
line per root, the key file relative to roots.txt). The finished anchor is
checked in and a test in crates/xtask/src/catalog.rs regenerates it with the
catalog anchor code path and requires byte equality, so the file cannot drift
from its inputs.
Rotation rule: the CI root is rotated before its not_after by a key_chain
record endorsed by the primary, and a new anchor is shipped with the next
release. The CI root signs routine release catalogs; the primary signs only
CI-key endorsement and CI-revoking catalogs.
Daemon archives (pohunek-daemon-* for glibc, musl and macOS arm64, the only
archives holding pohunekd) carry runtime-catalog-anchor.json at the archive
root, beside the pohunekd executable, with mode 0644; packaging/stage-archive
refuses to stage a daemon archive without it. CLI and relay archives carry no
anchor.
pohunek service install and upgrade copy the staged anchor into the version
directory next to the daemon (<prefix>/libexec/pohunek/<version>/, mode 0644)
so the catalog_trust_anchor doctor check is ok for an installed release
layout. An anchor that is not a valid trust anchor is refused with
service_staged_anchor_invalid and nothing is published; a build without one
installs and the daemon stays in the absent state. A version directory is
immutable, so republishing it with a different or missing anchor is
service_version_conflict.
Limits
Section titled “Limits”Named constants in crates/package/src/catalog.rs and release.rs: catalog 1
MiB, 256 entries, 16 platforms per entry (64 bytes each), 16 attestations per
entry (one per platform), 16 binary sets per release, core range 128 bytes,
16 key records,
8 signatures, 64 revoked key ids, 1024 revoked digests, 8 anchor roots.
Errors are typed (CatalogError) and never carry catalog content.