Packing, signing, shipping
A plugin ships as one archive that carries its own signature. The signature says who, never what.
The layout inside the archive
Section titled “The layout inside the archive”manifest.json <- exactly one, at the rootdist/ <- only if you declare a frontend; the entry you name lives hereruntime/ <- a sidecar's tree, whatever shape you likeA backend-only package ships no dist/, and that is correct. If such a package is
refused with layout_invalid, the other side is on a host older than SDK 0.8.0.
The limits, enforced at upload
Section titled “The limits, enforced at upload”| Limit | Value |
|---|---|
| Package, packed | 50 MB |
| Package, unpacked, total | 200 MB |
| One file | 50 MB |
| Entries | 2000 |
| Path depth | 32 segments |
manifest.json |
256 KB |
The entry count is the one that bites a Python tree: many small files, not a few big ones.
Also refused: an entry that resolves outside the target directory on unpacking, is a link, or
collides with another after normalisation (unsafe_entry), and an archive with a suspicious
compression ratio (resource_limit).
Pack deterministically
Section titled “Pack deterministically”A zip call is not enough. Copy plugins/tcp/scripts/pack.mjs and adjust what it collects.
What that script does that zip does not:
- Sorts entries by their UTF-8 bytes. Not
Array.sort()— that orders by UTF-16 code units and disagrees above U+FFFF. Everything else about your package can be right, and a filename with an emoji in it will still produce a different digest than expected. - Reads raw bytes, never strings. A packer that decodes and re-encodes a
.pyproduces a package that installs and then dies at startup. - Fixed timestamps. Same input, same bytes — otherwise the digest moves on every build, and verify-then-execute means nothing.
- Leaves out tests and
__pycache__. A.pyccarries an absolute source path and a timestamp; packing one makes the archive machine-dependent.
node scripts/pack.mjs # version from package.jsonnode scripts/pack.mjs --version 0.2.0node scripts/pack.mjs --tag acme.tcp-v0.2.0 # the release pathVersion resolution is --tag before --version before package.json. A version is never
typed into the script.
Line endings. If your tooling writes CRLF on Windows, your package differs byte for byte from one built on Linux. Both work, but they are not the same artifact and their digests differ. Keep
.gitattributesnormalising to LF and build from a clean checkout.
Signing
Section titled “Signing”A package is bytes plus an Ed25519 signature over their SHA-256 digest.
node dist/cli/sign-package-cli.js --package build/acme.tcp-0.1.0.zip --key your-key.pemInside the workspace during development:
tsx packages/sdk/src/cli/sign-package-cli.ts --package build/acme.tcp-0.1.0.zip --key your-key.pemFlags: --package and --key are required, --out overrides the output path, --detached
selects the older shape.
The tool never reads a key from an environment variable and never writes it anywhere. Only paths and the digest fingerprint — a public value — reach the output.
Two shapes, both valid
Section titled “Two shapes, both valid”- Embedded (the default). The signature is placed into the archive as the fixed entry
META-INF/signature; out comes one file,<id>-<version>.tspkg. This is the shape an operator uploads. - Detached (
--detached). A standalone<package>.signext to the unchanged archive. Not deprecated: the operator’s release shelf still holds pairs of<id>-<version>.zipand<id>-<version>.zip.sig.
The server accepts both by content, never by file extension or by which flag produced it.
An already-embedded package cannot be re-signed — sign the pre-embed archive instead.
Which key — this matters more than the command
Section titled “Which key — this matters more than the command”- A package signed with the operator’s key runs anywhere.
- A package signed with your own key runs only where the operator has allowed tenant-signed packages — and for a backend that is a separate setting again.
If your package carries backend code and is signed with your own key, it is refused today
with signature_scope_denied unless the instance runs the isolated backend substrate. A
frontend-only package signed with your key installs normally. Ask the operator before you
build a plan on it.
A tenant admin registers their own keys under Settings → Plugins → Signing keys: the
public half (public.pem) goes there. The private half stays with you and is never
uploaded.
Installing
Section titled “Installing”Two ways in, and they are for different people.
Upload — the plugin manager has an upload dialog. Drop the .tspkg in. This is how you
test, and how a customer installs something you built for them alone.
Catalogue — the operator’s release shelf. A tag in the product repository builds, signs and publishes a release; a script places it on the shelf; it then appears in every tenant’s catalogue to install with one click. The catalogue offers only packages verified against the manufacturer key — never a tenant-signed one.
Installing is not enabling. A package sits installed and inert until somebody switches it on for a tenant — and that is when a backend starts.
Updating and removing
Section titled “Updating and removing”From the catalogue — the normal way. When the catalogue holds a newer version of an
installed plugin, the plugin list under Settings → Plugins shows the step as an arrow
(0.1.0 → 0.2.0), and the row menu offers Update to 0.2.0…. It names the step, asks once,
and fetches the new version from the shelf — no file, no signature. The plugin keeps its
data and configuration. Where there is nothing newer, the item reads Up to date; for a
plugin the catalogue does not carry, Not in the catalogue. The Catalogue tab offers the
same update on its own row.
Never remove a plugin to update it. Removing can delete its data — that is what removal is for. An update never needs it.
Upload package… in the same row menu is the second way, for versions the catalogue does not carry — your own plugins, or a package signed for one customer. It is also the way for an update the one-click path refuses as destructive: the catalogue update never confirms a data-destroying change on your behalf, and the upload path shows the plan for you to confirm.
An update must carry a strictly higher version; there are no downgrades and no backward migrations. The same rule applies when the active version stopped verifying against the current trust anchor: bump the patch version, sign again, update — the version is what tells two uploads apart, not the signature.
Before anything is applied the system shows a plan and classifies every change: harmless, checked, destructive, forbidden. Whatever costs rows is counted and has to be confirmed explicitly.
Removal has three separate intents: remove the plugin completely (tables, registry entry, files), delete only this tenant’s data, or remove only the registry entry and leave the tables standing.
If the new version’s backend does not start, the update is not applied and the current
version stays active (backend_unstartable).
When the start fails
Section titled “When the start fails”Install, enable, then watch the server log. A sidecar that came up says so:
sidecar ready · runtimeVersion 3.12.10 · routes 5If it does not:
| What you see | What it means |
|---|---|
entry_digest_mismatch |
The tree changed between packing and starting. Usually the interpreter wrote bytecode into it — start Python with -B. |
runtime_missing / runtime_too_old |
The interpreter your manifest requires is not on the machine, or is older. |
runtime_unsupported_language |
Your requires.language is not one the host can start. |
entry_outside_package |
Your entry path leaves the package. Also fires for a link pointing outward. |
signature_scope_denied |
The signature is fine. The operator’s policy says a key like yours may not run this. |
permission_denied on a write |
A permission is missing — check the capability’s own gate, not just capability:invoke. |
backend_entry_missing |
The entry file named in the manifest is not in the archive. |
start_timeout |
Your backend did not report ready within 10 s. |
Further reading in the repository
Section titled “Further reading in the repository”plugins/tcp— a manifest declaring a sidecar and a connector type, a Python backend, a pack script, tests that read the manifest from disk, andscripts/peer.pyas the machine on the other end of the cable.docs/design/PRODLOG-179-sidecar-kanal.md— the frames on the pipes, byte for byte.docs/design/PRODLOG-179-konnektor-plugin-vertrag.md— a connector’s five routes and two events.