Decision
Five things, which are separable but arrive together. 1.plugin.json gains version and dependencies. The version is
semantic, major.minor.patch. A dependency names another Cordial plugin by
id, with a requirement written in one of exactly two forms: =1.2.0 for that
version and nothing else, ^1.2.0 for anything compatible with it.
2. A distribution archive is a .tar.zst — a tar of the plugin directory’s
contents, zstd-compressed, with plugin.json at its root.
3. An index is one static JSON file listing id, name, version, requested
capabilities, dependencies, an https download URL and a sha256: content
hash for each published release. It is designed to be served straight out of a
git repository. Nothing in Cordial names a URL for one, and nothing assumes
there is only one.
4. Resolution is offline, deterministic, and refuses by name — a missing
dependency, an unsatisfiable requirement, a cycle, and a plugin in the plan
asking for a capability the user has not granted are four distinct, named
failures, not one “could not install”.
5. Unpacking assumes the archive is hostile. Every rule is a refusal with a
name rather than a skipped entry, and each one has a test that fails when the
check is deleted.
crates/cordial-plugins/{registry,resolve,unpack}.rs, and — the marketplace
foundations described further down — crates/cordial-plugins/{source,sign,marketplace}.rs
and the Marketplace section of crates/cordial-shell/src/settings.rs.
Why the manifest stays plugin.json
The obvious alternative is to put all of this in a package.json, since plugins
are TypeScript on Deno (ADR-008) and
that file already exists in the ecosystem’s muscle memory. It is refused, and
the reason is dependencies.
One key cannot honestly carry two meanings. In npm, dependencies means npm
packages. Here it means other Cordial plugins — things with ids, capability
grants, an install order and a start order. A plugin may perfectly well need
both: a Cordial plugin that declares the events it subscribes to, and a
JavaScript library to parse something. A single field naming both kinds is a
field where the reader cannot tell which is which, and the resolver cannot
either.
VS Code took the other route, overloading package.json with a
contributes/activationEvents/extensionDependencies layer, and it is the
most complained-about part of its extension model precisely because the file
answers to two authorities. Nobody can tell by looking whether a given key is
npm’s or the editor’s, and the tools that validate one ignore the other.
So: plugin.json is Cordial’s, entirely. If an author wants a deno.json
beside it for their JS runtime’s benefit, that is the runtime’s business.
Cordial does not read it, does not validate it, and does not care.
The two-operator requirement language is deliberate and a bare version is
refused. "1.2.0" means exactly 1.2.0 in npm and ^1.2.0 in Cargo. An
author arriving from either ecosystem would write it expecting their own
meaning, and be wrong half the time with nothing to tell them. Refusing a bare
version and naming both forms in the error costs one character to fix and
removes the ambiguity permanently. Everything else — >=, ~, *,
comma-separated lists — is refused too: every operator in the language is one a
user has to understand before they can tell what an install will do.
version is optional, and that is not an oversight. Every plugin installed
before this existed has no such key, and making it required would have made
discover refuse all of them at once. That presents to a user as every plugin
they had silently vanishing, with the directories still sitting there looking
correct — the failure class ADR-013’s
migration exists to prevent. An unversioned plugin still loads. It simply
cannot be published or depended upon, and the resolver says so by name rather
than inventing a version for it.
Why .tar.zst
Better ratio and better speed than gzip, with mature Rust crates for both
halves, and it is what the rest of the Linux packaging world has moved to.
Zip was considered and is worse here, for a reason that is not about
compression. Zip carries Unix mode bits in an extension field that producers
populate inconsistently — some write them, some write zeros, some write
Windows attributes instead. That does not make zip safer despite the
temptation to read it that way; it makes the same archive unpack differently
depending on what wrote it, which is the property you least want in the format
you are hardening against. Tar states the mode plainly, so the setuid refusal
below has something definite to refuse.
Why the index is a static file, and why no URL is written down
A static file in a git repository is auditable — every change to what is on offer is a diff a human can read, and the history is the audit log — forkable — a user who dislikes what is in an index can maintain their own, and it is the same file in the same format — and cheap, because it is a file. This is Homebrew’s tap arrangement rather than a package server, and the reason is the same one: a registry that is a service is a registry only its operator can check. Who hosts an index and who decides what goes in one is a policy question, and it is not answered here. It belongs to whoever maintains Cordial, and they have not decided. What this design does instead is refuse to prejudge it: a curated list and a self-hosted index are indistinguishable to the code, and combining two indexes that publish the same id and version pointing at different bytes is refused rather than resolved by precedence. Precedence is the policy question. Whichever order were picked, an index a user added for one plugin would silently be deciding where a different plugin’s bytes come from.Signing: the mechanism is implemented; the key is still nobody’s to ship
The intended scheme is a detached minisign signature (Ed25519) beside the index asindex.json.minisig, checked against a key shipped with Cordial
before the JSON is parsed at all. Chosen because it is boring: one small
well-specified format, an existing Rust implementation, a public key that fits
on a line, and no infrastructure. OpenPGP was rejected for bringing a keyring
and a trust model nobody wants to operate; Sigstore for bringing a network
dependency to an operation whose entire appeal is that it is a static file.
SSH signatures (ssh-keygen -Y sign, with an allowed_signers file) would also
have done and are a reasonable thing to argue for, since the index lives in git
already.
Correcting what this ADR said before: it said “none of it is implemented”,
and as of the marketplace foundations (below) that is no longer true of the
mechanism. crate::sign::verify checks a minisign signature against exactly
the bytes handed to it, in the order this section always specified — before
serde_json runs — using the minisign-verify crate the paragraph above
already named as the plan. Index::parse_unverified is still there, unchanged,
for exactly the case its name always covered: a caller with no signature to
check, which is still every caller in this codebase today, because nothing here
configures a key.
What is still true, and is the part that actually matters: no key is
shipped with Cordial, none is hardcoded anywhere, and nothing falls back to
trusting an index that has none configured. Implementing the check without
shipping a key was deliberate, not an oversight — see “Browsing and installing
from a source” below. Shipping a key means naming whose index it belongs to,
which is the hosting decision this ADR has never made and still has not. Until
that decision exists, crate::marketplace::install refuses every plan built
from an index it was not handed a key to verify, and an index opened with no
key is exactly as trustworthy as the transport it arrived over — which is what
this paragraph said when there was no check at all, and remains true of every
index nobody has configured a key for.
The per-entry content hash is not a substitute and must not be read as one.
It protects the download against a mirror that serves different bytes than
were published. It cannot protect against a tampered index, because a tampered
index carries a matching hash for whatever it is pointing at.
Browsing and installing from a source — the foundations, 2026-08-20
The marketplace half of this ADR — browsing and installing from somewhere, rather than only being able to reason about an index file already on disk — did not exist. It now does, in three additions that change nothing about the decisions above; they are the plumbing those decisions were waiting for.crate::source::IndexSource is where “who hosts an index” plugs in. Two
calls — fetch the index document (and its signature, if the source has one),
fetch one entry’s archive — because those are the only two things ADR-014
ever needs moved over a network. crate::source::LocalFileSource is the one
implementation today: a directory holding index.json, an optional
index.json.minisig, and an archives/ directory. This is not a stand-in for
an HTTPS source waiting to be written; the “why the index is a static file”
section above already designs an index to be “served straight out of a git
repository”, and a local clone of one is that arrangement with the network
step removed. cordial-plugins still takes on no HTTP client dependency —
doing that now, ahead of a host being named, would itself be a form of the
policy call this ADR keeps declining to make.
crate::sign is the signature check described above, as code.
crate::marketplace is what an installer actually calls: open a source
against an optional trusted key (open), and resolve a request against the
result, fetch what the plan needs, and install every step through
crate::unpack::install — never a second extraction path — while refusing
outright, before a single byte is fetched, if the index behind the plan was
never verified (install). crates/cordial-shell/src/settings.rs’s
Marketplace section in the Plugins page is the UI wired to this: a directory
and an optional pasted key, a listing of what it offers with each entry’s
requested capabilities shown in the same words the per-plugin grant switches
use, and an Install button that is refused — visibly, with the reason stated
on it — for every entry until a key is configured and verifies.
What this still does not do, on purpose. No default index directory, no
shipped key, no HTTPS fetcher, and — see ADR-001
and ADR-003 — nothing that would let an index
install anything other than a Cordial plugin archive through the existing
hardened unpacker: there is no broader “run this” primitive here for a
listing to point at, which matters more once a marketplace exists than before
one did, because a marketplace is exactly the shape a distribution channel for
one would try to wear.
Resolution, and how its order relates to ADR-006’s start order
They are the same order, deliberately. ADR-006 already establishes that plugins have a start order:events.subscribe filters at subscribe time
against types that have already been declared, so a subscriber whose declarer
has not started is refused rather than parked. That ADR describes dependency
resolution for first-party plugins as “resolved once, shared, not restarted per
dependent” — an ESM import graph.
The install order this ADR produces is a topological order of the same graph,
dependencies first, and it is the order to start them in as well. Producing two
orders from one graph would be two chances to disagree, and the disagreement
would surface as a plugin that installs cleanly and then fails to subscribe on
every launch until something unrelated changed the enumeration order. The one
difference is a filter, not a second order: steps already installed at exactly
the planned version need no download, but they are still in the order, because
what is already on disk still has to start first.
A plugin lives at plugins/<id>/, so exactly one version of it can win.
Two dependents wanting incompatible versions is refused, naming both
requirements and what was on offer. Installing two copies was rejected: it
would give one id two event namespaces and two grant entries, and ADR-006’s
registry attributes an event type to an id, not to a directory.
Capability approval is a second call, on purpose. A user cannot approve a
plan they have not been shown, and the plan is what resolution produces. So
resolve builds it and Plan::refuse_ungranted refuses it, and the combined
plan is what an installer calls. The refusal that matters is the transitive
one: installing A must never quietly bring in B holding assets.override.
Nothing here widens a grant to make an install smoother. A dependency’s
capabilities are the user’s to grant, exactly as ADR-003 and ADR-013 say, and
arriving as somebody else’s dependency is precisely the case where that is
easiest to lose sight of.
The index’s claims are checked against the archive. An index repeats each
plugin’s capabilities and dependencies so a user can be shown them before
anything is downloaded. That is only honest if the archive is then held to it,
so the extracted plugin.json is compared against the entry it was installed
as — id, version, capabilities and dependencies — and a mismatch refuses the
install. Without that check an entry could ask for log, be approved for log,
and unpack a manifest requesting assets.override, and the approval the user
gave would have been for a different plugin than the one on disk.
Unpacking: what is refused, and why each
Every one of these is a refusal with a name. An unpacker that silently skips the entry it did not like produces a plugin directory that is subtly not what was published, and the person debugging it has nothing to go on. An entry is a regular file or a directory, and nothing else. Symlinks and hard links are refused whether or not the target looks like it stays inside.plugin.json -> ../../../etc/passwd is the obvious case, but a -> . followed
by a/b is the same attack written as two entries, neither of which is wrong
on its own. Deciding which links are safe means simulating the filesystem the
archive is building; refusing all of them means never having to be right about
that. Device nodes and FIFOs are refused because a plugin has no use for one and
creating one is how an unpacker becomes interesting.
A path has no .., is not absolute, and still lands inside the destination
once normalised. The third is redundant given the first two and is kept
anyway; it is the check that still holds if somebody later decides a .. in the
middle of a path is harmless because it cancels out.
Setuid and setgid are refused rather than stripped. Stripping would install
the archive anyway. An archive asking for setuid is telling you something about
itself worth stopping for.
Files are written 0644 and directories 0755 regardless of what the
archive asked for. Cordial never executes anything out of a plugin directory —
it runs deno run against the entry module — so an executable bit could only
ever be useful to something else.
Entry count and total uncompressed size are both capped. Zstd compresses a
few gigabytes of zeroes into a few hundred bytes, so the size of what was
downloaded says nothing at all about the size of what is being written. The cap
is enforced against what each header declares and against what actually comes
out of the decompressor, so an archive whose headers disagree with its contents
runs into one or the other.
The content hash is verified before anything is decompressed. Checking
afterwards would mean a tampered archive had already been through the tar parser
and had already put files on disk, and “we deleted them again” is a much weaker
statement than “they were never written”.
Extraction is staged and renamed into place. A dot-prefixed sibling of the
final directory, which manifest::discover does not look at, renamed in only
once the whole archive has been read and its manifest checked. install clears
the staging directory when it refuses — but a process killed part way through
clears nothing, and what is left at that moment is a directory holding a real
plugin.json and a truncated entry module. The dot prefix is the only thing
standing between that and Cordial loading half a plugin on the next launch.
is_valid_id forbids a dot, so such a directory can never collide with a real
plugin’s name.
What this does not protect against
A plugin that is exactly what it claims to be, and malicious. Every mechanism above answers one question — are these the bytes that were published, unpacked without escaping the directory they were meant for? None of them answers whether those bytes should be trusted. A hash proves provenance. It does not prove intent, competence, or that the author has not changed their mind since version 1.2. Capabilities remain the entire boundary, exactly as ADR-003 and ADR-007 say, and the registry does not move it an inch. A plugin that is grantedassets.override can overlay a
gameplay-affecting asset (ADR-010 is
explicit about this); one granted presence.set can broadcast what someone is
playing; one granted settings.write can throw their configuration away. Being
in an index changes none of that. It is the same trust decision the user makes
in approving the capability, and it is still theirs to make.
Nothing here should be built into a UI that implies otherwise. A storefront
is a shape that says “reviewed” whether or not anybody reviewed anything, and a
verified badge next to a hash is an invitation to read “safe” where the only
claim being made is “unmodified since publication”. Whatever the marketplace UI
turns out to be, it has to show what a plugin is asking for and let the user
decide, rather than presenting listing in an index as an endorsement. If review
ever does happen, it will be a human process with a name attached and it should
say who did it — not a property that follows from being downloadable.
Nor against a dependency chain nobody read. Resolution makes the whole plan
visible and refuses ungranted capabilities in it, which is the most a resolver
can do. It cannot make somebody read the list.
Consequences
Accepted: an index duplicates what is already in each archive’s manifest. That duplication is load-bearing — a plan has to be built before anything is downloaded — and it is checked at install time rather than trusted, so the cost is a mismatch refusal an honest publisher never sees. Accepted: the resolver refuses more than a general-purpose package manager would. Incompatible requirements on one dependency, and any cycle, stop the install rather than being worked around. Both are cheap to fix in a manifest and expensive to debug once installed. Accepted: four new dependencies (semver, sha2, tar, zstd), one of
which builds C. The workspace already requires Clang.
Accepted: nothing here downloads anything. Fetching is Cordial’s to do and a
plugin never holds the channel (ADR-007); keeping the fetch out of the resolver
and the unpacker is also what makes every refusal above reachable from a test
with no network.
Rejected: installing two versions of one plugin. One id, one directory, one
event namespace, one grant entry.
Rejected: treating an entry the unpacker dislikes as a skippable entry. The
result is a plugin directory that is quietly not what was published.
Rejected: a file: or plain-HTTP download URL in a published index. The
hash is what makes a download trustworthy, so https is defence in depth rather
than the guarantee — but a URL is the one field a fetcher acts on, and the set
of schemes it can be talked into is worth being a short list.
What would change this
If the project owner names a host for a default index, two things follow and neither is optional: a key forcrate::sign to check it against, shipped and
documented as belonging to that specific publisher, and an
IndexSource implementation that fetches over HTTPS rather than reading a
local directory. Neither is a large change against what exists now —
crate::marketplace already refuses to install without a verified signature,
and crate::source::IndexSource is already the seam an HTTPS fetcher drops
into — but both are genuine decisions, made once, by the person who has to
stand behind them, not defaults quietly picked to make the marketplace UI feel
less empty.
If a plugin ever legitimately needs two incompatible versions of a dependency —
which would mean plugins linking each other’s code rather than merely starting
in an order — the one-directory-per-id rule is what would have to give, and that
is a much larger change than a resolver tweak.