fix(sync): self-heal out-of-band rewrites instead of latching self-conflicts (TIN-3277) - #576
fix(sync): self-heal out-of-band rewrites instead of latching self-conflicts (TIN-3277)#576Jesssullivan wants to merge 4 commits into
Conversation
…IN-3277) An out-of-band writer (home-manager secrets/dotfile materialization, an editor bypassing the tcfs write hook) rewrites an enrolled file without ticking the vclock. The clock freezes at its last-synced value while the content diverges, so compare_clocks sees EQUAL clocks + differing blake3 and returns Conflict. The reconcile Conflict arm is record-only, so the path becomes structurally unpushable -- observed live on neo as 7 permanent conflicts stuck 24+ days with times_recorded in the thousands. Heal it: when the pair is provably a local-only rewrite, tick a comparison-only CLONE of the vclock before compare_clocks so it classifies LocalNewer -> Push. conflict.rs is untouched (compare_clocks byte-identical), so keep-both / loser-guard / FF-veto are unchanged. The safety proof is CONTENT identity, not device identity. The load-bearing clause is tracked.blake3 == remote_hash: the remote copy is byte-identical to what this device last synced, so overwriting it provably cannot lose another writer's work. Any foreign write -- from any device, including one publishing a spoofed written_by, or a second state cache under one device_id -- changes remote_hash, fails the clause, and records a Conflict exactly as before. written_by == device_id is kept as corroboration only; it is an unauthenticated client-set string and is deliberately not the safety boundary. Both out-of-band re-tick rules now key off a single stored_ordering computed against the STORED clock before any tick, joined by else-if. Previously the new predicate read a clock the TIN-2584 block had already ticked, so a strictly-dominated (Ordering::Less) self-pair could be double-ticked into Greater/LocalNewer and push over a remote the local side had never seen. Also: log the displaced remote manifest key at info on every self-heal push (audit trail in place of a parked copy -- the displaced bytes are this device's own tracked baseline), and log execute-time push vetoes, which previously left no trace in either `pushed` or `errors`. Scope: heals the SELF-pair only. Peer-side accumulation and the record-only Conflict arm remain open TIN-3277 scope.
7932d2c to
fe69adb
Compare
v1 → v2 (
|
| Finding | Change |
|---|---|
| C-MF1 double-tick promotes dominated self-pair to Push | stored_ordering computed once against the STORED clock before any tick; the two rules joined by else if; the predicate takes stored_ordering: Option<Ordering> so a caller cannot pass a mutated clock. Regression test tin3277_self_pair_dominated_clock_is_not_promoted_to_push pins main's Conflict. |
C-MF2 missing tracked.blake3 == remote_hash → isolated-second-cache clobber |
Clause added; it is now the load-bearing one. Negative test tin3277_remote_moved_since_baseline_still_records_conflict. |
| C-MF3 vetoed push rewrites baseline / loops / invisible | Veto proven unreachable for the healed shape via a real plan → execute → state cycle (pushed == 1; the engine's own local_edit_inferred tick reaches Greater independently). If the remote does move mid-cycle, a second cycle test pins the outcome: pushed == 0, visible ConflictInfo + status = Conflict, baseline moves to the live local hash which disarms the predicate → settles into pre-fix recorded-conflict behavior, no loop. execute_plan now logs skipped pushes at info (previously in neither pushed nor errors). |
| C-MF4 tests mirror, don't pin | Vacuous latched test replaced; hand-built post-push world replaced by the real cycle test (which now also asserts the latch is cleared). Two new negative/regression tests. Mutation-tested, below. |
| S-MF1 spoofed/colliding device identity → silent clobber | Closed by the content clause; written_by explicitly demoted to corroboration in code and body. |
S-MF2 devices.json self-heal-pushable past its Ed25519 gate |
Closed by the content clause: a stale local registry can only heal when the remote holds no revocation/enrollment we are missing. No hard carve-out — stated and argued explicitly in the body, since a carve-out would leave the 7 live stuck paths permanently unhealable. |
S-MF3 KeepBoth / undo-bundle bypassed, no parked copy |
Documented as redundant under content identity (displaced bytes are our own tracked baseline; a sidecar on secrets/** would multiply ciphertext for zero recovery benefit) + every self-heal push now logs displaced_manifest at info for auditability. |
| S-MF4 secrets SSOT local-wins, unreliable gate, age non-determinism | Acknowledged head-on in the body: every HM re-materialization will push (intended). The neo↔honey ciphertext parity check is promoted from a followup to a LANDING GATE. TIN-3278 interaction documented — a mis-keyed tracked makes the predicate fail closed (no heal), not fire wrongly. |
| Multi-host note | Body now states plainly that this heals the self-pair only; peer-side accumulation and the record-only Conflict arm remain open TIN-3277 scope. No overclaim. |
Mutation testing (the tests pin, they do not mirror)
- Drop the content clause →
tin3277_remote_moved_since_baseline_still_records_conflictFAILS:got Ok(Push { reason: LocalNewer }). - Drop it and restore v1's sequential structure / live-clock ordering → two failures, including
tin3277_self_pair_dominated_clock_is_not_promoted_to_push:got Ok(Push { reason: LocalNewer })— a verbatim reproduction of C-MF1's trace. - Unmutated: 8/8 pass.
sting gate
cargo fmt --all -- --check clean · cargo test -p tcfs-sync = 444 lib + 72 integration passed, 0 failed · cargo clippy -p tcfs-sync --all-targets -- -D warnings clean. Verbatim output in the PR body.
…content Gate-round fixes for the TIN-3277 self-heal, all in compare_both_exist: * tracked_is_exact: planning resolves `tracked` with the fuzzy suffix matcher StateCache::get_by_rel_path, while the push path re-resolves its baseline with the exact canonical key (StateCache::get) and only that exact entry is ever written back. Under TIN-3278's live key duplication those can be different records, so reconcile() now proves the two lookups return the SAME entry (pointer equality) and the self-heal predicate fails closed when they do not, with a warn! naming TIN-3278. No other classification decision reads the flag. * Degenerate local content: refuse to publish an out-of-band rewrite that emptied the file (local size 0 while the baseline was non-empty), and for *.age paths refuse bytes that no longer carry an age container header (binary or armored). Keyed off the .age extension rather than a secrets/ prefix because secrets/.manifest.toml is plaintext TOML by design and is one of the live stuck paths. Residual limit documented: header-preserving truncation is not detectable from content alone. * Skipped-push log: key the message off the engine's own verdict instead of asserting "remote moved since plan" — upload.skipped is also set for benign content dedup (UpToDate) and RemoteNewer. * Plan-time audit fields renamed displaced_*_at_plan and documented as best-effort provenance: they record the remote as observed during classification, not what the commit actually displaced. * Softened the doc comment's "provably": the clause compares our baseline against the manifest's self-declared file_hash, which validate_indexed_- manifest_binding does not bind to the chunk payload. Tests 8 -> 13: emptied rewrite, non-ciphertext .age rewrite (with a secrets/.manifest.toml control proving the guard is not a prefix rule), inexact baseline decline, age-header helper across both container forms, and a second structural double-tick regression that pins the else-if with the content-identity clause satisfied (the shape mutation B needed two edits to reach). *.age tests now use valid age bytes so each verdict still turns on the clause it is testing.
The veto test drives a *.age path, so the new degenerate-content guard applies to its local bytes too. Give both the baseline and the out-of-band rewrite a real age header so the test exercises the execute-time veto rather than being short-circuited by the container check.
Adversarial gate: v1 reject → v2 approve-with-must-fix ×2 → fixed at the gateRound 1: reject. Two independent refutations found a real correctness defect (the predicate read a clock the TIN-2584 block had already ticked, so a strictly-dominated self-pair could be double-ticked into a push) and a real safety hole (the "self-pair" proof rested on the unauthenticated, client-set Round 2 (v2, Round 3 (this head): all five v2 must-fixes applied. Two commits on top of What changed at the gate
Also from the non-blocking notes: the doc comment no longer claims the content clause provably prevents loss ( Evidence the new guards are load-bearing (mutation testing on sting)
Mutation E closes a coverage gap the reviewers named: v2 had no test that pinned the Verification (sting only — never neo)
Still blocking, by designThe neo↔honey ciphertext parity check for the 7 stuck paths stays a landing gate, not a follow-up: age encryption is non-deterministic, so size parity is a weak proxy — decrypt-and-compare or recipient-set comparison is the real check. And the honest limitation is unchanged: this heals the self-pair only. The out-of-band writer runs on every host, so expect some conflict volume to migrate to peers rather than drop fleet-wide. |
…(TIN-3278)
The primary state cache holds duplicate entries for the same logical file
under two key namespaces: an absolute canonicalized local path (the form
path_key() always produces on live get/set/mark_conflict today) and a bare
prefix-relative key left behind by an older keying scheme. Live evidence
on neo: secrets/.audit.log tracked at both
/Users/jess/tcfs/secrets/.audit.log (live, conflict record refreshes each
cycle) and /secrets/.audit.log (orphaned, times_recorded frozen at 1881
since 2026-07-07). Because get/set/remove/mark_conflict all re-derive the
key via path_key(), the orphan is never visited by any live read/write
path -- it just sits there forever, double-counted by raw-entry scans like
StateCache::conflicts() ("tcfs conflicts" reporting 9 vs the daemon's
per-cycle plan line reporting conflicts=8).
Root cause: path_key()'s identity fallback (return the input unchanged
when canonicalize fails) makes an unresolvable key indistinguishable from
an already-canonical one. A bare "/secrets/.audit.log" key's parent
("/secrets") does not exist at the filesystem root, so it canonicalizes to
itself and looks canonical even though it never independently resolves.
Fix: keep path_key() as the sole choke point for live writes (unchanged),
and add a load-time migration pass (migrate_duplicate_keys, wired into both
StateCache::open and StateCache::reload_from_disk):
- resolve_key_on_disk() mirrors path_key() but returns None instead of
falling back to identity, so migration can tell "canonical" apart from
"unresolvable".
- Pass 1 re-keys/merges any key whose independently-resolved form differs
from its stored key.
- Pass 2 handles orphans that cannot independently resolve at all: match
against the other loaded keys using the same suffix convention
get_by_rel_path() already uses for cross-host lookups. Only an
unambiguous single match is merged; zero or 2+ candidates leave the
orphan untouched rather than guess at a target.
- merge_duplicate_sync_states() joins causal history and never drops a
conflict record: vector clocks are merged pointwise-max via the
existing VectorClock::merge (entry vclock, plus same-side joins of a
merged conflict payload's local_vclock/remote_vclock), because
partial_cmp_vc reads a dropped component as 0 and would let a peer
falsely dominate -- classifying RemoteNewer and silently overwriting
the dropped side's divergence. Scalar fields (blake3/size/mtime/
chunk_count/remote_path/device_id) have no join, so they still come
from the higher-last_synced side; if either side carries a conflict the
merged entry keeps one (later detected_at as payload, times_recorded
as the max of the two, remote_manifest_key backfilled from the other
side when missing -- mirroring mark_conflict's preserve-on-missing).
- Load-time migration is IN-MEMORY ONLY and never marks the cache dirty.
StateCache::open is reached from read-only CLI paths that hold no
cross-process StateFileLock (lock_explicit_state_cache only locks when
a --state override is supplied), and Drop flushes on dirty -- so
dirtying here would turn "tcfs conflicts" into an unlocked writer whose
atomic-rename flush can clobber the daemon's concurrent locked update.
The fold is free for readers; it becomes durable on the next flush by a
legitimate lock-holding writer (the daemon dirties on any set /
mark_conflict and flushes the whole map under its own lock). A
debug_assert pins the invariant at construction.
- Each merge is logged via tracing::warn! and recorded in a new
StateCache::key_migration_log() accessor. Reload suppresses records
already seen in-process, so an orphan that disk keeps reintroducing
does not re-emit the same warn line every reconcile cycle.
- One canonicalize attempt per key per call, computed up front and shared
by both passes, so an unresolvable orphan no longer re-stats every
candidate on each reload.
Known boundary (documented on migrate_duplicate_keys): the len() != 1
ambiguity guard does not cover a multi-root host where two registered roots
share a relative suffix but only one materializes the file; closing that
needs root-scoped matching, which state-cache keys carry no attribution for
today.
Also adds StateCache::conflicts_naming_unknown_devices(), a read-only scan
for TIN-3278 defect 2 (ghost device ids, e.g. a stale "yoga" string, named
inside a recorded conflict but absent from the caller-supplied known-device
set). Reports only -- never mutates the entry, never touches devices.json
(that migration is TIN-3277 / TIN-1417 territory).
No file-level overlap with the TIN-3277 fix (#576, reconcile.rs only);
trial merge between the two branches is clean. PR #565 (TIN-2864,
Codex-owned) also touches state.rs but in an unrelated region.
Tests (9 TIN-3278 tests, all run on sting):
- tin3278_dedup_merge_on_load_collapses_duplicate_key_namespaces
- tin3278_dedup_merge_never_drops_a_conflict_record
- tin3278_merge_duplicate_sync_states_keeps_richer_conflict_and_max_times_recorded
- tin3278_merge_joins_vector_clocks_instead_of_dropping_one_side
- tin3278_merge_joins_conflict_side_clocks_same_side_only
- tin3278_migration_does_not_dirty_or_rewrite_for_unlocked_readers
- tin3278_migration_is_idempotent_on_second_load
- tin3278_ambiguous_orphan_suffix_match_is_left_untouched
- tin3278_conflicts_naming_unknown_devices_reports_without_mutating
sting: cargo fmt --all --check PASS, cargo test -p tcfs-sync PASS
(571 passed / 0 failed), cargo clippy -p tcfs-sync --all-targets
-D warnings PASS (0 warnings).
v2 — rewritten after both adversarial refuters rejected v1. Every must-fix is addressed below. Still DRAFT; the re-verify pass decides readiness.
The defect
An out-of-band writer (home-manager secrets/dotfile materialization on neo, an editor bypassing the tcfs write hook) rewrites an enrolled file without ticking the vclock. The clock freezes at its last-synced value while content diverges, so
conflict.rs::compare_clockssees EQUAL clocks + differing blake3 and returnsConflict. The reconcileConflictarm is record-only — it bumpstimes_recordedand flips status, it never pushes — so the path is structurally unpushable. Live on neo: 7 permanent conflicts (secrets/**,dotfiles/tcfs/devices.json) stuck 24+ days withtimes_recordedin the thousands.The fix
When the pair is provably a local-only rewrite, tick a comparison-only clone of the vclock before
compare_clocks, so it classifiesLocalNewer→Pushand converges.conflict.rsis untouched (compare_clocksbyte-identical), so keep-both / loser-guard / FF-veto surfaces are unchanged.v2 predicate (
self_rewrite_retick_applies)The safety proof is CONTENT identity, not device identity
This is the central v2 change.
tracked.blake3 == remote_hashmeans the remote copy is byte-identical to what this device last synced. Overwriting it therefore provably cannot lose another writer's work — there is nothing on the remote that is not already in this device's own tracked baseline.Any foreign write — from any device, by any mechanism — changes
remote_hash, fails the clause, and falls through to the ordinarycompare_clocks→Conflictpath exactly as before this PR. Concretely this closes:written_by: "neo".written_by(manifest.rs) is an unauthenticated client-set JSON string;validate_indexed_manifest_bindingbinds manifest bytes to the index'smanifest_hash, it does not sign the device claim, and the registry-signing key is HKDF-derived from the shared master key. v1 made that string an authorization input. v2 does not: to pass the predicate an attacker must publish content equal to our tracked baseline, i.e. leave the remote exactly as this device last left it, which is a no-op.device_id(the macOS FileProvider, atcfs pushagainst a different state dir — memory records this as live: "0.12.17 resolve blind to isolated caches"). Its push changesremote_hash; predicate fails; conflict recorded.device_id(re-imaged host reusing a hostname-derived id). Same mechanism — content, not identity, decides.dotfiles/tcfs/devices.jsonpushed stale. If this device's local registry copy missed a legitimate revocation or enrollment that landed remotely, the remote content is not our baseline, so no heal — a recorded conflict, as today.written_by == device_idis retained as corroboration, and because it preserves the pre-existing behavior that a distinct-device equal-clock pair records a conflict (and makes legacy v1 manifests, emptywritten_by, fail closed).No silent loss / why no parked copy
KeepBothand the.gitundo-bundle machinery exist to preserve a second writer's bytes. Under the content-identity clause the displaced remote content istracked.blake3— this device's own last-synced bytes, still described by its own state entry and, until GC, still addressable by the manifest key. A.conflict-{device_id}sidecar would duplicate our own history, and onsecrets/**would multiply ciphertext copies of sensitive material for zero recovery benefit.In place of a sidecar, every self-heal push now logs at
info:path,device,displaced_manifest(the remote manifest key being replaced),displaced_hash,local_hash. That is sufficient to reconstruct what was replaced.Refuter must-fixes → disposition
Less) self-pair becameEqual→ ticked again →Greater→Push.stored_orderingis computed once, against the stored clock, before any tick; the two rules are joined byelse if; the predicate takesstored_ordering: Option<Ordering>as a parameter so a caller cannot hand it a mutated clock. Regression testtin3277_self_pair_dominated_clock_is_not_promoted_to_pushpins main's behavior (recordedConflict). Independently, the refuter's exact trace also now fails the content clause (tracked.blake3 != remote_hashthere).tracked.blake3 == remote_hash— the justification's own unstated premise; isolated-second-cache clobber with noConflictInfo, no keep-both, GC-eligible chunks.tin3277_remote_moved_since_baseline_still_records_conflict.upload.skippedinvisible.local_edit_inferredtick (engine.rs:2205-2215), reaching{neo:2}vs remote{neo:1}→Greater/LocalNewer.tin3277_full_cycle_self_heal_advances_clock_and_clears_latchproves it with a realplan → execute → statecycle assertingpushed == 1. If the remote does move between plan and execute,tin3277_veto_between_plan_and_execute_records_conflict_and_settlespins the whole chain: push skipped,pushed == 0, a visibleConflictInforecorded (status = Conflict, surfaced bytcfs conflicts/ D-Bus), baseline moved to the live local hash — which disarms the predicate, so the next cycle settles into the pre-fix recorded-conflict behavior rather than replanning forever. Fail-closed, not silent. Plus:execute_plannow logs skipped pushes atinfo(they were in neitherpushednorerrors).ConflictInfois cleared andstatusleavesConflict(so the latch setup is load-bearing). Added the dominated-clock regression and the veto cycle test. Mutation-tested — see below.written_bydemoted to corroboration; documented in code and above.devices.jsonself-heal-pushable, bypassing the registry's Ed25519 verification.dotfiles/tcfs/devices.json"*. It does **not** demonstrate the different, undemonstrated property that the canonical Ed25519-signed registry (tcfs-secrets/src/device.rs`, untouched by this diff) stays correctly reflected. No hard carve-out added, deliberately — see below.KeepBoth/ undo-bundle safety net bypassed; no parked copy.infoaudit log of the displaced manifest key — now honestly labelled best-effort plan-time provenance (fields renameddisplaced_manifest_at_plan/displaced_hash_at_plan), because the commit happens later after the engine's own fresh remote read. A remote that moves in between is vetoed and logged at execute time instead. Corrected at the v3 gate.Explicit decisions the refuters asked for
No
secrets/**ordevices.jsoncarve-out. With content identity, the dangerous scenarios are already excluded: a stale local registry can only heal when the remote is byte-identical to our own baseline, i.e. when the remote holds no revocation or enrollment we are missing. A carve-out would instead leave exactly the 7 live stuck paths permanently unhealable, which is the ticket.Age non-determinism is real and intended here. Every home-manager re-materialization produces new ciphertext for identical plaintext, so post-fix each activation will push. That is the heal, not a bug — but it does mean secrets churn becomes visible push traffic.
LANDING GATE (not a followup): run a real ciphertext parity check neo↔honey for the 7 stuck paths before this reaches a host. Size parity is a weak proxy; decrypt-and-compare or recipient-set comparison is the real check. Promoted from a silent followup at the refuters' insistence.
TIN-3278 interaction. State-cache key-namespace duplication (absolute vs relative keys) affects 2 of the 9 entries in this triage and is a separate PR. It can make
trackedresolve to the wrong entry, which would make the predicate fail (no heal) rather than fire wrongly — the content clause fails closed on a mismatched baseline. Not fixed here.Multi-host honesty. This heals the self-pair only. The out-of-band writer runs on every host, so the equal-clock divergence class is inherently multi-host: peers keep accumulating record-only conflicts on the same paths, and the actual structural defect — the
Conflictarm that records but never acts — is untouched. Expect some conflict count to migrate to honey rather than drop fleet-wide. Remaining TIN-3277 scope for a later PR: route HM writes through tcfs, or make theConflictarm actionable (AutoResolveralready returnsKeepLocalfor equal device names and has no caller on this path).Symlink blind spot.
compare_both_exist_symlinkhas the same equal-clock hole and is untouched (neo's dotfiles are a nix/HM symlink farm). Deliberately out of scope to keep this narrow.Gate round (v3) — second-pass must-fixes
Two independent adversarial reviews of v2 both returned approve-with-must-fix. All five must-fixes are applied on this head.
compare_both_exist;compare_both_exist_symlinkhas no re-tick at all, andcompare_both_existroutes to it before any new code runs. neo's dotfiles are a nix/HM symlink farm and the headline test uses a hand-created regular file, which cannot disambiguate.lstaton the entry keys in~/.local/share/tcfsd/state.json(no writes, no daemon interaction, freeze respected). The 7times_recorded = 4696paths:secrets/api/{github_token,anthropic,gitlab_token,crates_io_token}.age,secrets/infrastructure/tailscale_auth_key.age,secrets/.manifest.toml,dotfiles/tcfs/devices.json— plussecrets/.audit.log, which appears twice under two key namespaces (/Users/jess/tcfs/secrets/.audit.logand/secrets/.audit.log, differentblake3) — the live TIN-3278 duplication, in the flesh. Symlink parity stays a follow-up, correctly scoped.tracked.blake3 != local_hashcannot tell "HM re-materialized the secret" from "HM/agenix failed and left a 0-byte or truncated file".local_size == 0whiletracked.size > 0; and for*.agepaths refuse bytes that no longer carry an age container header (binary or armored). Keyed off the.ageextension, not asecrets/prefix — becausesecrets/.manifest.tomlis plaintext TOML by design and is one of the 7 live stuck paths, so a prefix rule would have made this fix a permanent no-op there (exactly the A-MF1 failure mode). Verified against the live corpus: the 5 stuck.agefiles all carry the binary header, so the guard admits them; a binary-only guard would have been fine but the armored form is accepted too, proven by test. Residual limit stated in the doc comment: a truncation that preserves the header is not detectable from content alone.upload.skippedis also true for benignUpToDatededup,RemoteNewer, and the fast path.upload.outcome(Conflict→ "execute-time conflict veto: remote moved since plan";RemoteNewer;UpToDate→ "no-op: remote already holds this content"; else "skipped without a conflict verdict") under a neutral event name, with the verdict still logged as a field.trackedvia the fuzzyStateCache::get_by_rel_path(suffix match over the whole map, HashMap-order tie-break); the push resolves its own baseline via the exact canonical key (StateCache::get), and only the exact entry is ever written back bystate.set. Under TIN-3278 the safety proof can be evaluated against a different record than execute mutates, with no detection. "Fails closed" was asserted, not proven.reconcile()now also resolves the exact-key entry and proves the two lookups return the same record (pointer equality into the one entry map);self_rewrite_retick_appliestakestracked_is_exactand refuses when it does not hold, with awarn!naming TIN-3278. No other classification decision reads the flag, so nothing else changes behavior. The full-cycle test — which drives the realreconcile → execute_plan → statepath — still heals, which is the proof that pointer equality holds on the real code path and this guard is not itself a no-op.displaced_*fields are read at plan time but the overwrite happens later, so they are not provably what was displaced — and this log is S-MF3's sole replacement for a parked copy.displaced_manifest_at_plan/displaced_hash_at_plan; the comment and the S-MF3 row now say best-effort plan-time provenance, and point at the execute-time skip log for the moved-remote case. Emitting from the commit path would mean plumbing this throughengine::upload_planned_push_with_device's own remote read — a cross-cutting change with more blast radius than the honesty fix it buys; called out as a follow-up rather than smuggled in.Also taken from the reviewers' non-blocking notes: the doc comment no longer claims the content clause provably prevents loss — it now states the precise strength (
validate_indexed_manifest_bindingbinds manifest bytes tomanifest_hash, not the declaredfile_hashto the chunk payload, so the guarantee is "no honest remote writer's content is displaced"), and the noted coverage gap — that no test pinned theelse ifindependently of the content clause — is closed by..._same_content_republish_dominated_clock_is_not_double_ticked(mutation E below).Mutation testing — the tests pin, they do not mirror
Run on sting against this exact tree:
tracked.blake3 != remote_hashclause →tin3277_remote_moved_since_baseline_still_records_conflictFAILS withgot Ok(Push { reason: LocalNewer }). The content clause is load-bearing and the test catches its removal.tin3277_self_pair_dominated_clock_is_not_promoted_to_pushwithgot Ok(Push { reason: LocalNewer }). This is a verbatim reproduction of C-MF1's trace, caught by the new regression test.tracked_is_exactclause →tin3277_inexact_tracked_lookup_declines_self_healFAILS withgot Ok(Push { reason: LocalNewer })...._emptied_local_rewrite_is_not_pushedand..._non_ciphertext_local_age_rewrite_is_not_pushed, both withgot Ok(Push { reason: LocalNewer })...._same_content_republish_dominated_clock_is_not_double_tickedFAILS withgot Ok(Push { reason: LocalNewer }). This is the test that pins theelse ifon its own; mutation B needed two edits to reach the same class of defect.Tests (13)
..._self_rewrite_equal_clock_pushes_instead_of_conflicting..._distinct_device_equal_clock_still_records_conflict..._remote_moved_since_baseline_still_records_conflict..._self_pair_dominated_clock_is_not_promoted_to_push..._deferred_push_does_not_accumulate_ticks..._git_internal_self_pair_stays_conflict.gitcarve-out (G5-git-13)..._full_cycle_self_heal_advances_clock_and_clears_latchpushed == 1, clock{neo:1}→{neo:2}, latch cleared, next cycle no-op..._veto_between_plan_and_execute_records_conflict_and_settles..._same_content_republish_dominated_clock_is_not_double_tickedelse ifindependently of the content clause (mutation E)..._emptied_local_rewrite_is_not_pushed..._non_ciphertext_local_age_rewrite_is_not_pushed*.agenever publishes — plus asecrets/.manifest.tomlcontrol proving the guard is an extension rule, not a prefix rule..._inexact_tracked_lookup_declines_self_heal..._age_header_guard_accepts_both_container_formsEvery
*.agetest now uses real age-container bytes, so each verdict still turns on the clause it is testing rather than on an incidental header failure.sting results (verbatim)
All on sting (never on neo), scratch worktree
/tmp/tin3277-wt3, removed afterwards.Scratch worktrees
/tmp/tin3277-wt2(v2) and/tmp/tin3277-wt3(v3) on sting removed afterwards.Scope / collisions
One file:
crates/tcfs-sync/src/reconcile.rs.conflict.rs,engine.rs,state.rsunchanged. Source-only under the deploy freeze — no config, no CHANGELOG, no deploys.reconcile.rsonly to renamevalidate_indexed_symlink_target→validate_restored_symlink_target_for_physical_pathat 5 symlink call sites, and addsregistered_reconcile.rs. No functional overlap with the regular-file clock logic; mechanical rebase offset only.registered_remote_catalog/publication.rs/tcfs-core/config.rs. No overlap.