a changed external is a polad, and add no longer overwrites anything

Restores something the specimen said and the round that discarded the specimen
lost with it: a changed external becomes a polad in the cart, and somebody
decides. check now stages what moved into .loom/cart/current/polad/ with the ETag
that was served alongside the bytes, and prints the .usages.md beside it, because
reconciliation runs the other way — the facets usually survive and what moves is
the code a usage named. It says so when there is no usages file, which is its own
finding.

With no cart open, check reports and stages nothing. The tool does not open a
round: a cart is a bounded exchange between two presences and starting one is
somebody's act, not a side effect of asking about freshness.

add now adopts what is not here and refuses what is already adopted, superseding
the entry that had it announce an overwrite — it no longer overwrites at all. The
one exception is the only way out of a dead end: a document present but unlocked
was fetched by hand, nothing records its origin, and the path does not round-trip,
so check cannot ask about it and a refusal would strand it forever. add accepts it
and the bytes decide — identical locks it without rewriting anything, which makes
the lock's assertion verified rather than assumed, and different stages a polad and
leaves the local copy alone because it is the only evidence anything moved.

apply exists because the lock is the half a person forgets: moving a polad by hand
leaves a lock describing the copy you just replaced. Recorded with its limit —
for an external, discard does not mean the change goes away, so discarding is
really knowingly stale and nothing yet records that choice.

Measured end to end on this repository: eight hand-fetched documents, all eight
locked, nothing rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
This commit is contained in:
2026-09-07 14:57:57 -04:00
co-authored by Claude Opus 5
parent 57ed133021
commit 021bf63a11
5 changed files with 383 additions and 94 deletions
+121
View File
@@ -0,0 +1,121 @@
package external
import (
"fmt"
"io"
"os"
"path/filepath"
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
)
// CartDir is where a round happens. A cart is not committed — it lives in the
// working tree of the machine the two presences share — so everything staged
// here is deliberately outside version control.
const CartDir = ".loom/cart/current"
// PoladDir is where a changed external waits for somebody to decide.
//
// A polad is a candidate artifact, shaped exactly like what it would become,
// staged so you can see whether it fits. Its exits are apply or discard, and
// nothing may drift into being kept.
const PoladDir = CartDir + "/polad"
// cartOpen reports whether there is a round to stage into. The tool never opens
// one: a cart is a bounded exchange between two presences, and starting it is
// somebody's act, not a side effect of checking freshness.
func cartOpen(root string) bool {
fi, err := os.Stat(filepath.Join(root, CartDir))
return err == nil && fi.IsDir()
}
// stage writes a candidate copy into the cart, with the ETag that was served
// alongside the bytes, so that applying it locks what somebody actually read.
func stage(root, rel string, body []byte, rec lock.Record) error {
dest := filepath.Join(root, filepath.FromSlash(PoladDir), filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.WriteFile(dest, body, 0o644); err != nil {
return err
}
locks, err := lock.LoadFile(poladLockPath(root))
if err != nil {
return err
}
locks.Put(rec)
return locks.Save()
}
func poladLockPath(root string) string {
return filepath.Join(root, filepath.FromSlash(PoladDir), lock.File)
}
// usagesFor returns the path of the facet naming what depends on a document, and
// whether it exists.
//
// Reconciliation runs the other way: the question is not what do we rewrite
// here, but given what changed in theirs, what do we change in ours. The facets
// usually survive unchanged — what moves is the manifests, the config, the code
// that a usage named, which is why a usage names them.
func usagesFor(root, rel string) (string, bool) {
p := rel[:len(rel)-len(filepath.Ext(rel))] + ".usages.md"
_, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p)))
return p, err == nil
}
// Apply moves a staged polad into place and moves its lock with it.
//
// This exists because the lock is the half a person forgets. Moving the file by
// hand leaves a lock describing the copy you just replaced, which is the drift
// the lock was there to prevent.
func Apply(root string, rels []string, out io.Writer) error {
staged, err := lock.LoadFile(poladLockPath(root))
if err != nil {
return err
}
locks, err := lock.Load(root)
if err != nil {
return err
}
if len(rels) == 0 {
for _, r := range staged.All() {
rels = append(rels, r.Path)
}
}
if len(rels) == 0 {
fmt.Fprintln(out, "nothing staged")
return nil
}
for _, rel := range rels {
rec, ok := staged.Get(rel)
if !ok {
return fmt.Errorf("%s is not staged", rel)
}
src := filepath.Join(root, filepath.FromSlash(PoladDir), filepath.FromSlash(rel))
body, err := os.ReadFile(src)
if err != nil {
return err
}
dest := filepath.Join(root, lock.Dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.WriteFile(dest, body, 0o644); err != nil {
return err
}
locks.Put(rec)
staged.Remove(rel)
if err := os.Remove(src); err != nil {
return err
}
fmt.Fprintf(out, "applied %s\n", rel)
if u, ok := usagesFor(root, rel); ok {
fmt.Fprintf(out, " check %s names what depends on this\n", u)
}
}
if err := locks.Save(); err != nil {
return err
}
return staged.Save()
}