Skip to content
nauro

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 via format_decision rather 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 bulk read_decisions (the bulk analogue of read_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 (PyPI nauro-core) so third-party tools can read or write the Nauro decision format.
python · store.py
@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.json that names the project it belongs to. Local mode: {"mode": "local", "id": <ulid>, "name": <str>, "schema_version": 1}; cloud mode adds "server_url". The id is a 26-char Crockford-base32 ULID minted CLI-side for local projects (generate_ulid in repo_config.py); cloud IDs are server-minted. The loader rejects an unknown schema_version (current REPO_CONFIG_SCHEMA_VERSION = 1).
  • The actual store content lives outside the repo, under NAURO_HOME (default ~/.nauro/, i.e. DEFAULT_NAURO_HOME = ".nauro" under Path.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, and load_registry_v2 refuses to read a v1 file (one-time manual migration).
  • Inside a project store, scaffold_project_store creates: project.md, state_current.md, stack.md, open-questions.md, a decisions/ directory (seeded with 001-initial-setup.md), and a snapshots/ directory.
  • Decision files live in decisions/ and are named NNN-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 with rsplit("-", 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 filenames STATE_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 — Title H1 (em-dash separator), a ## Decision section (the rationale), and an optional ## Rejected Alternatives section with ### name subsections. 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. rejected is body-rendered, not frontmatter.
  • Supersession is recorded by flipping the old decision's status to superseded and setting superseded_by, while the new decision carries supersedes. Refs are validated as plain integer strings ("70", not "070" or "D70"); leading zeros raise.
  • Two control-plane JSON files sit at the NAURO_HOME root: registry.json (project registry; v2 is id-keyed, tracks mode + optional server_url) and config.json (user settings: telemetry consent, anonymous_id, search.embeddings). A .decision-hashes.json index inside each store backs exact-duplicate detection.
text · directory tree
<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
markdown · 042-use-postgres.md
---
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.py on top of the bm25s library 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.tokenize over the corpus, retriever.index(...), then retriever.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_search ranks across all decisions and returns number/title/date/status/relevance_snippet/score (used by search_decisions); bm25_retrieve restricts to active decisions only and returns number/title/similarity/rationale_preview (used for conflict checking). Both round their scores to 3 decimals.
  • check_decision and Tier-2 proposal validation extend bm25s's default English stopword list with the token use (TIER2_STOPWORDS = [*list(STOPWORDS_EN), "use"] and _CHECK_DECISION_STOPWORDS = [*list(STOPWORDS_EN), "use"]). Decision titles like "Use Postgres", "Use Redis" otherwise share the stem use with nearly every decision and surface as false near-neighbours on every call.
  • Both retrieval surfaces exclude the scaffold-seeded first decision (num == 1 with title "Initial project setup") so Nauro's own bookkeeping entry never gates a real proposal.
  • The assessment line check_decision returns 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 call get_decision before proposing.
python · search.py
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() (in nauro/store/config.py) returns False unless the NAURO_EMBEDDINGS env var or the search.embeddings config 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 passes use_embeddings into check_decision.
  • When enabled, retrieval is a union: union_retrieve returns 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 carry similarity=None (the static-embedding cosine is not on the BM25 score scale); check_decision surfaces 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_model short-circuits (records _load_failed, logged once at WARNING) and union_retrieve / embedding_pool return the BM25-only result. With use_embeddings False, union_retrieve is byte-identical to bm25_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.
python · union_retrieve
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]})
python · resolve_embeddings_flag
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 OFF

Writes: atomic, locked, and best-effort

  • Decision and markdown writes go through FilesystemStore.write_file, which mkdir -p's the parent, takes a per-target FileLock (a sibling <name>.lock), and write_text's the content. Reads are unlocked.
  • Reads guard against path traversal: FilesystemStore.read_file resolves the target and confirms it is relative_to the 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_num computes max(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-repo config.json) are written with atomic_write_text: write to a .tmp sibling, optional chmod on the tmp file before the rename, then os.replace over 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.json is written owner-only (mode=0o600 in save_config), and the chmod is applied to the tmp file before the rename so the target is never momentarily world-readable.
  • propose_decision runs a validation pipeline before any write: Tier 1 structural screening (empty title/rationale, rationale shorter than MIN_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 to superseded with superseded_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.json index is updated (SHA-256 of normalized title|rationale, lowercased + stripped) so subsequent Tier 1 checks catch the duplicate.
python · filesystem_store.py
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)
python · _atomic.py
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 fsync

Snapshots: point-in-time captures with logarithmic pruning

  • A snapshot is a single JSON file capturing the full store (all root *.md files plus every decisions/*.md, keyed by store-relative filename). capture_snapshot reads the files, serializes, writes snapshots/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, no datetime.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 as LEGACY_SCHEMA_VERSION = 0 via normalize_snapshot. token_count is derived as sum(len(content)) // CHARS_PER_TOKEN with CHARS_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_snapshots assembles a (baseline, latest, cutoff_date_used) tuple (by day-window when days is given, else previous-to-latest) and the kernel diffs them; the I/O sits in the adapter, outside the locked Store protocol.
python · snapshot.py
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) or nauro 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.
text · telemetry events
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 the nauro-core[embeddings] extra (model2vec>=0.3, numpy>=1.24).
  • Embeddings are OFF by default; enabled only via NAURO_EMBEDDINGS env var or the search.embeddings config 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) under NAURO_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, repo config.json) uses atomic_write_text: tmp + chmod-before-rename + os.replace, no fsync (crash-durability an explicit non-goal). config.json is mode 0o600.
  • Snapshots are snapshots/vNNN.json capturing 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=0 or nauro telemetry disable.