# Observer — the change-detection layer

## Purpose

Notice that bytes on disk changed, and say so exactly once. The observer is
the single place in the runtime where a local change is born. It never syncs,
never merges, never decides truth — it emits facts for the layers above.

## Axioms

1. **Registration is coverage, and every path has exactly one owner —
   permanently.** One observer root per enrolled workspace, repo, or
   single ground (the smallest root there is: one channel, relPath `''`,
   a parent-directory doorbell; a file under the file laws, or an
   explicitly enrolled pointer under the link laws). No caps, no
   side-effect watchers, no "watched because open". A root's identity is
   its rootId and its path is only the current address: each root
   carries its own physical identity (a dir root its inode, a
   single-ground root its channel row's), and enrollment rescues moved
   ground by continuity — unique inode match against a path-orphan
   rebinds the address (`root.moved`, the whole family in ONE store
   transaction), every channel intact; a lookalike at the old address
   is new ground. A scan's evidence horizon is the root's ground, and
   the root's own address is the horizon's edge: ground missing there —
   or a directory root's address wearing a different identity — is
   undecidable (moved? deleted? replaced?), so the scan CONCLUDES
   NOTHING about identity. IDENTITY PERSISTS; LOCAL PRESENCE IS
   SEPARATE EVIDENCE: the undecided address flips one durable `present`
   bit and says so — `root.unavailable` on the way down,
   `root.available` on the way back, one symmetric pair at one choke
   point, never a lifecycle verdict and never a grammar of unrelated
   events. Beliefs freeze, the referee convicts the divergence, and
   the next enrollment moment decides: naming the new address rescues,
   re-naming this one adopts the ground, and explicit removal is the
   only deletion. THE PARENT OBSERVES ADDRESS CHANGES; THE ENTITY
   OBSERVES CONTENT CHANGES: every top-level root also carries a
   non-recursive doorbell on its parent directory, and that doorbell
   HEARS EVERY EVENT THERE — a rename may lawfully deliver only the NEW
   name, so a name filter would decide the answer before looking; the
   cheap address check is what decides (an intact, present address
   returns in one lstat). An undecided address gets ONE DECISION, AT
   EVERY ENTRANCE: the parent doorbell and the root's own scans (the
   settle scan racing a slow delivery, the catch-up scan at start) can
   learn of one rename in either order, and the verdict may never
   depend on which arrived first — whichever entrance finds the
   address undecided makes the same directed look before concluding
   anything, so a followed move has no unavailable moment regardless
   of arrival order, and a same-parent rename made while nothing ran
   is followed at start. The rename is followed for EVERY kind when
   WITNESSED CONTINUITY EVIDENCE claims it — the root's
   kind-appropriate identity as of the last completed scan: a dir root
   its own inode, a single ground its channel row's (which absorbs
   atomic-save churn each time a scan witnesses it) — under the
   exactly-one rule: one candidate of the root's own shape in the
   parent, or no conclusion. The path still claims first (file ground
   is only undecided when nothing file-shaped holds the address),
   ambiguity — two entries wearing one inode, a hardlink beside a
   rename — refuses to guess, and so does MISSING evidence: an atomic
   save renamed away inside its own settle window leaves ground no scan
   ever witnessed (new inode, new bytes, nothing to tie it to the
   channel), so the follow refuses and even a later re-add of the new
   address is honestly a NEW identity — the follow rides evidence,
   never clairvoyance. A departure beyond the parent is honest absence,
   LIVE: `root.unavailable` on the same delivery, reattachment the
   explicit act that names the new address. The remaining honest edge: a
   move BEYOND THE PARENT made while nothing was running, or to ground
   nothing watches,
   stays unavailable until an enrollment moment names it (a move INTO
   registered ground is adopted by that tree's evidence, one level up). A `.git` claims its
   subtree, and
   territory is decided by disk, not by callers: a directory with a `.git`
   enrolls as repo territory; a `.git` appearing inside a live files root
   closes the file channels under it and births a repo root
   (`repo.discovered`); a `.git` vanishing returns the ground to files
   (`repo.removed`) and the channels are reborn. A repo's raw changes travel
   as one Card + Checkpoint state, never as thousands of realtime file
   events; its registered child channels reconcile only after that state
   settles. Enrollment names ground
   by ADDRESS: the parent chain resolves, the last segment is inspected,
   never traversed — an explicitly enrolled symlink enrolls as the
   pointer itself.
2. **The doorbell is a hint, never a fact — but a hint only ever raises
   scrutiny.** One OS watcher per outermost tree (coverage.js — see
   docs/watchers.md): enclosed roots hold no handles; the encloser
   forwards their dings, rebased, synchronously. OS watchers are lossy
   by contract. Truth is always:
   settle → walk → compare → hash. Stats may excuse a file from hashing
   only when all three hold: nothing named it, its stats are identical,
   and its mtime is strictly older than the moment we last read its bytes
   (git's racily-clean rule). A file the doorbell named is always hashed —
   timestamps can be backdated, a delivered event cannot.
3. **Every scan is the same code, and every scan has an event.** There is no
   clock. `scanNow` runs at exactly four moments: enrollment (birth), start
   (catch-up for the window we were dead), watcher death (rebuild the
   doorbell, reconcile the gap once), and whenever a consumer asks — a sync
   session opening, a document opening, an app regaining focus are events
   too, and they belong to the layer above. A freshly registered repo is the
   one adapter-defined birth exception: its Card + Checkpoint is already the
   complete first observation, so it arms Watch without scanning every child
   again. Restart still performs the ordinary conservative catch-up scan.
4. **Echo suppression is content, not bookkeeping.** A writer records its own
   write (`noteWrite`); a scan that finds the recorded hash finds nothing.
5. **Identity has two axes, never mixed — and KIND decides which axis
   testifies first.** `fileId → path/inode` answers "which file";
   `fileId → contentHash` answers "did it change". For files the path
   claims the channel — an atomic save replaces the inode in place, so
   the address must absorb that churn — and the inode only rescues
   path-orphans (renames), exactly-one match both sides. Directories are
   channels under the same two rules in the OPPOSITE order, minus
   content: nothing replaces a directory's inode in place, so the inode
   claims first (two directories exchanging names cross by inode,
   exactly like the files inside them) and the path claims only
   recreation — a never-seen inode under an old name is the same
   channel, silently retagged. Nothing to hash: a directory's meaning
   (membership) is derived by the layers above, so empty folders exist
   and folder renames — including exchanges — survive by inode. A
   symlink is a channel in the FILES' order for the file reason (a
   retarget is unlink + recreate, so the address absorbs the inode
   churn; `mv` preserves the inode, so the inode rescues renames) whose
   whole payload is the written target string, read every scan and NEVER
   followed — the real target is watched at its real enrolled address,
   and the edge-following enrollment operation (`edges.js`), not any
   walk, is what turns unclaimed targets into ground. Every root kind
   exposes its edges (`linkEdges`): files ground from detected truth,
   repo ground from a fresh worktree census — a repo's raw changes travel
   as one Card + Checkpoint state, but its edges are enrollment-policy inputs like any
   other root's.

## Shape

- `watch.js` — recursive OS doorbell (macOS FSEvents, Windows RDCW, Linux
  inotify via Node ≥20), plus `watchDir`: the non-recursive doorbell
  that hears EVERY event in one directory and delivers the raw name —
  filtering is the consumer's business, because a rename may deliver
  only the new name. Two consumers ride it: the single-file root's main
  doorbell (the file's own inode goes deaf on atomic saves; the parent
  directory does not — events naming the file ring content, foreign
  names ring the cheap address check) and every top-level root's
  ADDRESS doorbell (the parent observes address changes; the entity
  observes content changes). Allowed to die; death is an event — the
  owner rebuilds the doorbell and reconciles once.
- `scan.js` — walk a root, and the pure compare functions: `diffRootState`
  for files, `diffDirState` for directories (same rules, no content),
  `diffLinkState` for symlinks (the file rules, target string as payload);
  `censusLinks` reads a repo worktree's symlink edges fresh (stored
  nowhere — git owns a repo's memory); `gitDirTruth` is the three-state
  territory look every consumer asks.
- `apply.js` — where a compare result becomes truth: events published
  and store rows landed, deterministic order, one applier per channel
  kind. The content read is injected, because unsettled suspicion
  surviving onto the root's retry is the wiring's law.
- `continuity.js` — a root's WITNESSED physical identity, the
  moved-root claim, the undecided-address verdict, and the presence
  bit's only two writers. Questions only: the actions a verdict
  triggers (rebind, follow) re-aim watchers, so they live in the wiring.
- `verify.js` — the referee, deliberately built from none of the
  machinery above (see Verification below).
- `store.js` — the layer's whole memory: `observer_roots` (with each
  root's own `device, inode` — identity is permanent, the address is
  not, and a family of roots changes address as ONE transaction — and
  its durable `present` bit: presence survives a restart),
  `observer_files`
  (`file_id, rel_path, device, inode, size, mtime, content_hash`),
  `observer_dirs` (`dir_id, rel_path, device, inode`), and
  `observer_links` (`link_id, rel_path, device, inode, target`), on an
  injected database handle, stamped with its schema generation (5).
- `index.js` — the wiring, and only the wiring: enroll, settle, scan,
  emit. It owns what the pieces above may not — when a look happens
  (the settle timer and its backoff), what a doorbell means, territory
  births and burials, and the rescue actions that re-aim watchers.
- `edges.js` — the ratified enrollment policy acting on every root's
  edges (`linkEdges` — repo roots via census, single-ground pointer
  roots via their own one edge): adding a workspace authorizes every
  reachable real target, so this operation resolves each pointer to
  real ground and enrolls EXACTLY the target the link names — a repo
  only when the named target IS the repo root; a file as a
  single-ground root, never its parent — to a fixpoint, idempotent by
  real path (shared targets deduplicate, cycles terminate), add-only
  (a retarget enrolls the new ground and never removes the old). Two
  refusals, both reported: ground the enrollment policy excludes
  (`observer.shouldEnroll` — the SAME policy every route asks, about
  the COMPLETE candidate path, so a benign name inside excluded ground
  like `.ssh/config` is still refused; an edge is never a way around
  it) and a target containing enrolled ground. Enrollment FAILURES are
  never silence: they propagate, the boundary above stays dirty, and
  the retry decides (a vanished target is broken ground next pass).
  The registration boundary above (`registration/`) is what makes
  watched ground REGISTERED ground — a tree per top-level root, keyed
  by permanent rootId — and owns derived link resolution across trees
  (answering with the OWNING entity: a repo boundary changes realtime
  semantics, but an address in it resolves to its registered child).

The observer imports nothing from the rest of the runtime. Enrollment policy
(`shouldEnroll`) is injected; the default is the workspace inclusion rules.

## Events

- `file.created { rootId, fileId, path, hash, size }`
- `file.changed { rootId, fileId, path, hash, previousHash, size }`
- `file.renamed { rootId, fileId, from, to }`
- `file.deleted { rootId, fileId, path }`
- `dir.created { rootId, dirId, path }` / `dir.renamed { rootId, dirId,
  from, to }` / `dir.deleted { rootId, dirId, path }` — no `dir.changed`:
  a directory has no bytes of its own, and a recreation under the same
  name is the same channel wearing a new inode (silent).
- `link.created { rootId, linkId, path, target }` / `link.changed {
  rootId, linkId, path, target, previousTarget }` (a retarget — the
  written address is the payload) / `link.renamed { rootId, linkId,
  from, to }` / `link.deleted { rootId, linkId, path }` — recreation
  with the same address is silence, the same pointer wearing a new
  inode.
- `repo.discovered { rootId, path }` / `repo.removed { rootId, path }` —
  territory handoff; the consumer attaches or drops a follower.
- `root.moved { rootId, from, to }` — continuity rescued a moved root:
  same identity, new address, channels untouched; the follow-up scan
  proves silence. Consumers re-aim address-derived state, nothing else.
- `root.unavailable { rootId, path }` / `root.available { rootId,
  path }` — the symmetric presence pair: identity persists, local
  presence is separate evidence. Unavailable is never deleted (no
  lifecycle is concluded), available is never new (the same channels
  wake), and a followed move emits neither — continuity never broke.
- `repo.changed { rootId, areas: ['git'|'worktree'] }` — a raw doorbell,
  forwarded instantly with no settling: quieting and identity belong to the
  follower (repocard/follow.js), and a delayed doorbell would blind its
  torn-read guard. On any catch-up scan a repo root rings unconditionally —
  the observer cannot know what happened while nobody watched; the
  follower's ids turn "maybe" into silence.

The store is the identity half of detection's memory: `file_id`/`dir_id`
are the durable channel identities that survive renames, and the root's own
id is the store's WITNESS — the registry side binds it, validates it before
every canonical proposal, and convicts an unknown witness loudly, so a
lost store is never mistaken for a fresh start. Loss is judged by
completeness too: an established store either has its whole schema or it
convicts at open — a silently recreated table would end every channel it
held behind a witness that still checks out. Bytes are the disposable
half: re-detection is idempotent because equal hashes are silence.
Committed store state is always some truth disk actually had: the file loop
satisfies this per row (file renames never cross — the address claims first,
so a rename only ever targets a row-free slot), and a directory batch, whose
movers may cross, lands as ONE transaction with doorbells rung only after
truth has landed — a crash mid-batch changes nothing, and the next scan
re-detects the same batch.

## The referee

`verify()` re-reads enrolled ground and reports any divergence between disk
truth and the observer's beliefs — including unclaimed territory: a `.git`
on disk with no registered repo root. It is the referee of the INCREMENTAL
detector, and only that: it shares no part of the path it judges — no stats
shortcut, no doorbells, no dirty sets, no compare functions, no applier —
so no belief the scan reached by shortcut is ever confirmed by that same
shortcut.

It does share detection's PRIMITIVES: the same enrollment policy, the same
walk, the same truth adapter. That is deliberate — one definition of each,
none left to drift — and it bounds the verdict exactly: `verify()` grades
the machinery, never the primitives it asks. Grading those is the oracle
referee's job (`tests/entity-oracle/referee.js`), which enumerates and
hashes with its own code and none of this layer's.

So the two referees answer different questions and run in different places:
the storms and the oracle exams end with both, while an audit or shadow
mode in production has only `verify()` — the oracle is test ground and
production imports none of it. An empty `verify()` means no change went
undetected in the ground the primitives reported; that the primitives are
themselves right is the oracle's proof, not this one's.
