Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The shape

A library first. The CLI, the HTTP server and the MCP server are thin consumers of it. Agents are clients, not the engine.

crates/
  centinel-core/    domain model · store · config · op registry · ops · rendering
  centinel-macros/  the #[op] attribute
  centinel/         the binary: CLI, HTTP, MCP
  centinel-whisper/ the transcription worker — links whisper.cpp, and nothing else may
docs/
  SPEC.md           the settled specification
  research/         ~3,850 lines, ~450 primary-source citations
centinel.toml       what to collect, and where to keep it
~/.centinel/        the store, by default

One process, six stages

  per source   discover → collect                      network-bound, per-host paced
  then once    extract → transcribe → index → embed    CPU-bound, model-backed

Each stage is a chapter in this part of the book, in that order.

Where the variation is quarantined

Two ideas carry most of the weight, and both are about keeping a difference in one place.

Source is a trait, not an entity with a kind field. A crawled website and a YouTube channel differ in enumerate, acquire and change_signal. Everything downstream is one shared model. The alternative is a kind field and a match on it at every stage, which is what the codebase actually had before the trait had implementations — nine sites a third kind would have had to find.

A Resource is an address, not a thing in the world. The January 14 council meeting reachable as a Granicus RSS item, an HTML page, a Legistar Matter and a YouTube video is four Resources, and the model makes no claim they are related. Identity resolution across access paths is fuzzy, and a wrong merge silently corrupts the record. Four honest rows beat one confident wrong one.

The half that does not vary lives in acquire: one loop that derives the work list from the log, turns refusals into a status, and keeps the counters — for any Source. So discover and collect are single verbs that name what happens rather than how. There is no centinel youtube, and a third Source kind adds no verb either.

The same shape, four times

The codebase reaches one conclusion repeatedly, and it is worth naming once because everything in this part is an instance of it:

A registry is a list whose elements answer for themselves. It holds no match.

WhereThe list
content kindsmagic bytes: (signature → kind), each element answering for itself
readersreaders_for(kind) — an ordered list per content kind, tried in order
strategieseach strategy tests the seed itself; nothing dispatches on a name
opslink-time registration; the binary names no individual op, it iterates

The failure mode is identical every time it is got wrong: adding a case means edits in several places, and the compiler asks for none of them. One missing arm meant every caption track landed on disk as .bin. Another meant a fallback reader was unreachable for the 168 documents it was written for.

Depth, and where the seams are

The interface is the test surface. acquire’s loop is tested through the Source trait by a scripted adapter, which is how resumption, liveness-on-refusal and multi-artifact addresses became testable without standing up HTTP or yt-dlp.

One adapter is a hypothetical seam; two is a real one. The Source trait was drawn before the second adapter existed, and its first shape was wrong: fetch(&Resource) -> Fetched had no possible implementation for a video, which is one address holding metadata, captions and audio. So acquire returns a list.

Next: The store.