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>
225 lines
13 KiB
Markdown
225 lines
13 KiB
Markdown
## 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.
|