Cosmic Bull

Rendered from docs/DEPENDENCY_CLOSURE.md at commit f191063fd89d in the project repository. The committed file is the source of truth; this page is a rendering of it.

Dependency-closure equivalence

How Cosmic Bull proves that what it tested is what the target Gno network will actually execute.

A deployed realm's own bytes are byte-verified against the chain at deploy time. That proves the root is what was committed. It proves nothing about the packages the root imports — and those are compiled into every code path the realm runs. This document defines the missing half.

The complementary half is CATALOG.md: verify_catalog.py proves the roots — that every recorded package is on chain with the recorded bytes, that the committed source matches, and that the inventory is complete in both directions. It checks that the declared import list agrees with the chain; it says nothing about those imports' bytes. The two tools are deliberately not merged, because their evidence models differ: this one needs a pinned, chain-matched GNOROOT/GNOHOME and a resolved dependency cache, and that cache's provenance is itself the thing most likely to be wrong.


1. Root cause

Three independent mechanisms let the tested closure diverge from the executed closure with no error, no warning, and a green test run.

M1 — the fetch remote is derived from the import path, not from the deploy target. gnovm/pkg/packages/load.go:36 constructs rpcpkgfetcher.New(nil), and rpcpkgfetcher.go:72 computes rpcURL := fmt.Sprintf("https://rpc.%s:443", domain). Every gno.land/... import therefore resolves against rpc.gno.landmainnet — whatever chain you are building for. Nothing in the toolchain relates the fetch target to the deploy target.

M2 — the module-cache marker is content-free. DownloadPackageToCache writes an empty file at $GNOHOME/pkg/mod/.markers/<DerivePkgBech32Addr(pkgPath)> and returns early on a later run if it exists. No hash, no chain-id, no height, never revalidated. A cache poisoned from the wrong chain is therefore indistinguishable from a correct one, and -remote-overrides applied to a warm cache is a silent no-op. Cache provenance is not recoverable after the fact.

M3 — workspace-local packages shadow the chain. load.go:113-149 resolves a workspace-local directory before the module cache, with the fetcher explicitly nil. A first-party /p/ edited after being deployed will be compiled from the working tree while the chain still serves the old bytes. And because discoverPkgsForLocalDeps (load.go:267+) fs.SkipDirs on a nested gnowork.toml, the local package set depends on which directory the run started from — pearl/ is invisible from the repository root.

Worth stating because it is a natural guess and it is wrong: examples/ is not in the resolution path. $GNOROOT supplies only stdlibs, and examplespkgfetcher has no non-test construction site.

2. Current failure mode

The failure mode is a passing test. Demonstrated live, not inferred:

$ export GNOHOME=/tmp/m1m2/gnohome-m1        # empty
$ gno mod download -C pearl/p/permbook        # no -remote-overrides
gno: downloading gno.land/p/nt/groups/v0
gno: downloading gno.land/p/moul/addrset/v0
...
$ gno test pearl/p/permbook
ok      ./pearl/p/permbook 	0.57s

That ok was produced against a dependency closure in which three of groups/v0's four compiled files differ from pearl-1's, and in which the addrset path is addrset/v0a package that does not exist on pearl-1 at all. The checker, pointed at the same cache with pearl-1 as the target:

VERDICT: DIVERGENT -- 5 finding(s)
  - PATH-SET: gno.land/p/moul/addrset/v0 is in the tested closure but NOT in the chain closure
  - PATH-SET: gno.land/p/moul/addrset is in the chain closure but NOT in the tested closure
  - BYTES: gno.land/p/nt/groups/v0/group.gno differs     local=c8350c2a99339831 chain=5f89bd89ecc67ee0
  - BYTES: gno.land/p/nt/groups/v0/readonly.gno differs  local=34d23a01c950726c chain=53b7a2b64fd485ba
  - BYTES: gno.land/p/nt/groups/v0/role.gno differs      local=7d07a58288b610f6 chain=a08e8c26274862fa

M2, demonstrated separately: re-running gno mod download on that same cache with the correct pearl override re-fetched nothing and repaired nothing — group.gno remained c8350c2a99…. Every marker was 0 bytes — 9 in that cache when re-counted 2026-09-22, and 9 more in the pearl-pinned cache, all 18 of them empty. (An earlier revision of this line said "all 8 markers"; the count was wrong, the 0-byte finding was not.)

3. The verification standard

For a root package set R targeted at chain C, the tested closure and the executed closure are equivalent when all five hold:

  1. Path-set identity. The set of non-stdlib package paths reachable transitively from R is the same whether enumerated locally or enumerated from C's own stored sources.
  2. Compilation-input byte identity. For every package in that set, the set of non-test .gno files matches and every file's bytes are identical. gnomod.toml must match modulo the chain-injected [addpkg] table.
  3. Availability. Every tested dependency exists on C.
  4. Resolution transparency. Every locally-resolved package's origin — gnoroot, workspace, or modcache — is recorded, so M3 shadowing is visible rather than implicit.
  5. Declared residual. Packages that cannot be compared are named and counted, not silently passed. Today that is exactly the stdlib set, which executes from the node binary rather than chain storage.

The walk must be two-sided. This is the non-obvious requirement. A divergence can change the path set, so the diverging path may be absent from the local closure entirely — addrset vs addrset/v0 is exactly that case. A one-sided walk that compares only locally-known packages cannot see it. The checker therefore seeds from the roots' local imports but then reads every non-stdlib member from the chain and recurses on the chain's own imports.

Why byte identity is durable here. A realm's bytes are immutable after addpkg, so an existing dependency cannot change under the check. Verifying at height H and deploying later is sound: a dependency may be added to the chain in between, which cannot alter an already-enumerated closure. This is a genuine strength of the Gno model and the reason a single check suffices.

Evidence artifact: the --json record, which carries both closure digests, each package's class, origin, directory and per-file hashes from both sides.

Sufficient evidence, concretely

export GNOROOT="$HOME/go/pkg/mod/github.com/gnolang/gno@<pinned>"
export GNOHOME="$HOME/.cache/gno-toolchains/pearl/gnohome-pearlpinned"
/usr/bin/python3 tools/verify_depclosure.py \
    --rpc https://rpc.pearl.testnets.gno.land:443 \
    --json evidence.json \
    pearl/p/<pkg> pearl/r/<realm>

Exit 0 = EQUIVALENT, 1 = DIVERGENT, 2 = could not complete. A verdict of 2 is not a pass; treat it as a blocked gate and report it.

4. Where the gate belongs

Two placements, because they catch different things.

Gate A — before a test result is believed, immediately after dependencies are first resolved and before TEST is recorded as passed. This is where M1 and M2 are cheap to catch and where they would otherwise become permanently invisible. Without Gate A, AUDIT is performed against possibly-wrong source. Run Gate A with --include-tests. Gate A is the gate about what a test result means, and a test-only dependency is inside that question even though it is outside "what the chain executes" — § 5a is what running it without the flag had been hiding.

Gate B — at PEARL VALIDATION, immediately before deploy. Re-run, because the working tree may have drifted since Gate A (M3) and because first-party dependencies may have been deployed in between. Gate B's JSON record is part of the deployment record.

In the lifecycle from APPLICATION_FACTORY.md:

… → PORT / REMEDIATION → [GATE A] → TEST → AUDIT → COMMIT/PUSH
  → FRESH GITHUB VERIFICATION → PEARL VALIDATION [GATE B] → DEPLOY → …

A DIVERGENT verdict at Gate B is a hard boundary — stop and report, do not remediate and proceed. It means the audited source is not the source the chain will run, which invalidates the audit rather than merely delaying the deploy.

Portfolio re-check whenever the GNOROOT pin or the dependency cache changes, since both are load-bearing for every application at once — and whenever the checker itself changes, since a verdict is only as good as the code that produced it. tools/portfolio_depclosure.sh runs every deployed package with committed source and prints a verdict / compared-dep-count / digest table. It exists as a committed script rather than a typed command because the one time it was typed, an unquoted shell variable collapsed the package list into a single argument, the loop ran once over a nonexistent path, and the run reported a bogus DIVERGENT.

5. Historical exposure

Measured, not assumed. Every deployed package was re-checked against pearl-1 using the actual unpinned, mainnet-resolved cache that the pre-permbook benchmarks used (~/.cache/gno-toolchains/pearl/gnohome, whose groups/v0 imports addrset/v0 and carries mainnet bytes).

result
deployed packages checked17
EQUIVALENT15
DIVERGENT2 — p/permbook, r/permbook_demo

The exposure was latent for 15 packages and actual for one application.

The reason 15 were clean is provenance-independent, which is what makes the result durable rather than lucky. Comparing the shared dependencies directly between the two chains:

dependencycompiled .gnopearl-1 vs mainnet
p/nt/avl/v03identical
p/nt/markdown/sanitize/v01identical
p/nt/bptree/v03identical
p/moul/addrset2absent on mainnet
p/nt/groups/v04differs in 3group.gno, readonly.gno, role.gno

avl/v0 and sanitize/v0 are the only external dependencies of every application before permbook, and their compilation inputs are identical on both chains. Those benchmarks were therefore equivalent whichever chain their cache came from — the M2 unrecoverability of cache provenance does not matter, because both possible provenances give the same bytes. Earlier-noted cross-chain differences in avl/v0 and bptree/v0 were in README.md, which is not a compilation input and is correctly excluded.

groups/v0 is the only portfolio dependency that genuinely differs, and p/permbook is its only consumer. So the single actual divergence is confined to benchmark #3, where it was found at the time.

Present state: closed. With the pinned cache, all 19 source-carrying deployed packages are (re-verified 2026-09-23, post-subscriptions) EQUIVALENT with matching closure digests, including permbook (bc0512a6def04b62 on both sides). The full record is in ../benchmarks/README.md § Dependency-closure audit.

One package in the repository is legitimately DIVERGENT: r/upvotes depends on p/cosmicbull/tally, and neither was ever deployed to pearl-1. NOT-ON-CHAIN is the correct verdict — it is old-era code, and the checker is reporting that it could not be validated against pearl-1, which is true.

5a. The pinned cache is pearl-matched for the deployed closure only

Amendment, 2026-09-22. § 5's "present state: closed" is true as written — it is a statement about the deployed closure. Widening the walk with --include-tests for the first time showed that the cache called "pearl-pinned" is not uniformly pearl-resolved.

gno.land/p/nt/uassert/v0 is the case. Four files compile; three of them and gnomod.toml are byte-identical on both chains, so only one file carries the divergence, and the cached copy of it is mainnet's:

filecachedpearl-1mainnet
doc.gnoa63ff27050de1f16samesameidentical everywhere
helpers.gno48c55837f74643c2samesameidentical everywhere
types.gnod9f5965305ec9c06samesameidentical everywhere
gnomod.tomlf83cc19990665fc4samesameidentical everywhere
uassert.gno54e13f74a2ab168b8cd47b4107e7a4fd54e13f74a2ab168bcache == mainnet

The single difference is an import path, and it reproduces the addrset vs addrset/v0 shape exactly — a path-set divergence, not a byte divergence:

import in uassert.gnodiff.gno bytes
pearl-1gno.land/p/onbloc/diffa4c65afd9743240b
mainnet / cachegno.land/p/onbloc/diff/v0a4c65afd9743240b

p/onbloc/diff is absent from mainnet; p/onbloc/diff/v0 is absent from pearl-1. The contents are identical under either path, so nothing miscompiles — but the tested closure reaches a package path the target chain does not host, which is precisely the condition § 3's path-set criterion exists to catch, alive inside the cache this gate depends on.

Why the 17-package portfolio audit is still sound. uassert and diff are test-only: they are compiled into the test binary and never into a deployed realm, so they are outside every deployed closure. The EQUIVALENT verdicts in § 5 and in ../pearl/DEPLOYMENT.md are unaffected, and re-running the portfolio after this finding reproduced every recorded digest unchanged.

What it does change. Two things, and both are corrections to claims made elsewhere in this repository:

  1. "The pinned cache is pearl-matched" was stated without qualification. It is true of the deployed closure and false of the cache as a whole. The qualified form is the only one that has been demonstrated.
  2. Gate A should be run with --include-tests. § 6 records that flag's default-off as "a judgement, not a proof"; this is what the judgement costs. A test-only dependency cannot change what the chain executes, but it can change what a green gno test means, and that is the exact thing Gate A exists to establish before AUDIT trusts a test result.

The mechanism is M1 + M2 from § 1, unchanged: uassert/v0 was fetched at some point against the default (mainnet) remote, and the 0-byte marker makes that provenance unrecoverable and the cache un-repairable in place. The remedy is the same as for the original finding — a fresh GNOHOME, never an override applied to a warm one.

6. Limitations

What this mechanism does not prove. Each is a real residual, not a caveat for form's sake.

7. Reuse — no new on-chain code

This is local tooling only. No /p/ or /r/ was created: the problem is a property of the build and verification pipeline, and putting a verifier on-chain would neither observe the local closure nor be trustworthy about it. The existing byte-verification approach (vm/qfile + base64 paths, per ../pearl/DEPLOYMENT.md) is reused directly; the checker extends it from the root package to the transitive closure.