The portfolio manifest and its verifier
How Cosmic Bull's inventory of deployed packages is made machine-readable, and how every claim in it is proved against the chain and the repository.
Companion documents: catalog/DISCOVERY.md is the discovery record and the architecture decision; this file is the standard the decision produced. docs/DEPENDENCY_CLOSURE.md is the parallel standard for imports, and the two are deliberately separate — see §6.
1. The problem this exists to solve
Cosmic Bull's catalog is prose. Prose drifts. The repo-wide audit of 2026-09-22 found stale counts across thirteen documents, and catalog/README.md carried the sentence "nothing is deployed that this catalog does not record" — a true sentence, established by hand, in a conversation, with nothing that could re-establish it tomorrow.
The failure mode is not that someone lies. It is that the inventory grows, a record is written once, the chain moves, and the two quietly stop matching. Prose drift scales with the portfolio; hand-checking does not.
So the inventory gets a machine-readable form, and every claim that form makes gets checked against something that cannot be edited to agree with it.
2. The three artifacts
| Path | What it is |
|---|---|
catalog/portfolio.json | The manifest. One entry per deployed package, per chain. |
tools/verify_catalog.py | The verifier. Exit 0 VERIFIED, 1 DIVERGENT, 2 could not complete. |
tools/test_verify_catalog.sh | The verifier's tests. 89 cases; each negative one breaks exactly one check and greps for that finding's own text. |
Nothing new was deployed to produce them. The on-chain half of this capability is four RegisterService calls into the already-live service_registry realm — reuse, not construction. The refusal to build an on-chain catalog realm, and the four reasons for it, are recorded in catalog/DISCOVERY.md §5.
3. Three-way agreement
The manifest is not the source of truth for anything. It is a set of claims, and each one is held against two independent authorities:
catalog/portfolio.json
(claims)
/ \
/ \
live pearl-1 committed repository
(heights, bytes, txs, (source files, prose
imports, enumeration) records, hashes)
A claim that only the manifest asserts is not verified — it is recorded. The verifier says which is which, and its own output states the boundary:
Scope: chain-attested provenance, deployed bytes, imports, committed source and namespace completeness. NOT checked: the curated prose fields, the upstream GitHub SHAs, and dependency-closure equivalence.
That the curated prose is out of scope is asserted by a test, not merely written down: POS2 rewrites a summary to something deliberately false and requires the run to still pass. A documented blind spot that no test pins is a blind spot that will quietly close or quietly widen.
4. The checks
| Family | What it proves |
|---|---|
| C0 | The package is live on the chain — by status, not by "no error" |
| C1 | Chain-attested identity: creator and [addpkg] height |
| C2 | The deploy transaction: hash, height, success, framed type URL, gas, storage |
| C3 | The deployed bytes: vm/qfile content hashed and matched |
| C4 | The file set the chain holds matches the file set claimed |
| C5 | The committed source matches the deployed bytes |
| C6 | Declared divergences from the working tree carry a written reason |
| C7 | consumed_by is recomputed from the chain's own declared imports |
| C8 | Kind (/r/ vs /p/) agrees with the path and with the chain |
| C9 | Every entry cites a record, and the record carries the transaction hash |
| C10 | Namespace completeness — the chain's enumeration equals the manifest set |
| C11 | Each service_registry registration, resolved live and traced to its tx |
| C12 | The registered set, compared in both directions |
C10 and C12 are the two-sided ones
Most checks ask "is this entry true?" C10 and C12 ask the question a one-sided checker cannot: "is anything true that this manifest does not know about?" An undocumented deploy and a forgotten registration are both states where every entry present is perfectly correct and the catalog is still wrong. Those two checks are the reason the manifest can carry a completeness claim at all.
Both are gated on an explicit boolean — namespace_complete and service_registry.complete. A missing key is an accident and a false is a decision, so only an explicit false may stand the check down, and the run prints a line saying so. A truthy 1 is refused outright.
C11 exists because the task created a new drift surface
Registering four primitives produced a third artifact that can disagree with the other two — precisely the failure mode this capability exists to fight. So a registration is checked the way a deployment is: resolved live through TryResolve, traced to the transaction that created it, and matched on the event attributes, the storage deposit, the gas, and the tx's presence in a cited record.
TryResolve rather than Resolve is deliberate. An unregistered name makes Resolve panic, which arrives as an ABCI error and would be reported as "could not complete" — but a name the manifest claims is registered and is not is a finding about the world, not a broken read.
5. The tests, and why there are 89 of them
Seven of the 89 are positive or fixture-faithfulness cases (PRE, POS1, POS2, A0, A0', R0, R0'). The rest assert a specific failure.
CLAUDE.md §7a: "The checker is code, so it gets audited and tested like code." and "A tool that only ever reports PASS is worse than no tool."
Every check is exercised against a manifest mutated to break exactly that check, and every negative assertion greps for the finding's own text. An assertion that accepts any failure tests nothing, because any failure whatsoever produces DIVERGENT.
Three structural defences, each of which caught a vacuous test in the verify_depclosure.py suite audit that preceded this one:
- A preflight. pearl-1 must answer with the expected package before any test runs, so "absent from the chain" can never be network silence.
- Fixture faithfulness tests.
A0proves the degraded-node proxy is transparent when passive;R0/R0'prove the registry slices verify unmutated. Without those, a broken fixture makes every negative test pass for the wrong reason. - A degraded-node proxy. Some defects live in how the tool reads the wire, not in what the manifest claims, and no manifest mutation can reach them. The proxy forwards every request to pearl-1 verbatim except one, which it answers the way a half-broken node would.
What the proxy caught
The proxy is not decorative. A1/RED-1 reproduces, end to end against the live chain, a pre-fix read of base.get("Data") or "" in which an ABCI success carrying no Data field decoded to b"". Against a manifest entry declaring the empty hash, the pre-fix tool printed:
VERIFIED -- 38 checks, 0 findings. (exit 0)
C3 was comparing sha256(b"") against a declared e3b0c442… — nothing against nothing. The hardened tool exits 2 on the same input.
R21 uses the same mechanism for a reading the live chain will not produce: ListByType answering with its "none" empty-result sentinel while the manifest declares four registrations. Reading that as "both sets are empty, so they agree" is the same false pass in a different check.
A check family that does not run is a finding
require_families asserts that each expected family actually ran for each scope. Without it, a future edit that drops a family — or an early return that quietly skips one — reads exactly like a clean run. A12 asserts this directly against the Report class.
6. What this does NOT prove
State the limits, because a verifier trusted past its scope is worse than one not trusted at all.
- Not the imports' bytes. C7 checks that the declared import list agrees with the chain and that
consumed_byis recomputed from it. It says nothing about whether those imports' deployed bytes match their sources. That istools/verify_depclosure.py's job, and the two tools are deliberately not merged: see docs/DEPENDENCY_CLOSURE.md. - Not the curated prose. Summaries, capability descriptions and design notes are human judgments with no chain-side counterpart. Asserted out of scope by test
POS2. - Not the upstream GitHub SHAs. The manifest records them; nothing in this tool fetches GitHub.
- Not that a registration is true.
service_registry's INTEGRATOR CONTRACT applies unchanged and in full: the realm does not and cannot verify that a registeredpkgPathexists or that the registrant controls it. C11 and C12 prove the registry and the manifest agree with each other and with the transactions that produced them. They do not promote an attestation to a proof. - Not stdlib packages. They execute from the node binary and appear in no enumeration.
- An EQUIVALENT-looking clean run on a sliced manifest is narrower than it looks.
namespace_complete: falsemeans completeness was not checked, and the run says so on its own stdout.
7. Running it
/usr/bin/python3 tools/verify_catalog.py # full portfolio
/usr/bin/python3 tools/verify_catalog.py --json out.json # machine-readable
/usr/bin/python3 tools/verify_catalog.py --chain pearl-1 # one chain
bash tools/test_verify_catalog.sh # the verifier's tests
Both hit live pearl-1 and take a few minutes. The tests must be green before a verdict from the verifier is quoted anywhere.
Unlike verify_depclosure.py, this tool needs no GNOROOT/GNOHOME pin — it reads the chain and the repository directly and never resolves a dependency, so there is no cache whose provenance could be wrong.
8. Toward cosmicbull.net
The manifest is the shape a site can consume. That is the point of the JSON: portfolio.json plus a verified-at timestamp and a run's --json output is enough to render a package index, a dependency graph, and a per-package provenance page without a human writing any of it a second time.
Nothing here builds that site, and nothing here should be read as a commitment to a particular one. What is established is the invariant a site would need: every published claim is re-derivable from the chain and the repository, by a command anyone can run.
A site was considered directly, on 2026-09-22, and deferred with a stated condition rather than built — see catalog/DISCOVERY_APIDOCS.md §6. The short version: gnoweb already serves this portfolio live, and a site had no content it alone could carry. §9 is the content.
Amendment, later the same day: the condition was met and the site was built. §9's generated API reference existed and was verified, which is exactly what the deferral was waiting for. tools/gen_site.py (with tools/sitemd.py and tools/test_gen_site.sh, 193 cases) builds cosmicbull.net into site/ — 61 pages over 21 packages, 33 rendered records and 372 exported symbols, plus the manifest and API reference published verbatim as machine-readable artifacts. A same-day public-exposure hardening pass made the output publishable without the (private) repository — the full categorization is catalog/DISCOVERY_SITE.md §9 — and a third pass evolved the site into the public identity of the ecosystem: the Gno Application Factory homepage, separate Applications and Packages indexes, community links, and an honest future-facing SHAMS page (catalog/DISCOVERY_SITE.md §10). The build is offline and hermetic: it reads portfolio.json, catalog/api/*.md and the committed records, opens no socket, and links to gnoweb for anything live, so it inherits this invariant rather than adding a second source of truth. site/ is a gitignored build output, not a committed artifact. The discovery record with the four refused alternatives and the measurements behind them is catalog/DISCOVERY_SITE.md. The site is not deployed — no DNS, no host; localhost only, which is what was authorized.
9. The second generated artifact — the API reference
tools/gen_api_docs.py extends the same invariant from provenance to comprehension. The manifest proves the bytes are what the repository says. It does nothing to tell a developer how to call them.
| Path | What it is |
|---|---|
tools/gen_api_docs.py | The generator. Exit 0 generated/matches, 1 DRIFT, 2 could not complete. |
catalog/api/*.md | The committed output — one page per package plus an index. |
tools/test_gen_api_docs.sh | Its tests. 36 cases. |
Source of truth is vm/qdoc, which the node derives from the deployed bytes. That is the same authority as the vm/qfile byte-matches in C3: a doc page generated from it cannot disagree with the code the way a hand-written one can.
Why it is a separate tool
For the same reason verify_catalog.py and verify_depclosure.py are separate (§6): one artifact, one question. And for one more, specific to this case — the API surface is deliberately not recorded in portfolio.json. Writing it there would create a third copy of a fact the chain already holds, which is precisely the drift surface C11 was added to fight. --check compares the committed page against the chain, never against the manifest.
What the sabotage runs caught
§5's standard applies here too: a generator whose --check cannot fail is indistinguishable from no --check. Two guards were removed and the suite re-run, to prove the tests bite rather than merely pass:
| Guard removed | Result |
|---|---|
The package_path equality check | A2 fails. The run exits 0 and writes feeledger.md containing service_registry's entire API — a false pass in the one shape documentation cannot recover from, because the page looks perfectly plausible |
The exported() filter | E4, E5, E6 and U1 fail. vm/qdoc returns unexported declarations and the chain will not filter them; unfiltered, the pages publish checkedAdd, feeFor and rejectStraySend as though they were API |
A2 is reachable only through the degraded-node proxy — no manifest mutation can make a healthy node describe the wrong package.
What it does NOT prove
- Not that the documentation is good. It proves the page matches what the chain reports. Whether the doc comment on chain is accurate, complete or useful is a human judgment with no chain-side counterpart — the same boundary §6 draws around the curated prose.
- Not the bytes, the provenance or the closure. Those belong to
verify_catalog.pyandverify_depclosure.py, and the run says so on its own stdout. - It does not repair the packages. Five deployed realms carry no package doc comment and no
/p/primitive carries a README; deployed bytes are immutable and CLAUDE.md §6 forbids redeploying a live application, so those are permanent. The generated page states the absence rather than papering over it.