- Split the monolith into package/provider/fetch/merge/cache modules with the Resolver deciding install/adopt/update/merge/conflict outcomes - Three-way merges via git merge-file/diff3 against a content-addressed merge-base cache; conflicts go to a .picopackage-merge sibling - Tests for package, provider, resolver, merge, and cache - notes.md: update UX and signing design — diff-by-default, SSH signature identity pinning (TOFU), key changes as a hard stop, exit-code contract - Add CLAUDE.md; remove the pre-refactor exe/pppkg monolith and scratch files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EiyJC73Mz8xZyCTvCEY8qn
196 lines
11 KiB
Markdown
196 lines
11 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.
|