Changes touching this path
- day 0: loot hosts loot
f4c30e75 · dbf3dbe6… - evidence: crew minted and verified (#86)
1fada823 · dbf3dbe6…diff - normalize working tree to LF: byte-stable co-located bridge (.gitattributes -text)
e58fdda6 · dbf3dbe6…diff - converge: three-way deletion-vs-base rule (#295) (#300)
merge_trees had no deletion-vs-base case: a path in theirs but not ours was
always AdoptTheirs, and a path in ours but not theirs was never visited (the
walk only iterated theirs). Either way a one-side deletion since the fork was
silently undone -- it fired twice while landing #288, re-adopting freshly
deleted files from the sibling line.
Apply the standard 3-way rule against the base merge_tips already computes:
- one side deleted + other unchanged-from-base -> the deletion wins (both
directions; a symmetric ours-only pass covers the path theirs dropped);
- one side deleted + other edited-since-base -> a delete/edit Conflict,
recorded so it surfaces through the harbor bounce (ADR 0036) rather than
silently resurrecting or deleting (base content stands in for the deleted
side's missing oid);
- base lacks the path -> a genuine add, adopted as before.
Unchanged-ness: address equality first, then plaintext via the key oracle
(re-seals mint fresh addresses, #65/#98); an unopenable side keeps the
conservative pre-#295 keep/adopt. New same_content helper.
Tests: converge unit tests for both delete-vs-untouched directions (incl. a
reseal-address case), both delete-vs-modified conflict directions, delete-vs-
delete clean, base-lacks-path add, unopenable conservative, and a classify
theirs-side case; engine merge_tips tests proving the one-side deletion is
honored (both orders, clean) and a delete/edit collision surfaces a conflict.
Amends ADR 0028 (new #295 amendment) + CONTEXT.md Convergence-classifier and
Adopt entries + concurrent.md, retiring the "the merge is what resurrects
files deleted upstream" caveat for the reconcile/adopt merge.
Git-Author: Connor Miller <53197564+Connor-Miller@users.noreply.github.com>
9fed0444diff - Workspace accepts its clock; lane flag-gate goes pure so cmd_* tests never touch a real .loot (#322)
20ac82e1 · dbf3dbe6…diff - conflicts map stores base OID + loot resolve --tool for external 3-way merge (#400, #401)
a0711731 · dbf3dbe6…diff - Rename the Public visibility tier to Internal (ADR 0041 §2, #480)
6969626d · dbf3dbe6…diff - a resolution records which side it answered, so catching up after resolving no longer re-raises the path (#744); ADR 0039 says what the one-commit promise actually guarantees (#653)
b837289e · dbf3dbe6…diff - a publication survives a merge, an ingest and a resolve, so a catch-up stops silently reverting one (#783)
The reported symptom was consent fatigue: every capture in the repo refused with
"refusing to publish README.md", including changes touching nothing published.
The cause was the opposite of the symptom -- README was not published any more,
and the gate was correctly reporting a new @world transition on a publication
that had been reverted.
The ticket's hypothesis (an adopt/ferry fold re-sealing README from git-side
content) was not the mechanism. Reading the real graph, #750's own publication
change never published README at all, while it did publish both licenses. The
difference is that the licenses were NEW paths, so the snapshot seal minted them
@world, while README already existed and went through the merge -- where converge
picked between two addresses holding identical plaintext under identical
Visibility and its tie-break took the unpublished one.
@world is address-neutral, outside every signature and ignored by open,
key-travel and compression. That inertness is what made publication cheap; it is
also why nothing carries the marker structurally. It survives only where the code
writing a tree entry chooses to carry it, and four writers did not: converge, the
git bridge's ingest, resolve (so a bounce quietly unpublished the conflicted
path) and absorb's span rebuild. The snapshot seal learned this at #481 and
nothing generalized it, so Repo::put_sealed now is the generalization.
converge gains an is_published question on KeyOracle and compares publish-status
as part of a path's content, which makes the existing 3-way answer publish flips
correctly in both directions: a publication since the fork is an edit and wins
over a stale untouched side, and a deliberate unpublish is equally an edit and
still lands. On identical bytes with no base to judge from, the published side
wins -- widening was consented to once, and reverting it on no evidence is the
only outcome that loses something silently.
The other three carry, and carry ONLY. A re-seal never originates a publication:
ingest takes its policy from a git commit, which reaches loot without passing a
consent seam, so a break-glass commit adding a `published` rule seals plain and
leaves the transition for the capture gate to ask about in the usual place.
Sealing more tightly than the rule says is always safe; more loosely is what this
subsystem exists to prevent.
Ask 2 -- should an unpublish need consent? No. A prompt would make consent MORE
frequent, which is the failure the ticket is actually about: a gate that fires
routinely teaches operators and agents to pass publication flags reflexively. The
fix is fewer ways to unpublish, not another prompt, so dropping the rule is now
the only one. For the same reason the gate still asks the anchor OBJECT's marker
and not the anchor's .lootattributes: policy and marker disagree both when a
marker was lost and when a rule has just begun publishing a path, nothing there
can tell them apart, and trusting policy would turn the standing rule into a way
to publish unasked. A future marker-losing path would make the gate nag again,
and that is the failure worth having.
Ask 4 -- the forge's published index cannot disagree with main: this repo pushes
to relay.millerbyte.com and has never pushed to the forge, so the index holds no
README row. Had the push happened inside the window, it would have received the
unpublished object.
ADR 0041 gains a 2026-08-04 amendment; CONTEXT.md's Published entry records the
carry/originate split.
ec50222e · dbf3dbe6…diff - the converge seam merges with a diff3 three-way, so two disjoint edits compose instead of one silently dropping the other (#790)
2fd16e7f · dbf3dbe6…diff - a fourth caller stops being able to read the merge's unsealed tree, because merge_trees now returns an Unsealed with no tree field and exactly two exits, one for the two callers that seal it into a change and one for the cherry-pick that re-seals under its own attributes (#1409)
e14639ff · dbf3dbe6…diff - a publication marker that could not be read stops being carried as an unpublished one, because the three re-seals that carry @world across a rewrite now ask a read that refuses instead of a predicate whose swallow was argued for the publish guard alone, and the refusal is a typed variant rather than prose so a corrupt seal reaches the run-loot-verify remedy a vanished one already did, while ADR 0012 table rows name their consumer and state the grep the census is the output of (#1582)
6ee38984 · dbf3dbe6…diff - loot apply-patch lands as the inbound half of patch interchange, and the limit 1512 deliberately left open is CLOSED BY REFUSING: CRLF is refused on both sides, because the two halves are not the same kind of problem. A patch declaring lossy: crlf-normalized cannot reconstruct the bytes the change it names recorded - that is fidelity, read off the exporters own measurement rather than re-derived, since by the time the content is in the file it is LF. The half that actually bites is the LOCAL one: a hunks line space is LF, so writing an applied result back over a CRLF working file rewrites EVERY line ending in it, a whole-file change no hunk describes, that no report would show as more than the lines the patch touched, and that the next capture would sign. Reconstruction was rejected as a guess - the line space carries no endings at all, so an inserted line in a mixed file has nothing to decide from - and documentation as too weak for a silent whole-file rewrite. The cost is named rather than hidden: a CRLF repo cannot use patch interchange in either direction, and the remedy is a bundle, which carries content verbatim because it carries objects. Refusing is also what makes the round-trip pin true rather than approximately true. This is a separate verb from loot apply and not a mode of it, since a bundle carries objects, keys and signed change nodes while a patch carries none of those; dispatch is an exact-name lookup so nothing can collide there, and the place that DOES match loosely is verb_usage, which compares the token after loot rather than a prefix - pinned, because a prefix match would have dragged the whole apply-patch usage line into loot apply --help. Hunks apply strictly at the lines they declare, and what does not falls back to the ADR 0044 three-way against the change the patch names in its own parent row, which is the git am -3 shape and the reason that row is full width; loot-core gains one narrow door, converge::compose, because the theirs side of a patch is text at NO address while three_way_merge exists to pick between two addresses. A contended path is recorded and the run stops with the tree untouched, which makes this the sixth member of the 1506 resume family, and the census is what said so. Three of the ticket premises moved under it. The AC3 re-seal under the local .lootattributes is a SENTENCE and not a step, exactly as 1499 found for restore: the verb writes plaintext and then captures, so the tier is todays local policy by construction, and the export side already carries no tier for it to have inherited - the work was pinning it and saying it in the report. The AC4 no-partial-application rule forced the capture-first snapshot to run AFTER the plan rather than before, found by a fixture rather than reasoned out: a repo holding a path it cannot open has that paths plaintext on disk, capture_uncaptured_edits refuses to re-seal content it cannot read, and with the capture first the verbs own refusal was never reached at all. And a hunk body must be consumed by the counts in its @@ header and never by scanning for the closing separator, because a deleted line whose content is exactly a dash renders as two dashes, byte-identical to it. ADR 0082 freezes the header grammar now that a reader exists, which is why the 1786 fidelity rows went in before this ticket rather than after; the machine shape 1512 expected this ticket to name is deferred, with the reason written into ADR 0066 and PROSE_ONLY_CEILING, because it is one shape shared by both verbs and freezing the import side alone is the same half-contract 1512 declined on its own side (#1513)
d5fba81e · dbf3dbe6…diff
Renames are not followed. loot's tree maps a path to an address, so a rename is a delete and an add. This list is the history of the name, not of the bytes.