Appearance
Filesystem events
Generated by the probe, not written by hand:
cargo run -p retype-watch --example probe_eventsThe translation it measures is the one that ships: retype-watch holds the collection and translation, retype-core holds only the correlator and knows nothing about notify.
Raw transcripts are kept in docs/dev/measurement/probe/, named <platform>-<date>.txt, so every claim below can be checked against the run that produced it. CI uploads the same output as an artifact.
Each run creates a temporary directory, watches it, and replays a scenario suite. Scenarios are split into a setup and an action so the correlation starts from a primed state, the way it does in the daemon when a file has existed for a while. For each action it prints the raw events, then what the name-pair strategy concluded, then what the identity strategy concluded.
A conclusion is a fact, which says only that this name became that name in the same directory, and the intent the grammar reads from it. intent=none is therefore the answer for a rename that means nothing, such as a change of case: correlation does not know formats, and deciding where a stem ends needs the registry. Every transcript here is from the probe revision that printed it; the 2026-10-29 files are kept as history.
1. Linux (inotify, notify 8)
Measured 2026-11-02, transcript docs/dev/measurement/probe/linux-2026-11-02.txt.
1.1 Raw events
An in-place rename produces three rename events, not one:
Modify(Name(From)) a.heic
Modify(Name(To)) a.jpg
Modify(Name(Both)) a.heic -> a.jpgAlongside them, ordinary noise: Create(File), Access(Open(Any)), Modify(Data(Any)), Access(Close(Write)). The dataless events matter only because they must be filtered: a rename followed by a write produces a second data event for the new name, and treating it as a trigger would convert twice.
1.2 Scenario results
| Scenario | Pair strategy | Identity strategy |
|---|---|---|
| Rename in place | a.heic -> a.jpg, intent jpg | Same |
| Rename case only | fact, no intent | fact, no intent |
| Rename stem and extension | none | none |
| Rename across directories | none | none |
| Two renames in one window | e.heic -> e.webp, intent webp | Same |
| Rename over an existing file | f.heic -> f.jpg | Same |
| Twenty renames in one gesture | twenty facts | twenty facts |
| Rename then write | h.heic -> h.jpg, once | Same, once |
| Rename then delete | i.heic -> i.jpg | none |
| Rename while a job's files appear | j.heic -> j.jpg | Same |
1.3 What this establishes
- Identity correlation and name pairing agree on the simple cases, so the fallback is safe
- Both report the net rename. A double rename is two moves; identity sees only where the file ended, and pairing sees both and collapses them with
coalesce. The user's final intent waswebp; the intermediatejpgnever existed for long enough to convert, and converting it would produce a file nobody asked for - Rename then delete yields nothing under identity, which is correct and is worth relying on: the file is gone, so there is nothing to convert. The pair strategy would report a job for a path that no longer exists, and the dispatcher would have to fail it
- The filters behave: case-only, stem change and cross-directory produce no fact under either strategy, as Watch scope §7 requires
- Batches are fine: twenty renames in one gesture yield twenty facts, no loss, which the previous debouncer-based design could not promise
- Data events must be filtered: a write after a rename arrives as an ordinary modify, and it is not a second trigger
- A new subdirectory's contents may not be in the batch. In the churn scenario inotify reported the created directory but neither file created inside it, so the rename was still named by identity. The watch for a directory created at runtime is added after the event that announced it, and a file created before that happens is not in the stream
1.4 Duplicates
Linux reports one rename as From, To and Both, so a batch yields the same fact up to three times. The feeder dedupes by source and target within a batch, and the daemon has to do the same.
1.5 Feed order
Discovered by the probe, and the reason it is written down: the identity strategy silently produced nothing until the feed order was fixed.
Within one debounced batch, appearances must be observed before disappearances. A rename reports the old path as gone; feeding that first discards the identity mapping before the new path can claim it, and the rename is lost. The daemon obligation is:
- Observe every path in the batch that exists now
- Forget every path in the batch that no longer exists
Getting this backwards is invisible on Linux, because inotify also reports both names, and fatal on any platform that does not.
2. Windows (ReadDirectoryChangesW, notify 8)
Measured 2026-11-02, transcript docs/dev/measurement/probe/windows-2026-11-02.txt, from the probe that ships. Earlier runs are in the history and none describes the platform: the first predated cross-event pairing, so its empty pairing column described the probe; the second predated chain coalescing and the file id read through a handle, so it showed a double rename as two facts and identity as empty.
2.1 Raw events
Modify(Name(From)) a.heic
Modify(Name(To)) a.jpgNo Both event exists on this platform. A rename is always two events, each carrying one path, adjacent when the batch is small.
2.2 Scenario results
| Scenario | Pair | Identity | Raw events of note |
|---|---|---|---|
| Rename in place | a.heic -> a.jpg | same | From, To |
| Rename case only | fact, no intent | fact, no intent | Remove(Any) first |
| Rename stem and extension | none | none | From, To |
| Rename across directories | none | none | Remove(Any), Create(Any), Modify(Any) on the parent |
| Two renames in one window | e.heic -> e.webp | same | four events; coalesced to one fact |
| Rename over an existing file | f.heic -> f.jpg | same | Remove(Any) for the victim first |
| Twenty renames in one gesture | twenty facts | twenty facts | forty events, strictly interleaved |
| Rename then write | h.heic -> h.jpg, once | same | two Modify(Any) afterwards |
| Rename then delete | i.heic -> i.jpg | none | Remove(Any) afterwards |
| Rename while a job's files appear | j.heic -> j.jpg | none | the created directory and its contents appear; the hard link takes the identity |
2.3 What this establishes
- Pairing must work across events, not within one.
Correlator::rename_fromandrename_toexist for this. Before they did, this platform looked like it reported no renames at all - Interleaving is orderly. Twenty renames arrive as alternating
FromandTopairs, so a single pending slot would happen to work here; matching by directory and oldest first survives either interleaving, and is what the correlator does - Chains must be collapsed. A double rename arrives as two facts with an intermediate file the user never asked for. Converting the intermediate would re-encode twice, which is why
coalesceexists and why it lives in the library rather than in a caller - Moves are not renames on Windows, which satisfies the in-place rule for free. The opposite of Linux, where the same gesture arrives as a rename and is rejected only by the parent check
- Victims of a rename produce removals, so a removal is not a failure and not a trigger
rename then deleteproduces a fact here, exactly where Linux suppresses it: pairing names both paths while identity has no sighting of the target, because the target is gone. Dispatch therefore has to check that the target still exists and is a regular file, whichdocs/reference/watch.md§7 requires and this transcript demonstrates. The gesture is rare, but the failure it prevents is not: a deleted name can be recreated within the same window by an unrelated file, and dispatching on the name alone would convert bytes the user never touched- File identity comes from a handle.
stdmetadata has no file id on Windows, so the watcher reads the volume and file index throughFileIdInfo, withBY_HANDLE_FILE_INFORMATIONas the fallback, the same source the journal uses for a produced output. The probe measures it now, and it agrees with pairing wherever the target still exists. The hard-link rule indocs/reference/watch.md§7 is what keeps a link made beside the file from being read as a rename
2.4 Consequences
Three rules were written because of this platform, not guessed from it: cross-event pairing, chain coalescing within a batch, and a dispatch-time existence check. Linux needed none of them to work, and Linux could not have revealed any of them.
3. macOS (FSEvents, notify 8)
Measured 2026-11-02, transcript docs/dev/measurement/probe/macos-2026-11-02.txt. Paths in the transcript are normalised to <tmp>.
3.1 Raw events
Modify(Name(Any)) a.heic
Modify(Data(Content)) a.heic
Modify(Name(Any)) a.jpgOne event per path, never two, and no From, To or Both. A rename is indistinguishable from any other name change except by the paths themselves. Writes appear as Modify(Data(Content)), and a rename is usually followed by Modify(Metadata(Any)), because the system attaches attributes.
3.2 Scenario results
| Scenario | Pair | Identity |
|---|---|---|
| Rename in place | none | a.heic -> a.jpg |
| Rename case only | fact, no intent | fact, no intent |
| Rename stem and extension | none | none |
| Rename across directories | none | none |
| Two renames in one window | none | e.heic -> e.webp |
| Rename over an existing file | none | f.heic -> f.jpg |
| Twenty renames in one gesture | none | twenty facts |
| Rename then write | none | one fact |
| Rename then delete | none | none |
| Rename while a job's files appear | none | none, the hard link takes the identity |
3.3 What this establishes
- Identity is the only mechanism here. There is no pair of names to match, so
rename_fromandrename_toare dead code on this platform and the feed-order rule from §1.5 is load bearing rather than defensive - No fallback exists. On Linux and Windows a lost pair or a missing identity still leaves the other strategy. On macOS, a file whose identity is unreliable, such as one on a network mount or a dataless iCloud placeholder, is silently not converted. That is one more reason network filesystems are a separate stage rather than an afternoon of work
- Cross-directory moves are reported as renames and rejected only by the parent check, which is the third distinct platform behaviour for the same gesture: a rename on Linux, delete plus create on Windows, a rename-shaped event here
- The watched root is reported. Creating a subdirectory inside it delivers metadata events for the root itself,
Modify(Metadata(Any))and sometimesCreate(Folder)as well, and the daemon sees the same around the time a job creates its undo record. A directory is not a sighting, so the correlator forgets it; treating it as a file would make a directory whose name carries an extension a conversion, whichSighting::ofalready refuses - Our own link under the watch takes the identity. In the churn scenario the hard link is observed before the user's target, so the identity moves to a name in the subdirectory, where no in-place rename can be reported, and the target is then a second name the identity already has. That is why the daemon drops its state directory from the batch,
docs/reference/watch.md§7 - A rename followed by a delete yields nothing, because identity correlation refuses a fact whose target is gone. The dispatch-time existence check is therefore redundant here and necessary on Windows, which is why it lives in the dispatcher rather than in the correlator
- Paths must be canonical. FSEvents reports resolved paths, so
/vararrives as/private/var. A watch root that is a symlink, or a path that traverses one, would otherwise produce two identities for one file. The daemon canonicalises roots at registration and canonicalises event paths before lookup. This is also why the probe canonicalises its temporary root, or every printed path is absolute
4. BSD (kqueue)
Not yet measured, and not before the port. The backend reports a single-path Any event, which makes identity correlation load bearing, as on macOS.
5. Cross-platform summary
| Linux | Windows | macOS | BSD | |
|---|---|---|---|---|
| Rename as one event with both paths | yes (Both) | no | no | no (expected) |
Rename as separate From and To | yes, in addition | yes | no | no (expected) |
| Cross-directory move reported as rename | yes | no | yes | unknown |
| Rename followed by delete yields a fact | no | yes | no | unknown |
| Directory paths in the stream | a created directory only | a created directory, and the target's parent on a move | a created directory, and the watched root when one is created | unknown |
| File identity available | yes | yes, from a handle | yes | yes |
| Primary strategy | identity, pairing as backup | pairing, identity as backup | identity only | identity only (expected) |
| Rules the platform forced | duplicate suppression, feed order | cross-event pairing, chain coalescing, existence check | canonical paths; no fallback when identity is unavailable | pending |
No single event shape covers all three, so the correlator accepts a two-path event, a pair of events, and a batch of single-path events with no rename kind at all. The translation lives in one place, crates/retype-watch/src/lib.rs, and is asserted twice: by unit tests over synthetic events for every shape, including shapes the running platform never produces, and by an integration test against a real watcher for the three outcomes that matter, in crates/retype-watch/tests/rename_detection.rs. docs/reference/watch.md §7 records the resulting obligations.