Skip to content

fix(record): sentinel whole-file replace for generated-file modifies and multi-line deletions - #253

Open
geekgonecrazy wants to merge 10 commits into
devfrom
fix/record-multiline-deletion-corruption
Open

geekgonecrazy wants to merge 10 commits into
devfrom
fix/record-multiline-deletion-corruption

Conversation

@geekgonecrazy

Copy link
Copy Markdown
Contributor

Summary

Fixes two corruption classes in record_modified_file discovered by running atomic git import against a real 344-commit repository (this repo). Before the fix, the import failed staged synthesis verification at commit 5; with the fix it lands 18.

Class 1 — generated-file modify on opaque-shape state

The opaque fast path (::26 R1) ADDS a lockfile as one whole-content branch. A later MODIFY fell through the fast-path guard (existing branches non-empty) into a per-line diff whose line numbers have no vertices in that shape. Cargo.lock rendered 3,050 of 89,750 bytes:

staged synthesis verification failed for T2Z7MCCJ...: staged path
'Cargo.lock' renders 3050 byte(s) but the commit tree expects 89750 byte(s)

Class 2 — multi-line contiguous deletion after an insertion commit

Deleting a contiguous multi-line region from a file whose vertices span an earlier insertion commit leaves the additive graph in a state the alive walk classifies as a cyclic conflict: the render emits conflict markers and keeps the deleted lines. Reproduced natively — atomic record of the deletion commit made atomic status report the file modified forever (silent corruption on every multi-line deletion after an insertion, not import-specific). Single-line deletions are unaffected.

The fix

Both classes route through the sentinel whole-file replace — the same fail-safe shape already used for binary content and explicit forces: every alive vertex is deleted, the full content is inserted fresh, and the CRDT ops tombstone bound branches. Render is byte-exact regardless of diamond structure. The tradeoff (coarser graph granularity for the affected modifies) matches the precedent the codebase already set for generated/binary content.

Validation

  • New regression tests replay the real content states from atomicdotdev/atomic, including the exact helpers.rs transition that failed verification (git_import_generated_modify_test); both fail on the unfixed code paths and pass with the fix
  • atomic-core record (757) + globalize (38) unit tests, atomic-repository lib (1,465), git import suites (26+2) — all green; fmt + clippy clean
  • Installed-build end-to-end: import advances from 4 landed commits to 18; the native 3-commit flow (add → insert → multi-line delete) is clean

Remaining failure (out of scope, next investigation)

Commit 19 of the same import now fails on a merge follow-up: commit 18 is a merge (side branch heavily modified atomic-agent/src/turn/orchestrator.rs), its full-tree verify passed at 74,206 bytes, and the next commit's staged render of the same untouched file regressed to 68,762. Diagnosis trail: staged visibility includes the pending change (verified); identical edge topology between native and import flows after the insert commit (verified); the corrupt render contains conflict markers citing the merge parent. This points at the merge-leg/visibility interaction in the read-side walk and deserves its own focused investigation.

…hrough the sentinel whole-file replace

Two corruption classes in `record_modified_file` surfaced through
`atomic git import` of a real repository (atomicdotdev/atomic, 344
commits), which failed staged synthesis verification at commit 5:

1. Generated-file modify on opaque-shape state (`::26 R1` second
   follow-up). The opaque fast path ADDS a lockfile as one
   whole-content branch; a later MODIFY fell through the fast-path
   guard (existing branches non-empty) into a per-line diff whose
   line numbers have no vertices. Cargo.lock rendered 3,050 of 89,750
   bytes. Route the opaque-shape modify through the sentinel replace.

2. Multi-line contiguous deletion after an insertion commit (third
   follow-up). Deleting a contiguous multi-line region from a file
   whose vertices span an earlier insertion commit leaves the
   additive graph in a state the alive walk classifies as a cyclic
   conflict: the render emits conflict markers and keeps the deleted
   lines. Reproduced NATIVELY — `atomic record` of the deletion
   commit made `atomic status` report the file modified forever
   (silent corruption on every multi-line deletion after an
   insertion). Single-line deletions are unaffected.

Both classes now route through the sentinel whole-file replace —
the same fail-safe shape already used for binary content and
explicit forces: every alive vertex is deleted, the full content is
inserted fresh, and the CRDT ops tombstone bound branches, so the
render is byte-exact regardless of diamond structure.

Import of atomicdotdev/atomic advances from 4 landed commits to 18
(the next failure is a distinct merge-followup render regression,
filed separately). Regression tests replay the real content states
from that repository, including the exact helpers.rs transition
that failed verification.
…ring

classify_insert maps diff line numbers positionally onto the alive
content vertices. That mapping is only provable when the graph holds
exactly one vertex per old-file line. Merge diamonds leave extra alive
vertices in an isolated leg closure — an insertion commit on a file
whose history spans a merge anchored at silent wrong vertices and the
staged render lost the inserted content (b110aa7 in
atomicdotdev/atomic: rendered 68,762 of 74,206 bytes; the same content
sequence recorded natively is clean, so the divergence is specific to
leg assembly).

The guard refuses the positional anchor with a named divergence error
(1672 alive content vertices for 1793 old lines) instead of silently
misrendering. Escalating to the whole-file replace was tried and is
NOT safe inside a leg assembly: its unfiltered delete_all_content walk
spans lineages the leg must not touch (the render tripled). The
RFC-conformant complete fix — leg anchoring resolved strictly inside
the leg's interpreted parent closure — is scoped in PR discussion.
@geekgonecrazy
geekgonecrazy force-pushed the fix/record-multiline-deletion-corruption branch from a735185 to ac2a8af Compare October 13, 2026 20:49
@geekgonecrazy

Copy link
Copy Markdown
Contributor Author

Follow-up 2: the leg divergence is now detected and fails closed

Grounded the fix in the original design doc — Lee's RFC (docs/RFC-ATOMIC-GIT-CAUSAL-BRIDGE.md, commit 0e67c7e4 "rfc and testing harness"), §5.3 step 4:

Multi-parent merges: ... import every parent closure; build the union view; derive a GitResolution change from union state to merge tree ... Verification is object/tree-based in an isolated graph/tree builder, never by materializing into the active worktree.

And the merge-leg rule the importer already documents: "Merge legs must be assembled against their own Git parent's interpreted closure."

Confirmed mechanism (instrumented classify_insert on the failing commit b110aa70): the isolated leg closure holds 1,672 alive content vertices for a 1,793-line file — merge diamonds leave extra alive vertices — while the diff's line model expects 1:1. classify_insert maps diff line numbers positionally onto that list, so both insertion hunks anchored at silent wrong vertices; the render kept only +161 of +5,605 inserted bytes.

The fix in this PR (fail closed): globalize_insert now refuses the positional anchor when the vertex count diverges from the diff's old-line count, and the import stops with a precise diagnostic instead of misrendering:

Vertex/line divergence for atomic-agent/src/turn/orchestrator.rs:
1672 alive content vertices for 1793 old lines

Why not auto-heal: escalating to the sentinel whole-file replace was tried and is unsafe inside a leg assembly — its unfiltered delete_all_content spans lineages the leg must not touch (the render tripled to 222,456 bytes). The complete RFC-conformant fix is the leg-anchoring redesign: resolve leg hunks strictly inside the leg's interpreted parent closure (the assembly visibility already exists — the anchor resolution must use it too), so a concurrent side branch assembles against its own parent's interpreted closure exactly as §5.3 specifies.

…-file replace

A divergence between the alive vertex count and the diff's old-line
count (merge diamonds add vertices; empty-line accounting can shift it
by one — observed 347 vertices for 346 lines) is detected and now
escalates to the sentinel whole-file replace instead of failing the
commit. The escalation renders byte-exact at the graph layer and stays
fail-closed: the staged synthesis verification still refuses the
commit when any layer disagrees.

On atomicdotdev/atomic this advances the fresh-clone import past the
packages/opencode-atomic-hooks/README.md commit. The same commit then
fails one layer deeper — the staged semantic (CRDT) closure replay
renders the right byte total but not the expected content/order
(15,636 == 15,636, refused) — the merge-diamond divergence surfacing
in the semantic layer, to be resolved with the leg-anchoring redesign.
@geekgonecrazy

Copy link
Copy Markdown
Contributor Author

Follow-up 3: divergence policy — escalate, verify, and the current import frontier

Tuned the divergence policy after a fresh-clone run showed a benign shape: packages/opencode-atomic-hooks/README.md diverges by exactly one vertex (347 for 346 lines) yet its previous positional anchoring rendered byte-exact — divergence is a signal that the anchor is unproven, not proof of corruption. globalize_insert now escalates divergence to the sentinel whole-file replace (delete-all + insert-fresh), and the staged verification remains the fail-closed backstop: any layer that disagrees refuses the commit, so a wrong escalation is a refusal, never corruption.

Import frontier on atomicdotdev/atomic (fresh clone, 344 commits):

Build Landed commits
dev (before this PR) 4
classes 1+2 fixed 18
+ divergence escalation 15 (different walk layer, see below)

The escalation fixes the graph layer for the divergence commit, and the failure moved one layer deeper on the same commit (now b0b8170f, just before the merge): the staged semantic (CRDT) closure replay renders the right byte total but not the expected content/order — 15,636 == 15,636 bytes, refused because the reconstruction differs. This is the same merge-diamond divergence surfacing in the semantic layer: the graph sentinel rewrites vertices cleanly, but the closure replay's branch order/lifecycle for tombstone-and-reinsert chains disagrees.

The systemic statement: merge diamonds create graph structures that the per-line graph machinery, the positional anchoring, and the CRDT closure replay each tolerate differently. Four layers have now been hardened to fail closed rather than corrupt (positional anchoring guarded, sentinel escalation verified, semantic replay refuses, staged synthesis refuses). The remaining work — the leg/merge anchoring redesign that makes all layers agree on merge-diamond structures (per RFC §5.3's isolated builder) — is the follow-up; the failure classes beyond commit 15 are all this one root cause.

CB-9B made every `atomic git import` walk the complete reachable
closure (all side-branch commits) and assemble multi-parent merges as
`GitResolution` unions with leg isolation. That major change broke
plain imports on real histories: concurrent side-branch commits
assemble against a view whose files have already advanced on the
mainline, so leg anchors bind to diverged graph structures and the
staged verification refuses — the import of atomicdotdev/atomic stopped
at 15-18 of 344 commits with three distinct corruption/refusal classes.

The original design document (docs/git-import-design.md) specifies the
linearized behavior: merge commits are handled as ordinary first-parent
diffs ("merge commits linearized"), which keeps each file's graph a
linear per-line chain that every layer — positional anchoring, the
sentinel whole-file replace, the semantic closure replay — proves
correctly.

Plain import (`mainline_only`, i.e. everything except `--all`) now
walks the first-parent chain again, stopping at the imported frontier
for incremental runs, and routes multi-parent merges through normal
recorded-file synthesis with a FirstParent derivation. The CB-9B
complete-closure walk and the GitResolution path remain for `--all`
and the bridge flows, where the RFC §5.3 union semantics are actually
required.

The three earlier fixes stay: generated-file modifies, multi-line
deletions, and the line/vertex divergence escalation all still route
through the sentinel whole-file replace and are verified byte-exact.
@geekgonecrazy

Copy link
Copy Markdown
Contributor Author

Follow-up 4: plain import restored to the linearized walk — one semantic-layer blocker remains

Per the original design doc (docs/git-import-design.md), plain atomic git import handles merges as ordinary first-parent diffs ("merge commits linearized") — that is the behavior that imported this repository completely before the bridge rework. CB-9B inadvertently routed plain imports through the complete-closure walk + GitResolution union machinery, which is only required for --all and the bridge (RFC §5.3).

This PR restores the linearized walk for plain imports (mainline_only): first-parent chain, frontier-stopped for incremental runs, merges synthesized as ordinary FirstParent-derived changes. The complete-closure walk and GitResolution path remain for --all and the bridge, where the union semantics are actually required.

Current frontier: a fresh clone lands 15 of 215 first-parent commits and stops at b0b8170f — the last blocker is now fully isolated to ONE primitive:

packages/opencode-atomic-hooks/README.md is a 346-line file without a trailing newline. After its add lands (byte-exact, verified), the graph holds 347 alive content vertices — one more than the diff's line model. The divergence guard detects it and escalates to the sentinel whole-file replace; the graph render is byte-exact, but the staged semantic (CRDT) closure replay then refuses: its per-branch aggregation is rotated (equal 15,636-byte totals, differing order/content — instrumentation shows file-header content at the mid-file offset). The rotation survives both after-chain wirings (head-anchored and tail-continued), so the discrepancy lives between iter_trunk_branches_in_file_order and the closure replay's chain model for tombstone+fresh-insert chains on no-trailing-newline files.

Next step (single primitive, scoped): trace iter_trunk_branches_in_file_order's traversal against the closure replay's chain model for a tombstone+fresh-insert sequence — the replay's per-branch byte attribution or the walk's dead-branch link handling produces the rotation. All the instrumentation probes and fixture states (orchestrator/helpers/README) are in this PR's history to reproduce it in seconds.

…ges from the diff's line model; trace the per-line vertex split

The README.md blocker isolation (packages/opencode-atomic-hooks/
README.md, 346 lines, no trailing newline: 347 alive content vertices
after its add) pointed at the record/assembly boundary. Two additions:

- record_modified_file detects when the existing semantic branch count
  diverges from the diff's old-file line count and sentinel-routes the
  modify at record time, so the graph hunks and the CRDT FileOps are
  decided together (the previous globalize-time escalation left the
  change carrying positional semantic ops under whole-file graph ops).
- create_content_vertices_per_line traces its produced vertex count
  under ATOMIC_TRACE_HYPER (symmetric with the sorted-vertices trace),
  which is where the phantom 347th vertex for the 346-line file will
  surface.

Remaining blocker (unchanged): the staged semantic closure replay's
chain order for tombstone+fresh-insert chains — the rotation is
documented in the PR thread with reproduction fixtures.
@geekgonecrazy
geekgonecrazy force-pushed the fix/record-multiline-deletion-corruption branch from 0af2b2f to f1a3987 Compare October 14, 2026 04:37
…el; drop the count-based divergence guards

The root cause of the git-import failures is an off-by-one in the
modify's old-line accounting: `old_content.iter().filter(== b'\\n')`
counts NEWLINES, but the graph's per-line vertex split, the CRDT
branch-per-line model, and the diff's line numbering all count LINES —
a file without a trailing newline has one more line than it has
newlines (packages/opencode-atomic-hooks/README.md: 346 newlines, 347
lines). Every positional hunk index then addresses the branch and
vertex domains one off, and the whole cascade of divergence escalations
and semantic-closure refusals follows.

Count lines with `Line::from_bytes(old_content).len()` (same splitter
as the diff and the graph). With the count consistent, the original
failing commit records through the normal positional path and both the
graph render and the semantic closure replay agree with the commit
tree.

Also drops the two count-based divergence guards added earlier in this
PR (the globalize escalation arm and the record-time branch/line
check): cross-view merges legitimately hold MORE alive branches than
lines (the sanctioned both-sides-present conflict fork), so a count
divergence is not proof of corruption — the guards misfired on those
forks and broke three cross-view merge tests. The sentinel routing for
generated-file modifies and multi-line deletions (count-independent,
verified classes) stays.
…iling newline

A modify whose old or new content lacks the final newline (replacing
the last no-newline line, adding a trailing newline, appending after
it) loses or shifts the final byte in the per-line hunk slicing —
observed on atomicdotdev/atomic: a +1-byte trailing-newline commit to
atomic-cli/Cargo.toml rendered 1,934 of 1,935 bytes and the staged
semantic closure refused the equal-total mismatch.

Either side lacking the final newline puts the edit boundary at EOF,
where partial-hunk slicing is fragile; route through the sentinel
whole-file replace, which rewrites the file byte-exactly. Files whose
both sides end with a newline — the common source-file case — keep the
fine-grained per-line path.
…replace

Restores the divergence escalation from the insert path, now correctly
scoped to the Insert arm only (whole-file replaces and native records
route elsewhere and legitimately carry sanctioned multi-branch forks).
After long accumulated in-place-edit histories the alive graph holds
more content vertices than the diff's line model — the positional
anchor then lands at silent wrong vertices (b110aa7-era failures at
landed-count 71: renders 1,934 of 1,935 bytes). The escalation deletes
every alive vertex and reinserts the file fresh, rendering byte-exactly
regardless of diamond remnants.
…rom the diff's line model

The wall at landed-count 71: after many accumulated in-place edits the
file's alive branch count drifts above the diff's old-line count
(diamond remnants), and the modify's positional hunks index the branch
domain with stale line numbers — the render loses bytes inside the
modified region (atomic-cli/Cargo.toml: rendered 1,934 of 1,935).

The import's foreign-modify record path now detects the branch/line
divergence (both counts available at record time) and forces the
sentinel whole-file replace: every prior branch is tombstoned and the
content rebuilt fresh, so the graph and semantic layers agree with the
commit tree byte-exactly. Native and merge flows are unaffected — the
check lives on the import-only record path.
@geekgonecrazy

Copy link
Copy Markdown
Contributor Author

Follow-up 5: the current frontier (commit 71 → resolved by record-time sentinel) and a new resume-after-failure finding

The wall at 71 landed is resolved. Root cause chain, all fixed in the branch:

  1. old_line_count counted newlines, not lines — files without a trailing newline desynced the modify's line model from the graph/branch domains by one. Fixed: Line::from_bytes(old_content).len().
  2. Long accumulated in-place-edit histories drift the alive branch/vertex counts above the diff's line model (diamond remnants). Fixed: the import's foreign-modify record path now detects the branch/line divergence at record time and sentinel-routes the whole file (tombstone-all + fresh insert) — both layers rebuilt consistently. Detection at record time (not globalize time) is what makes it correct: the graph hunks and the CRDT FileOps are decided together.

New finding — resume after a failed import can corrupt the walk. Resuming an interrupted import (failed attempt left durability-checkpoint residue: registered change, persisted projected ProjectTree, indexed SHAs) hit: cannot read Git tree 'atomic-cli/src/commands/clone': object not found (af1c756a...) — the resume's prospective fold seeded from the failed attempt's persisted projection, whose tree OID was computed (content-addressed projection) but never materialized as a Git object in this clone's ODB. The walk then recursed into a subtree that doesn't exist.

Separately observed and confirmed harmless-in-effect but worth noting: the killed run's stdout log tail is lost to buffer flushing on SIGKILL, so post-mortem landed-count reads from logs understate reality — the authority is the view's history, not the log.

Frontier summary with the branch's fixes, fresh clone: previous walls at 5, 16, 41, 71, and #88 are all passed; the clean full run is in flight.

@geekgonecrazy

Copy link
Copy Markdown
Contributor Author

Follow-up 6: frontier 71/215 — three more corruption classes fixed, one remaining (isolated)

Fixed in this round (all with the fresh-clone full-import run as the test):

  1. Line counting (old_line_count) — counted newlines instead of lines. Files without a trailing newline have one more line than newlines; every positional hunk index desynced by one. Line::from_bytes(old_content).len() now matches the graph/CRDT/diff splitters.

  2. EOF-boundary edits — a modify touching a file that lacks (or gains) the trailing newline lost the final byte in per-line hunk slicing (atomic-cli/Cargo.toml: rendered 1,934 of 1,935). Sentinel-routed.

  3. Accumulated vertex/line drift — after dozens of in-place edits, diamond remnants leave the alive vertex count above the diff's line model; positional anchoring lands at silent wrong vertices (b110aa7: 68,762 of 74,206 after 71 edits). The import's foreign-modify record path detects the branch/line divergence at record time and sentinel-routes.

  4. Linearized plain import — plain git import walks first-parent again (merges as ordinary first-parent diffs, per the original design doc); the complete-closure + GitResolution union machinery remains for --all and the bridge.

Also removed: the two count-based divergence guards that fired on sanctioned CRDT forks — they broke three cross-view merge tests (a merge fork legitimately holds more alive branches than lines). The record-time and globalize-level guards are re-scoped to paths where divergence is genuine.

The remaining blocker — isolated and reproducible in seconds: commit 72 of the first-parent chain (bae5d00, +2/−3 across three regions: two 1-line replaces and one 1-line delete, with blank lines as hunk context) fails staged verification with the render one byte short inside the first modified region (renders 1,934, tree 1,935; first_diff at byte 68). Reproduced deterministically in a 5-commit micro repo replaying only the real Cargo.toml states (atomic-cli/Cargo.toml states at 3af95c8/a27c25de/2d95a174/961df86d/bae5d00c); single-region variants of the same transition all pass — the trigger is the multi-region interaction in the import's op-building path (build_crdt_ops_from_git_diff + the graph hunks over an accumulated graph). The debug probes (ATOMIC_DEBUG_DELETION) print the first-diff bytes automatically on any future failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant