Concepts
How the store works
The Nauro store is a plain-text, local-first project memory: human-readable markdown decision files plus a few state files on disk, with an in-memory BM25 index built per call for retrieval and an optional, fully local embedding augmenter. The core loop makes no external network calls of its own; the only model call in a session is the connected agent's own model reading the context Nauro hands it. This page documents the on-disk layout, the retrieval ranking, atomic write and snapshot mechanics, and the privacy posture, grounded in the actual source of Nauro-AI/nauro.
What the store is
- The store is the project's persisted context: architectural decisions (with rejected alternatives), current state, stack, and open questions, all as plain markdown on disk. There is no database in the local path.
- It is local-first. Per the README, the documented adopt/init/check workflow works on your machine with no account ("The steps above work fully on your machine with no account"); cloud sync is explicitly optional.
- The on-disk decision format is a single source of truth in nauro-core (
decision_model.format_decision/parse_decision); both the CLI and the hosted server serialize through it so the formats cannot drift. The scaffolded first decision is also emitted viaformat_decisionrather than a string template, so the format never diverges. - Operations are written against a minimal storage Protocol (
nauro_core.operations.store.Store) with six primitives:read_file,write_file,delete_file,list_decisions,read_decision, and the bulkread_decisions(the bulk analogue ofread_decision, which lets the hosted server fan decision reads out concurrently instead of one S3 GET at a time). The local CLI/stdio MCP supplies a filesystem implementation; the hosted server supplies its own (S3 + DynamoDB, per the Protocol docstring). The same operation code runs on both. - nauro-core has only four runtime dependencies (
bm25s>=0.2,PyStemmer>=2.2,pydantic>=2.0,PyYAML>=6.0) and is published separately (PyPInauro-core) so third-party tools can read or write the Nauro decision format.
@runtime_checkable
class Store(Protocol):
def read_file(self, path: str) -> str | None: ...
def write_file(self, path: str, content: str) -> None: ...
def delete_file(self, path: str) -> None: ...
def list_decisions(self) -> list[str]: ...
def read_decision(self, file_stem: str) -> str | None: ...
def read_decisions(self, stems: list[str]) -> dict[str, str | None]: ...On-disk layout
- A repo opts in by committing a small
<repo>/.nauro/config.jsonthat names the project it belongs to. Local mode:{"mode": "local", "id": <ulid>, "name": <str>, "schema_version": 1}; cloud mode adds"server_url". Theidis a 26-char Crockford-base32 ULID minted CLI-side for local projects (generate_ulidin repo_config.py); cloud IDs are server-minted. The loader rejects an unknownschema_version(currentREPO_CONFIG_SCHEMA_VERSION = 1). - The actual store content lives outside the repo, under
NAURO_HOME(default~/.nauro/, i.e.DEFAULT_NAURO_HOME = ".nauro"underPath.home()). In the canonical v2 layout the per-project directory is~/.nauro/projects/<id>/, keyed by the ULID; a legacy v1 layout keyed by name (~/.nauro/projects/<name>/) is still readable, andload_registry_v2refuses to read a v1 file (one-time manual migration). - Inside a project store,
scaffold_project_storecreates:project.md,state_current.md,stack.md,open-questions.md, adecisions/directory (seeded with001-initial-setup.md), and asnapshots/directory. - Decision files live in
decisions/and are namedNNN-slug.md, e.g.042-use-postgres.md. The number is zero-padded to 3 digits ({next_num:03d}); the slug is lowercased, runs of non-alphanumerics collapse to a single hyphen, leading/trailing hyphens are stripped, and if the result exceeds 60 chars it is truncated to 60 then trimmed back to the last hyphen withrsplit("-", 1)[0](_slugify,_SLUG_MAX_LENGTH = 60). - Filename constants are centralized in
nauro_core/constants.py:PROJECT_MD='project.md',STATE_CURRENT_FILENAME='state_current.md',STACK_MD='stack.md',OPEN_QUESTIONS_MD='open-questions.md',DECISIONS_DIR='decisions',SNAPSHOTS_DIR='snapshots',DECISION_HASHES_FILE='.decision-hashes.json'. (Legacy/aux state filenamesSTATE_MD='state.md',STATE_HISTORY_FILENAME='state_history.md',STATE_LEGACY_FILENAME='state.md'also exist.) - Each decision file is YAML frontmatter + markdown body. Required frontmatter:
date,confidence. Defaulted:version(1),status(active). Optional:decision_type,reversibility,source,files_affected,supersedes,superseded_by. The body has a# NNN — TitleH1 (em-dash separator), a## Decisionsection (the rationale), and an optional## Rejected Alternativessection with### namesubsections. The parser is strict (missing/unterminated frontmatter, missing H1, missing## Decision, malformed YAML, or frontmatter that is not a mapping all raise). - Canonical frontmatter key order (
_FRONTMATTER_ORDER):date,version,status,confidence,decision_type,reversibility,source,files_affected,supersedes,superseded_by.rejectedis body-rendered, not frontmatter. - Supersession is recorded by flipping the old decision's status to
supersededand settingsuperseded_by, while the new decision carriessupersedes. Refs are validated as plain integer strings ("70", not"070"or"D70"); leading zeros raise. - Two control-plane JSON files sit at the
NAURO_HOMEroot:registry.json(project registry; v2 is id-keyed, tracksmode+ optionalserver_url) andconfig.json(user settings: telemetry consent,anonymous_id,search.embeddings). A.decision-hashes.jsonindex inside each store backs exact-duplicate detection.
<repo>/.nauro/config.json # committed: {mode,id,name,schema_version[,server_url]}
~/.nauro/ # NAURO_HOME (default)
registry.json # project registry (v2: keyed by ULID)
config.json # user config (telemetry, search.embeddings)
projects/<id>/ # the project store
project.md
state_current.md
stack.md
open-questions.md
.decision-hashes.json # exact-dup hash index
decisions/
001-initial-setup.md
042-use-postgres.md # NNN-slug.md
snapshots/
v001.json # point-in-time JSON captures---
date: 2026-04-16
version: 1
status: active
confidence: high
decision_type: infrastructure
---
# 042 — Use Postgres
## Decision
Use Postgres for the primary datastore...
## Rejected Alternatives
### SQLite
Single-writer ceiling won't hold under concurrent API load.Retrieval: BM25 by default
- The default and always-available retrieval is lexical BM25, implemented in
nauro_core/search.pyon top of thebm25slibrary with a PyStemmer English stemmer (_stemmer = Stemmer.Stemmer("english")). Both are hard dependencies of nauro-core, so BM25 needs no optional install and no external service or API key. - The index is built in-memory per call, not persisted:
bm25s.tokenizeover the corpus,retriever.index(...), thenretriever.retrieve(query_tokens, k=...). For a prototype-scale store (a few hundred short decisions) this is cheap. There is no vector DB and no persisted index. - The indexed text per decision is its title + rationale:
corpus = [f"{d.title} {d.rationale}" for d in decisions]. Tokenization uses English stopwords and the shared stemmer; bm25s progress bars are disabled (show_progress=False) so they don't pollute the CLI surface. - Results are ranked by BM25 score descending and any hit with score <= 0 is dropped (the loop breaks on the first non-positive score). There is no fixed similarity threshold beyond that "score must be positive" cutoff.
- There are two BM25 entry points.
bm25_searchranks across all decisions and returnsnumber/title/date/status/relevance_snippet/score(used bysearch_decisions);bm25_retrieverestricts to active decisions only and returnsnumber/title/similarity/rationale_preview(used for conflict checking). Both round their scores to 3 decimals. check_decisionand Tier-2 proposal validation extend bm25s's default English stopword list with the tokenuse(TIER2_STOPWORDS = [*list(STOPWORDS_EN), "use"]and_CHECK_DECISION_STOPWORDS = [*list(STOPWORDS_EN), "use"]). Decision titles like "Use Postgres", "Use Redis" otherwise share the stemusewith nearly every decision and surface as false near-neighbours on every call.- Both retrieval surfaces exclude the scaffold-seeded first decision (
num == 1with title "Initial project setup") so Nauro's own bookkeeping entry never gates a real proposal. - The assessment line
check_decisionreturns is deterministic and built purely from retrieval facts (top match label, status, date, BM25 score); there is no model in the loop, and it directs the agent to callget_decisionbefore proposing.
corpus = [f"{d.title} {d.rationale}" for d in decisions]
corpus_tokens = bm25s.tokenize(corpus, stopwords="en", stemmer=_stemmer, show_progress=False)
retriever = bm25s.BM25()
retriever.index(corpus_tokens, show_progress=False)
k = min(limit, len(decisions))
query_tokens = bm25s.tokenize([query], stopwords="en", stemmer=_stemmer, show_progress=False)
results, scores = retriever.retrieve(query_tokens, k=k, show_progress=False)
# ... only hits with score > 0 are kept (the loop breaks on the first score <= 0)Optional local embeddings (off by default)
- Embeddings are an optional augmenter, not a replacement for BM25. They live in
nauro_core/embeddings.py, isolated from search.py so the BM25 path carries no embedding imports. - The model is
minishlab/potion-retrieval-32M(EMBEDDING_MODEL), a Model2Vec static embedding. The module comment is explicit: numpy-only, no torch, no ONNX runtime. The model loads locally and runs as a numpy matmul; there is no external service call and no API key. - It ships behind an optional extra:
nauro-core[embeddings] = model2vec>=0.3, numpy>=1.24.embeddings_available()returns False if model2vec/numpy aren't importable. - It is OFF by default.
resolve_embeddings_flag()(innauro/store/config.py) returns False unless theNAURO_EMBEDDINGSenv var or thesearch.embeddingsconfig key is truthy (1/true/yes/on, case-insensitive; a native bool from config is also accepted). Env wins over config. The kernel itself stays I/O-free; the adapter resolves the flag and passesuse_embeddingsintocheck_decision. - When enabled, retrieval is a union:
union_retrievereturns the BM25 top-k first, in BM25 order and shape, then appends any embedding-top-k decision BM25 didn't already surface. Embedding-sourced hits carrysimilarity=None(the static-embedding cosine is not on the BM25 score scale);check_decisionsurfaces them with score 0.0 to signal "not a BM25 match". - It is fail-open. If the dependency is absent or the model fails to load,
_get_modelshort-circuits (records_load_failed, logged once at WARNING) andunion_retrieve/embedding_poolreturn the BM25-only result. Withuse_embeddingsFalse,union_retrieveis byte-identical tobm25_retrieve. - Ranking inside
embedding_pool: encode title+rationale of each decision and the query, L2-normalize, take the dot product as cosine, argsort, take the last k and reverse for descending order, return the top-k decision numbers. The loaded model is memoized per process (in-memory only, not a persisted artifact); encoding is per call.
bm25_hits = bm25_retrieve(decisions, query_text, top_k=top_k, stopwords=stopwords)
if not use_embeddings:
return bm25_hits
from nauro_core.embeddings import embedding_pool
active = [d for d in decisions if d.status is DecisionStatus.active]
pool = embedding_pool(active, query_text, top_k=top_k)
if not pool:
return bm25_hits # fail-open: BM25-only
seen = {hit["number"] for hit in bm25_hits}
by_num = {d.num: d for d in active}
augmented = list(bm25_hits)
for num in pool:
if num in seen:
continue
d = by_num.get(num)
if d is None:
continue
seen.add(num)
augmented.append({"number": d.num, "title": d.title,
"similarity": None, "rationale_preview": d.rationale[:200]})def resolve_embeddings_flag() -> bool:
env_value = os.environ.get(NAURO_EMBEDDINGS_ENV) # NAURO_EMBEDDINGS
if env_value is not None:
return _is_truthy(env_value)
return _is_truthy(get_config(_EMBEDDINGS_CONFIG_KEY)) # "search.embeddings"
# _is_truthy: True for bool True, or "1"/"true"/"yes"/"on" (case-insensitive); default OFFWrites: atomic, locked, and best-effort
- Decision and markdown writes go through
FilesystemStore.write_file, whichmkdir -p's the parent, takes a per-targetFileLock(a sibling<name>.lock), andwrite_text's the content. Reads are unlocked. - Reads guard against path traversal:
FilesystemStore.read_fileresolves the target and confirms it isrelative_tothe store root before returning, otherwise returns None (also None if the target doesn't exist or isn't a file). - There is no cross-file lock serializing decision numbering.
_next_decision_numcomputesmax(existing num) + 1, so two racing writers can mint the same number; this is an explicit design choice, and collisions are caught and repaired on the next sync-pull. - Control-plane JSON files (
registry.json,config.json, per-repoconfig.json) are written withatomic_write_text: write to a.tmpsibling, optional chmod on the tmp file before the rename, thenos.replaceover the target. The rename is atomic on a single filesystem so a reader never sees a partial file. There is deliberately no fsync; crash-durability is an explicit non-goal. config.jsonis written owner-only (mode=0o600insave_config), and the chmod is applied to the tmp file before the rename so the target is never momentarily world-readable.propose_decisionruns a validation pipeline before any write: Tier 1 structural screening (empty title/rationale, rationale shorter thanMIN_RATIONALE_LENGTH=20, invalid confidence, exact SHA-256 hash duplicate, same-title duplicate of an existing active decision) can reject; Tier 2 BM25 similarity is advisory only and does not block the write. Supersede is a two-write sequence (new decision, then flip the old tosupersededwithsuperseded_by); a failure on the second write returns a structured half-state error and leaves the first write intact for sync-repair.- After an exact-clean write, the in-store
.decision-hashes.jsonindex is updated (SHA-256 of normalizedtitle|rationale, lowercased + stripped) so subsequent Tier 1 checks catch the duplicate.
def write_file(self, path: str, content: str) -> None:
target = self._store_path / path
target.parent.mkdir(parents=True, exist_ok=True)
lock = target.with_name(target.name + ".lock")
with FileLock(str(lock)):
target.write_text(content)def atomic_write_text(path: Path, text: str, *, mode: int | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(text)
if mode is not None:
os.chmod(tmp, mode) # e.g. 0o600 before the rename
os.replace(tmp, path) # atomic on a single filesystem; no fsyncSnapshots: point-in-time captures with logarithmic pruning
- A snapshot is a single JSON file capturing the full store (all root
*.mdfiles plus everydecisions/*.md, keyed by store-relative filename).capture_snapshotreads the files, serializes, writessnapshots/vNNN.json(zero-padded to 3 digits, e.g.v001.json), and prunes after every capture. - Serialization is a pure function in
nauro_core/snapshot.py(serialize_snapshot): no filesystem access, nodatetime.now, no regex; the caller owns the clock and passes an ISO timestamp. Both the local CLI capture path and the remote capture path build snapshots through it so the two on-disk formats cannot drift. - Canonical key order:
schema_version,version(only when supplied),timestamp,trigger,trigger_detail,token_count,files.SNAPSHOT_SCHEMA_VERSION = 1; pre-field snapshots read back asLEGACY_SCHEMA_VERSION = 0vianormalize_snapshot.token_countis derived assum(len(content)) // CHARS_PER_TOKENwithCHARS_PER_TOKEN = 4(a rough heuristic, not a real tokenizer). - Pruning uses logarithmic spacing: keep every snapshot in the last 7 days (
PRUNE_KEEP_ALL_DAYS = 7); one per day in the last 30 (PRUNE_DAILY_DAYS = 30); one per week up to 180 days (PRUNE_WEEKLY_DAYS = 180); one per month older than that. The latest snapshot is always kept. - Auto-pin preserves the decision chain: any snapshot whose
decisions/file count increased versus the previous snapshot is pinned and never pruned. A snapshot with an unparseable (user-editable) timestamp is skipped during pruning and left on disk untouched rather than breaking the prune. - Snapshots back
diff_since_last_session:resolve_diff_snapshotsassembles a(baseline, latest, cutoff_date_used)tuple (by day-window whendaysis given, else previous-to-latest) and the kernel diffs them; the I/O sits in the adapter, outside the locked Store protocol.
files = {}
for md in sorted(store_path.glob("*.md")):
files[md.name] = md.read_text()
decisions_dir = store_path / DECISIONS_DIR
if decisions_dir.exists():
for md in sorted(decisions_dir.glob("*.md")):
files[f"{DECISIONS_DIR}/{md.name}"] = md.read_text()
snapshot = serialize_snapshot(
timestamp=datetime.now(timezone.utc).isoformat(),
trigger=trigger, trigger_detail=trigger_detail,
files=files, version=next_version,
)
out_path = snapshots_dir / f"v{next_version:03d}.json"
out_path.write_text(json.dumps(snapshot, indent=2) + "\n")
_prune_snapshots(snapshots_dir)Privacy posture
- The local store's core loop makes no external network calls. Retrieval (BM25, and the optional embeddings) runs entirely on your machine; the embedding model is a local numpy-only static model with no API key. The only model call in a session is the connected agent's own model reading the context Nauro provides.
- Local-first: per the README, the documented adopt/init/check steps work fully on your machine with no account. Cloud sync is opt-in (
nauro auth login+nauro link --cloud+nauro sync). - When cloud sync is enabled, PRIVACY.md states only project context (decisions, state, open questions, not source code) is stored encrypted in AWS S3 (us-east-1, SSE-S3), separated per user by an S3 key prefix derived from auth identity. There is no self-service deletion command at this time; removal is via support.
- Over remote MCP (Claude AI, Perplexity, ChatGPT, or another MCP client), your project context is read from S3 and delivered to the connected AI tool; that tool's own data policies then govern what it does with the response. Nauro does not monitor downstream use.
- Telemetry is anonymous product-usage only, default opt-in via a one-line first-run prompt. Only four event types fire (
cli.command_invoked,mcp.tool_called,sync.completed,project.created) with a closed property set; an explicit "Never sent" list covers decision titles/rationale/content, file paths, repo names, project names, MCP tool arguments and return values, stack traces, command-line args, IP, and geolocation. - Opt out with
NAURO_TELEMETRY=0(suppresses telemetry and the first-run prompt) ornauro telemetry disable(persists in~/.nauro/config.json). Analytics go to PostHog (cloud; Nauro does not currently support pointing at a self-hosted instance); Lambda operational metrics go to CloudWatch and never include user content.
Events (only these, with only these properties):
cli.command_invoked { command, success, duration_bucket, nauro_version, os }
mcp.tool_called { tool_name, transport, success, duration_bucket }
sync.completed { snapshot_count, duration_bucket, bytes_bucket }
project.created { schema_version }
Never sent: decision titles, rationale, content; file paths;
repo names; project names; MCP tool arguments and return values;
stack traces; command-line arguments; IP address; geolocation.Key facts
- Default retrieval is BM25 (bm25s + PyStemmer English stemmer), built in-memory per call; both are required nauro-core deps, so no optional install, no external service, no API key.
- Indexed text per decision is title + rationale; results sorted by BM25 score descending and any hit with score <= 0 is dropped. Scores are rounded to 3 decimals.
- Optional embeddings model:
minishlab/potion-retrieval-32M(Model2Vec static, numpy-only, no torch, no ONNX), shipped as thenauro-core[embeddings]extra (model2vec>=0.3,numpy>=1.24). - Embeddings are OFF by default; enabled only via
NAURO_EMBEDDINGSenv var or thesearch.embeddingsconfig key (truthy tokens: 1/true/yes/on, case-insensitive, or a native bool). Env wins over config. - With embeddings on, retrieval is a BM25-then-embedding union: BM25 hits first in their existing shape/order, embedding-only hits appended with
similarity=None. Fail-open to BM25-only if the dep is absent or the model fails to load. - Store filenames (
nauro_core/constants.py):project.md,state_current.md,stack.md,open-questions.md,decisions/,snapshots/,.decision-hashes.json. - Decision files are named
NNN-slug.md, number zero-padded to 3 digits; slug lowercased, non-alphanumerics collapsed to single hyphens, capped at 60 chars (_SLUG_MAX_LENGTH). - Decision frontmatter is, required:
date,confidence; defaulted:version=1,status=active; optional:decision_type,reversibility,source,files_affected,supersedes,superseded_by. Body:# NNN — Title,## Decision, optional## Rejected Alternatives. - DecisionStatus enum is exactly {active, superseded}; confidence is {high, medium, low}; DecisionType is {architecture, api_design, infrastructure, pattern, refactor, data_model}; Reversibility is {easy, moderate, hard}; DecisionSource is {mcp, commit, compaction, manual, import}; MIN_RATIONALE_LENGTH = 20.
- Canonical store root:
~/.nauro/projects/<id>/(v2, ULID-keyed) underNAURO_HOME(default~/.nauro/); repo opts in via committed<repo>/.nauro/config.json{mode,id,name,schema_version[,server_url]}. - Decision writes take a per-target FileLock; reads are unlocked and path-traversal-guarded. No cross-file lock on numbering; collisions are repaired on next sync-pull.
- Control-plane JSON (
registry.json,config.json, repoconfig.json) usesatomic_write_text: tmp + chmod-before-rename +os.replace, no fsync (crash-durability an explicit non-goal).config.jsonis mode 0o600. - Snapshots are
snapshots/vNNN.jsoncapturing all root*.md+decisions/*.md;SNAPSHOT_SCHEMA_VERSION=1, legacy reads default to 0;token_count = total chars // CHARS_PER_TOKEN (4). - Snapshot pruning is logarithmic (7d keep-all / 30d daily / 180d weekly / older monthly); snapshots whose
decisions/count increased are auto-pinned and never pruned; latest always kept. - Per PRIVACY.md cloud sync stores only context (decisions/state/open questions, not source code) encrypted in AWS S3 us-east-1 SSE-S3, separated per user by an S3 key prefix. Local core loop makes no external network calls.
- Telemetry is default opt-in; four events only; explicit never-sent list (no titles/rationale/content/paths/repo names/project names/tool args/return values/stack traces/CLI args/IP/geo). Opt out:
NAURO_TELEMETRY=0ornauro telemetry disable.