Changes touching this path
- Extract the engine's merge algorithm into engine/merge.rs
The three change-level merge operations — merge_tips (2-parent merge node),
carry_line (ADR 0039 superseding replay of a diverged suffix), and
change_delta_merge (cherry-pick/revert parent-delta core) — sat buried in the
1200-line "History & identity face" of engine.rs, though they form one cohesive
unit: all combine divergent history over converge.rs's per-path classifier.
Relocate them, with their dedicated result types (CarryOutcome, DeltaMerge,
DeltaAction), into a new engine/merge.rs submodule — the "Merge face" — matching
the engine/negotiation.rs and engine/custody.rs precedent (impl DagRepo via
`use super::*;`). A pure relocation: no interface or behaviour change. engine.rs
re-exports the three types so loot_core's public surface and loot-cli's call
sites are untouched.
Boundary is deliberately narrow: the "Reconcile & relay face" (apply_sync/stow/
resolve) stays put — the call graph shows it uses converge::merge_trees directly
and never the merge fns. The integration tests stay in engine.rs (they share the
engine test module's helpers), the same convention negotiation's extraction
followed.
engine.rs: 6280 -> 5889 lines. Full workspace suite green.
aecc2779 · dbf3dbe6… - loot-core testkit: extracted engine modules own their tests again (#660)
3412c046 · 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 - the change graph gets one read view, and the two forwarding bands over it retire (#868)
826c3339 · 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 - content whose embargo has already lifted stops reading as sealed, because promoting due keys out of escrow is now the construction of the reader every content read hangs off instead of a doc-comment obligation six callers hoisted by hand, and deleting four of those hoists left all 2438 tests green while loot surface told an author to request a grant from herself (#1464)
e59e46b3 · dbf3dbe6…diff - the loot-core tests stop sharing one repo and the shared store stops growing, and the design was already written one line above the bug: tmp said the temp root every fixture repo lives under while returning the root itself, so returning a fresh directory beneath a per-process root makes that sentence true as written with no doc edit and none of the 161 call sites changed. uniqueness is per call rather than per process, because the sites run on the harness's parallel threads inside one process, which is the half a pid does not touch and the half the isolation gradient had already separated - lib alone green, workspace red. a third hole neither ticket named is the larger one: 48 sites spelled DagRepo::init with the bare temp dir longhand, bypassing the helper entirely and invisible to both tickets, so the real set is 51 statements and not the sixty a three-line window had suggested - established by reading whole statements, since that window reads past short ones and stops short inside this crate's many multi-line format calls. the evidence is a paired experiment rather than a green run: two concurrent processes reddened object_reads five times in five, asymmetrically with the earlier starter losing every time, while the shared-store half needed six concurrent runs to reproduce at all and then failed 28 of 36 across four test names, two of them never recorded before, which is the fifth and sixth name for a ticket whose own point is that the name is not stable. after, the same experiments are ten of ten and thirty-six of thirty-six green, and a paired count on an idle machine has the unfixed arm adding 44 objects and rewriting graph, keyring and heads while the fixed arm moves nothing, byte-identical with mtimes unchanged to the millisecond. a census derives the property from source with no allowlist and was proved red four ways, including one site from the third hole that any list written from the two tickets would have passed, and including the census blinded, which reported ok on an empty set and was caught only by its own non-vacuity guard (#1667, #1688)
87c3aacb · dbf3dbe6…diff - the ferry layer gets its own #288 pin, and the decision is recorded: a_keyless_reconcile_merge_does_not_resurrect_files_deleted_on_the_spine replays the deleted-on-the-spine incident in a keyless repo, so the reconcile takes the merge arm through merge_tips rather than the carry, and asserts the reconcile minted a two-parent merge so the pin cannot slide onto the carry unnoticed. restoring the pre-9763144 ancestry-union tree_at turns it red (53 passed, 1 failed in the ferry module) while the carry pin stays green and both loot-core pins go red as the positive control. the carry pin stays green there because its replay feeds the node manifest against a polluted base and carried tree, and the converge DeleteWins arm drops the stale path: with that arm also made to re-adopt theirs the carry pin goes red too (52 passed, 2 failed), and with the arm alone broken the ferry module stays green (54 passed) while four loot-core tests go red, among them delete_vs_untouched_ours_deleted_theirs_kept_deletion_wins, the carry replay input shape. carry_line doc records that reason at the seam, ADR 0028 gains a #1790 amendment, and the carry and keyless test headers drop the claims the new test made stale. no behaviour changed, only a test, comments and the ADR, and the workspace suite is green (#1790)
588d7a2b · dbf3dbe6…diff - every land now states which versions it published that the lander did not write, and refuses nothing - ADR 0092's detection half, part 1 of 2 of this ticket. a version is foreign when its author is not in the lander's own key set, which is new: loot_identity::own_signing_keys reads the active id.pub plus every id.pub.rotated archive that loot id rotate writes, exposed as Workspace::own_signing_keys beside author_pubkey rather than instead of it, because the two answer different questions. carry_line's Foreign asks may I RE-AUTHOR this and only the current key can sign, while the gate asks did I WRITE this and authorship is historical, so a rotated operator's own change is foreign to the carry and theirs to the gate and both are right - each site now names its own question and points at the other, since unifying them breaks a caller in either direction. the judged set is the ancestor closure of the landed change minus the closure of the main it was judged against, read BEFORE the pass because by verdict time main already includes the landed change and the difference would be empty on every land, and it is the whole closure rather than carry_line's first-parent chain because a stack carrying a merge of its own would otherwise have one side waved through. an absent author is foreign, which is carry_line's answer too, so the two predicates disagree only about which keys are mine. the count rides the verdict line as foreign= and a block names the versions, since the count says that and the next question is which. foreign= sits immediately BEFORE pusher= and not after it, which I got wrong first: pusher= carries an arbitrary percent-encoded path and is designed as the tail, workflow.md says the path comes last, and five pusher tests read it with ends_with - appending after it broke exactly those, while inserting before it breaks only a fixed-index reader and the repo has none, and those five pass unmodified. the key set reads only id.pub.rotated and never the private id.rotated spelling, which holds a secret key and has no business answering a question about authorship. a machine that pulled rather than rotated holds no archive, so there your own pre-rotation work reads as foreign, which fails toward asking for an approval nobody owed and never toward waiving one, and the block and workflow.md both say so. red under mutation, counts read each time: the key set ignoring the archive (52 passed and 1 failed), the filter admitting the private archive spelling (52 passed and 1 failed), and an unauthored change read as the lander's own (202 passed and 1 failed), each restored to 53, 53 and 203. ADR 0092's status now says which sections are built, because section 5 - the gating column, review/approve and the refusals - is part 2 and until it lands detection reports and nothing refuses, which is that section's decided default anyway. no migration, no wire or format byte moves, and nothing on a host moves, so this owes no deploy. the workspace suite is green (4189 passed over 133 binaries) (#2178)
8cb77a56 · dbf3dbe6…diff - loot revert --continue and loot cherry-pick --continue now replay the stopped pick instead of closing the stop, on the answers loot resolve gave to that stop and on nothing else: the in-progress record gains the version the verb applies, the conflicts its stop recorded and the answers, resolve writes an answer there only when the conflict it settles is the one the stop recorded at that path, and change_delta_merge takes only the answers its caller hands in, none from an ordinary run, so the repo settled ledger is still never read by a pick (#744). a path whose content moved after it was answered is asked again rather than merged, a path holding the answer bytes at another address keeps it, a replay that stops again keeps the record restore point, and a record written before this closes as it always did. ADR 0080 gains the #1986 amendment deciding the three questions and recording, measured through the binary, that move, squash and apply-patch stop again after a resolve for reasons that are not a ledger and that apply and merge have nothing to replay; CONTEXT.md, the resume module and the usage note say the same. the resume runs to completion through the binary on the home position, on a pinned primary and in a lane. red under mutation, counts read each time: the repo ledger handed to the resume (0 passed, 1 failed in the stale-entry pin), the repo ledger handed to the three-way (0 and 1 in the loot-core pin, 0 and 1 in the re-run pin), a moved path left to the three-way (0 and 1), the address test alone (0 and 1 in loot-core, 0 and 1 through the binary), an answer keyed on the path alone (0 and 1), the record sentence worded by verb (0 and 1), the replay branch emptied (0 and 4), the re-stop taking a new restore point (0 and 1) and the usage note dropping revert (0 and 1), each restored to green. the record gains a trailing section, no format byte moves and there is no migration, so this owes no deploy. the workspace suite is green (4302 passed over 138 test binaries, 12 ignored) (#1986)
8bd8e53c · dbf3dbe6…diff - the review-sweep fix-up over #2287, #2283 and #1986. a resumed pick now asks again about a path its stop asked about when the record holds no answer for it, rather than handing it to the three-way, which merged over the resolution and put back the edit it rejected: change_delta_merge takes the stopped record whole and takes every question on it out of the three-way, keeping an answer only where the line still holds it, so a resolve that recorded no answer (a loot from before #1986, or a failed answer write, which the workspace comment now truthfully says costs a re-ask) re-stops with nothing applied, keeps the restore point, and lands once the question is answered again; refusing --continue was weighed and rejected in the ADR 0080 #2291 amendment, since nothing would be left to resolve. the replay reads its direction from pick::SPECS, which now pairs each spec with it, and a record naming a version under a verb SPECS does not hold refuses instead of replaying as a cherry-pick. the usage note names no verb, so each stopping verb line is true of its own verb, and its test now fails if the note names a verb that replays. a cherry-pick replay to completion is pinned through the binary on the home position, a pinned primary and a lane. false sentences fixed: the in-progress operand reader, the pick stop comment, the resume test doc link, the continue_clause list, the CONTEXT.md in-progress entry and HUNT-PERF on graph file reads; rewrap leftovers in fixture.rs and engine.rs are fixed, and the replaying tests keep their resolution file outside the working tree. red under mutation, counts read each time: questions taken from the answers alone (16 passed and 1 failed in the loot-core pin, 31 and 2 through the binary), an unknown verb replayed as not inverted (20 and 1), the note naming cherry-pick and revert again (19 and 2, the second the census the usage lines no longer matched), each restored to green. no migration and no format byte moves, so this owes no deploy. the workspace suite is green (4309 passed over 138 binaries, 12 ignored) (#2291)
812c48c7 · 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.