Files
picopackage/notes.md
T
Dan MilneandClaude Opus 5 f3557a1ec0 Port surviving design questions from the pico-package spec repo
The pico-package repo was the spec written a day before this implementation
started; it never shared history with it and stopped in Feb 2025. Most of it
is superseded by README.md and the notes here, but a few questions were only
ever recorded there, and its README had diverged into two versions -- one
local and uncommitted, one edited in the Gitea web UI.

Adds, as open questions: the missing GitLab/Gitea snippet providers; why
test_method is blocked behind signing; the spec's answer that a picopackage
holds text, not necessarily code; and the name collision on source_url, which
the pinning proposal wants for a third distinct meaning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:03:23 +10:00

13 KiB
Raw Blame History

Resolution rules

Two principles decide every case below:

  1. The checksum decides, never the timestamp. mtime is unreliable (a raw file re-uploaded unchanged gets a new mtime; a gist mirrored elsewhere gets an arbitrary one) and it is not evidence about content. If the payload checksums agree, the payload is settled — there is nothing to resolve no matter what the timestamps say. This is why every ???? in the old table collapsed: all of those rows assumed equal checksums, so all of them are metadata questions, not content questions.

  2. Merge the payload, regenerate the metadata. The metadata block is derived, not authored, so it never takes part in a merge — merging it would conflict on every single update. Three-way merges run on the payload alone and the block is rewritten afterwards.

Terms

  • payload — the file with its metadata block removed, then normalised.
  • bare — a file with no metadata block. A hand-copied file, or an upstream that has never been packaged.
  • payload_checksum — checksum of the payload as it sits in this file. Local edits break it, which is how modification is detected.
  • base_checksum — checksum of the upstream payload this file was last reconciled with. Equal to payload_checksum on a clean install; they diverge after a merge or a local edit. This is the merge base pointer and the cache key.

Same payload checksum

The content is identical, so the only question is which metadata to keep.

Remote Local Action State
Bare missing Write payload + generated metadata installed
Package missing Write payload + upstream metadata installed
Bare Bare Adopt: attach generated metadata adopted
Package Bare Adopt: attach upstream metadata adopted
Bare Package Nothing to do current
Package Package Rewrite only if the metadata itself changed current

mtime appears nowhere in this table. That is the point of principle 1.

Different payload checksum

Local Modified Base available Action State
missing — — Write it installed
Package no — Fast-forward to upstream updated
Package yes yes Three-way merge, clean merged
Package yes yes Three-way merge, conflicted conflict
Package yes no Refuse — no ancestor to merge from conflict
Bare n/a never Refuse — unrelated file, same name conflict
any any any --force overwrites unconditionally updated

A bare local file whose payload differs is not a modified picopackage — it is an unrelated file that happens to share a name. There is no ancestor and no claim that the two share a history, so merging would be guesswork. Refuse and say so.

Conflicts are non-destructive

git/diff3 conflict markers are written to <file>.picopackage-merge, beside the original. The original is never touched. A package manager with no reset and no index has no business writing conflict markers into a file the user may not have committed.

Merge base cache

Three-way merge needs the common ancestor — the payload exactly as upstream last sent it. It is content-addressed under $XDG_CACHE_HOME/picopackage/sha256/ab/cdef… and written on every save, so a file installed by ppkg carries the pointer to its own ancestor in base_checksum.

Cache misses are expected and survivable: a file installed on another machine, a cleared cache, a package predating this feature. The fallback is to refuse the merge and say why, which is honest. A future source_url pinned to an immutable revision would let us refetch the ancestor instead of refusing — see below.

Update UX and signing (designed 2026-08-13, not implemented)

The security question splits in three, and today's answers are: first install — pure trust in the URL, same as copy+paste, mitigated by "it's one file and you can read it"; update — worse than copy+paste, because ppkg update fetches whatever the mutable url serves and writes it with less friction than a human re-pasting; transport — plain http is currently allowed. The checksum contributes nothing adversarial: it lives inside the file it hashes and is regenerated on every write, so it is integrity against yourself, not against an attacker. The design below closes the update gap.

Diff by default

ppkg update shows the diff and asks before writing. The diff shown is ancestor → upstream — the untrusted delta. Local edits are not news to the user, and the Resolver already holds all three payloads at that point, so this is nearly free. We are not a diff tool: shell out (git diff --no-index, else diff -u), respect $PAGER — the same philosophy as merge.rb. -y/--yes skips the prompt for scripts; it never covers an identity change (below).

Identity: SSH signatures, TOFU-pinned

SSH rather than Sigstore. A full Sigstore bundle is 4–10KB of JSON — routinely larger than the payload it certifies — and drags a dependency tree into a zero-dependency gem. An armored ed25519 ssh signature is ~0.5KB, embeds in the metadata block, and the authors this format serves (gists, blogs) already hold ssh keys, with github.com/<user>.keys as a verification channel separate from the file itself. What Sigstore would add — identity that survives key loss, a transparency log — is real but not a different security class at this scale. It stays a possible opt-in upgrade, not the price of entry.

  • The signature covers the normalized payload + the claims (url, filename, payload_version). Signing the payload alone leaves url unsigned, so a tampered first install could silently redirect every future update. The claims/records split in provider.rb is the seam: sign the claims, keep deriving the records.
  • TOFU: the key fingerprint is pinned at install, or the first time a signature appears on an already-installed file. Unsigned packages keep working; signing is opt-in per file.

Key changes are a hard stop

The known_hosts model: banner, refuse, distinct exit code — not a y/n prompt someone can fat-finger through. The only door is --accept-key <fingerprint>; making the human transcribe the new fingerprint is what proves they looked.

Routine updates and identity changes are different signals and get different ceremonies. "Upstream changed, here's the diff" is routine: default-proceed, informational. "The signing identity changed" is how account compromise presents: default-refuse. Flattening both into one always-maximum-caution ceremony is how alarm fatigue happens.

No rotation chains

Old-key-signs-new-key succession (signify/TUF style) was considered and dropped. If every key change is loud and manual anyway, a valid rotation proof can only downgrade the alarm — and a stolen key holds everything needed to mint that proof, so chains sharpen theft (a thief can rotate the author out of their own package) while buying convenience only for planned rotation. At this scale, "key changed, human reads the diff carefully, re-pins" is the rotation ceremony, and it covers loss and theft with the same motion.

Exit codes are the contract

Callers — scripts and reviewing agents — build policy on exit codes, not on prose. An agent's policy may auto-accept a clean diff under a continuous key; an identity change must be unrepresentable in that path — no flag an agent routinely passes gets through it.

Code Meaning
0 written (installed/adopted/updated/merged) or already current
1 conflict — original untouched, as ever
2 declined — user said no to the diff
3 identity change — signing key differs from the pin

Review is the content check; the signature is the identity check

A signature answers "same author?" and says nothing about whether the code is safe. Review answers "does this change look sane?" and says nothing about who made it. They are complements, and single files invert the economics that make library review hopeless: the whole unit — old file, new file, three-way diff, the project it lives in — fits in one reading (or one context window), and with no install hooks, build steps, or dependencies, what you review is exactly what runs.

The gem's job is to surface facts, not to judge: emit the diff plus the machine-readable facts (--json — key changed, size delta, e.g. "adds net/http") and exit accordingly. Any LLM stays out of the gem. Note for whoever builds the reviewing agent: the payload is adversarial input — prompt injection in comments, aimed at the reviewer, is an expected attack.

Order of work

  1. Refuse plain http, including via redirect — cheap, orthogonal.
  2. source_url pinning (see open question below) — makes the update diff a pointer change and recovers merge bases on cache miss.
  3. Diff-by-default + the exit-code contract.
  4. SSH signing and pinning.

Open questions

  • Immutable source pinning. url is mutable ("whatever is at this address today"), which means builds aren't reproducible and "upstream improved" is indistinguishable from "upstream was compromised". Wants splitting into a pinned source_url (gist revision SHA, commit SHA) and a mutable update_url used only for checking. This also recovers merge bases on a cache miss.
  • Multi-file packages. Jmap::Client + Jmap::Http are one unit of reuse across two files. Either the spec grows a flat file-set, or single-file packages accept that they define several classes in one file and lose Zeitwerk file-per-class layout. Unresolved, and the first real consumer hits it.
  • Comment styles. # covers Ruby/Python/shell/YAML/Perl. Needs a table for //, --, <!-- -->, /* */, ; before the format is honestly language-agnostic.
  • Unknown metadata keys are preserved verbatim on rewrite (see Metadata#extra), so optional spec fields survive a tool that predates them.
  • Snippet-hosting providers. Provider::PROVIDERS covers GitHub Gist and OpenGist; GitLab and Gitea snippets have no provider, so they fall through to DefaultProvider and only work if the pasted URL already points at raw content. Gitea matters now that picopackage itself is hosted on one. A Gitea snippet is https://<host>/snippets/<id> → …/snippets/<id>/raw; GitLab is https://gitlab.com/(-/)?snippets/<id> → …/snippets/<id>/raw. They sit on opposite sides of the handles_url? contract: GitLab is a fixed host, so it can answer true by pattern like GithubGistProvider, while a self-hosted Gitea can be any domain and has to answer :maybe and prove itself by fetching, like OpenGistProvider.
  • test_method. test_url is carried in Metadata; test_method (the name of a test defined inside the payload itself) was in the original spec and never implemented. It only earns its place if ppkg can actually run it, which means executing upstream code — so it's blocked behind the signing work above, not just unimplemented.
  • Is a picopackage necessarily code? The spec repo answered this and the answer never made it here: “a scheme for sharing text content, often source code… to track a single file of text”, packaging “code or configuration or data”. So the scope is text, not code. Nothing in the tool assumes otherwise, but nothing states it either, and it decides two things downstream: the comment-style table above, and test_url/test_method, which only mean anything for the code case.
  • source_url is an overloaded name. The pinning proposal above wants source_url for an immutable revision pointer. The spec repo already used that name for something else — there, url was the human-readable page and source_url the optional direct/raw link “if url isn’t parsable”, which is the job Provider now does by resolution rather than by metadata. If pinning is implemented under that name, it is a third meaning for the same key; worth picking a fresh one (pinned_url, revision_url) instead.